Ver código fonte

Add a generic fan entity.

Use it as the primary entity for the Goldair fan.
Moved legacy "fan" implementation to "legacy_fan" to avoid namespace clash.
Jason Rumney 4 anos atrás
pai
commit
bd6ec7fc89

+ 5 - 1
README.md

@@ -30,7 +30,7 @@ Note that devices sometimes get firmware upgrades, or incompatible versions are
 - Remora pool heatpumps (partially also BWT FI 45, which differs in its presets)
 
 #### Fans
-- Goldair GPCF315 fans
+- Goldair GCPF315 fans
 
 #### Dehumidifiers
 - Goldair GPDH420 dehumidifiers
@@ -102,6 +102,10 @@ You can easily configure your devices using the Integrations UI at `Home Assista
 
     _(boolean) (Optional)_ Whether to surface this device as a humidifier device (supported only for humidifiers and dehumidifiers)
 
+#### fan
+
+    _(boolean) (Optional)_ Whether to surface this device as a fan device (supported for fans, humidifiers and dehumidifiers)
+
 ## Heater gotchas
 
 Goldair GPPH heaters have individual target temperatures for their Comfort and Eco modes, whereas Home Assistant only supports a single target temperature. Therefore, when you're in Comfort mode you will set the Comfort temperature (`5`-`35`), and when you're in Eco mode you will set the Eco temperature (`5`-`21`), just like you were using the heater's own control panel. Bear this in mind when writing automations that change the operation mode and set a temperature at the same time: you must change the operation mode _before_ setting the new target temperature, otherwise you will set the current thermostat rather than the new one.

+ 7 - 1
custom_components/tuya_local/__init__.py

@@ -19,6 +19,7 @@ from .const import (
     CONF_CLIMATE,
     CONF_DEVICE_ID,
     CONF_DISPLAY_LIGHT,
+    CONF_FAN,
     CONF_HUMIDIFIER,
     CONF_SWITCH,
     DOMAIN,
@@ -69,7 +70,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
         hass.async_create_task(
             hass.config_entries.async_forward_entry_setup(entry, "humidifier")
         )
-
+    if config.get(CONF_FAN, False) is True:
+        has.async_create_task(
+            hass.config_entries.async_forward_entry_setup(entry, "fan")
+        )
     entry.add_update_listener(async_update_entry)
 
     return True
@@ -93,6 +97,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
         await hass.config_entries.async_forward_entry_unload(entry, "switch")
     if CONF_HUMIDIFIER in data:
         await hass.config_entries.async_forward_entry_unload(entry, "humidifier")
+    if CONF_FAN in data:
+        await hass.config_entries.async_forward_entry_unload(entry, "fan")
 
     delete_device(hass, config)
     del hass.data[DOMAIN][config[CONF_DEVICE_ID]]

+ 10 - 0
custom_components/tuya_local/configuration.py

@@ -6,6 +6,7 @@ from .const import (
     CONF_CLIMATE,
     CONF_DEVICE_ID,
     CONF_DISPLAY_LIGHT,
+    CONF_FAN,
     CONF_HUMIDIFIER,
     CONF_LOCAL_KEY,
     CONF_SWITCH,
@@ -20,6 +21,7 @@ from .const import (
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_GARDENPAC_HEATPUMP,
+    CONF_TYPE_INKBIRD_THERMOSTAT,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
     CONF_TYPE_PURLINE_M100_HEATER,
@@ -45,6 +47,7 @@ INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
                 CONF_TYPE_GPPH_HEATER,
                 CONF_TYPE_GSH_HEATER,
                 CONF_TYPE_GARDENPAC_HEATPUMP,
+                CONF_TYPE_INKBIRD_THERMOSTAT,
                 CONF_TYPE_KOGAN_HEATER,
                 CONF_TYPE_KOGAN_SWITCH,
                 CONF_TYPE_PURLINE_M100_HEATER,
@@ -90,6 +93,13 @@ INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
         "default": False,
         "option": True,
     },
+    {
+        "key": CONF_FAN,
+        "type": bool,
+        "required": False,
+        "default": False,
+        "option": True,
+    },
 ]
 
 

+ 1 - 0
custom_components/tuya_local/const.py

@@ -23,6 +23,7 @@ CONF_TYPE_INKBIRD_THERMOSTAT = "inkbird_thermostat"
 CONF_CLIMATE = "climate"
 CONF_DISPLAY_LIGHT = "display_light"
 CONF_CHILD_LOCK = "child_lock"
+CONF_FAN = "fan"
 CONF_SWITCH = "switch"
 CONF_HUMIDIFIER = "humidifier"
 API_PROTOCOL_VERSIONS = [3.3, 3.1]

+ 71 - 34
custom_components/tuya_local/devices/goldair_fan.yaml

@@ -1,65 +1,102 @@
 name: Goldair Fan
 legacy_type: fan
 primary_entity:
-  entity: climate
-  legacy_class: ".fan.climate.GoldairFan"
-  icon: "mdi:fan"
+  entity: fan
   dps:
     - id: 1
       type: boolean
-      mapping:
-        - dps_val: false
-          value: "off"
-        - dps_val: true
-          value: "fan_only"
-      name: hvac_mode
+      name: switch
     - id: 2
       type: integer
+      name: speed
+      range:
+        min: 1
+        max: 12
+      mapping:
+        - scale: 0.12
       constraint: preset_mode
       conditions:
-        - dps_val: normal
-          range:
-            min: 1
-            max: 12
         - dps_val: nature
           mapping:
-            - dps_val: 4
-              value: low
-            - dps_val: 8
-              value: medium
-            - dps_val: 12
-              value: high
+            - step: 4
         - dps_val: sleep
           mapping:
-            - dps_val: 4
-              value: low
-            - dps_val: 8
-              value: medium
-            - dps_val: 12
-              value: high            
-      name: fan_mode
+            - step: 4
     - id: 3
       type: string
       mapping:
         - dps_val: normal
           value: normal
         - dps_val: nature
-          value: eco
+          value: nature
         - dps_val: sleep
           value: sleep
       name: preset_mode
     - id: 8
       type: boolean
-      mapping:
-        - dps_val: false
-          value: "off"
-        - dps_val: true
-          value: "horizontal"
-      name: swing_mode
+      name: oscillate
     - id: 11
       type: string
-      name: unknown_11
+      name: timer
 secondary_entities:
+  - entity: climate
+    legacy_class: ".legacy_fan.climate.GoldairFan"
+    icon: "mdi:fan"
+    dps:
+      - id: 1
+        type: boolean
+        mapping:
+          - dps_val: false
+            value: "off"
+          - dps_val: true
+            value: "fan_only"
+        name: hvac_mode
+      - id: 2
+        type: integer
+        constraint: preset_mode
+        conditions:
+          - dps_val: normal
+            range:
+              min: 1
+              max: 12
+          - dps_val: nature
+            mapping:
+              - dps_val: 4
+                value: low
+              - dps_val: 8
+                value: medium
+              - dps_val: 12
+                value: high
+          - dps_val: sleep
+            mapping:
+              - dps_val: 4
+                value: low
+              - dps_val: 8
+                value: medium
+              - dps_val: 12
+                value: high            
+        name: fan_mode
+      - id: 3
+        type: string
+        mapping:
+          - dps_val: normal
+            value: normal
+          - dps_val: nature
+            value: eco
+          - dps_val: sleep
+            value: sleep
+        name: preset_mode
+      - id: 8
+        type: boolean
+        mapping:
+          - dps_val: false
+            value: "off"
+          - dps_val: true
+            value: "horizontal"
+        name: swing_mode
+      - id: 11
+        type: string
+        name: timer
   - entity: light
     name: Panel Light
     dps:

+ 51 - 0
custom_components/tuya_local/fan.py

@@ -0,0 +1,51 @@
+"""
+Setup for different kinds of Tuya fan devices
+"""
+import logging
+
+from . import DOMAIN
+from .const import (
+    CONF_FAN,
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+    CONF_TYPE_AUTO,
+)
+from .generic.fan import TuyaLocalFan
+from .helpers.device_config import config_for_legacy_use
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+    """Set up the Tuya device according to its type."""
+    data = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
+    device = data["device"]
+
+    if discovery_info[CONF_TYPE] == CONF_TYPE_AUTO:
+        discovery_info[CONF_TYPE] = await device.async_inferred_type()
+
+        if discovery_info[CONF_TYPE] is None:
+            raise ValueError(f"Unable to detect type for device {device.name}")
+
+    cfg = config_for_legacy_use(discovery_info[CONF_TYPE])
+    ecfg = cfg.primary_entity
+    if ecfg.entity != "fan":
+        for ecfg in cfg.secondary_entities():
+            if ecfg.entity == "fan":
+                break
+        if ecfg.entity != "fan":
+            raise ValueError(f"{device.name} does not support use as a fan device.")
+
+    data[CONF_FAN] = TuyaLocalFan(device, ecfg)
+
+    async_add_entities([data[CONF_FAN]])
+    _LOGGER.debug(f"Adding fan device for {discovery_info[CONF_TYPE]}")
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    discovery_info = {
+        CONF_DEVICE_ID: config[CONF_DEVICE_ID],
+        CONF_TYPE: config[CONF_TYPE],
+    }
+    await async_setup_platform(hass, {}, async_add_entities, discovery_info)

+ 8 - 2
custom_components/tuya_local/generic/climate.py

@@ -65,7 +65,6 @@ class TuyaLocalClimate(ClimateEntity):
         self._hvac_mode_dps = None
         self._unit_dps = None
         self._attr_dps = []
-        self._temperature_step = 1
 
         for d in config.dps():
             if d.name == "hvac_mode":
@@ -178,7 +177,14 @@ class TuyaLocalClimate(ClimateEntity):
     @property
     def target_temperature_step(self):
         """Return the supported step of target temperature."""
-        return self._temperature_step
+        dps = self._temperature_dps
+        if dps is None:
+            dps = self._temp_high_dps
+        if dps is None:
+            dps = self._temp_low_dps
+        if dps is None:
+            return 1
+        return dps.step(self._device)
 
     @property
     def min_temp(self):

+ 199 - 0
custom_components/tuya_local/generic/fan.py

@@ -0,0 +1,199 @@
+"""
+Platform to control tuya fan devices.
+"""
+import logging
+
+from homeassistant.components.fan import (
+    FanEntity,
+    SUPPORT_DIRECTION,
+    SUPPORT_OSCILLATE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SET_SPEED,
+)
+
+from homeassistant.const import (
+    STATE_UNAVAILABLE,
+)
+
+from ..device import TuyaLocalDevice
+from ..helpers.device_config import TuyaEntityConfig
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TuyaLocalFan(FanEntity):
+    """Representation of a Tuya Fan entity."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the fan device.
+        Args:
+           device (TuyaLocalDevice): The device API instance.
+           config (TuyaEntityConfig): The entity config.
+        """
+        self._device = device
+        self._config = config
+        self._support_flags = 0
+        self._switch_dps = None
+        self._preset_dps = None
+        self._speed_dps = None
+        self._oscillate_dps = None
+        self._direction_dps = None
+        self._attr_dps = []
+        for d in config.dps():
+            if d.name == "switch":
+                self._switch_dps = d
+            elif d.name == "preset_mode":
+                self._preset_dps = d
+                self._support_flags |= SUPPORT_PRESET_MODE
+            elif d.name == "speed":
+                self._speed_dps = d
+                self._support_flags |= SUPPORT_SET_SPEED
+            elif d.name == "oscillate":
+                self._oscillate_dps = d
+                self._support_flags |= SUPPORT_OSCILLATE
+            elif d.name == "direction":
+                self._direction_dps = d
+                self._support_flags |= SUPPORT_DIRECTION
+            elif not d.hidden:
+                self._attr_dps.append(d)
+
+    @property
+    def supported_features(self):
+        """Return the features supported by this climate device."""
+        return self._support_flags
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the climate device."""
+        return self._device.name
+
+    @property
+    def friendly_name(self):
+        """Return the friendly name of the climate entity for the UI."""
+        return self._config.name
+
+    @property
+    def unique_id(self):
+        """Return the unique id for this climate device."""
+        return self._device.unique_id
+
+    @property
+    def device_info(self):
+        """Return device information about this heater."""
+        return self._device.device_info
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend for this device."""
+        if self.is_on:
+            return "mdi:fan"
+        else:
+            return "mdi:fan-off"
+
+    @property
+    def is_on(self):
+        """Return whether the switch is on or not."""
+        # If there is no switch, it is always on
+        if self._switch_dps is None:
+            return True
+        is_switched_on = self._switch_dps.get_value(self._device)
+
+        if is_switched_on is None:
+            return STATE_UNAVAILABLE
+        else:
+            return bool(is_switched_on)
+
+    async def async_turn_on(self, **kwargs):
+        """Turn the switch on"""
+        if self._switch_dps is None:
+            raise NotImplementedError()
+        await self._switch_dps.async_set_value(self._device, True)
+
+    async def async_turn_off(self, **kwargs):
+        """Turn the switch off"""
+        if self._switch_dps is None:
+            raise NotImplementedError
+        await self._switch_dps.async_set_value(self._device, False)
+
+    @property
+    def percentage(self):
+        """Return the currently set percentage."""
+        if self._speed_dps is None:
+            return None
+        return self._speed_dps.get_value(self._device)
+
+    @property
+    def percentage_step(self):
+        """Return the step for percentage."""
+        if self._speed_dps is None:
+            return None
+        return self._speed_dps.step(self._device)
+
+    async def async_set_percentage(self, percentage):
+        """Set the fan speed as a percentage."""
+        if self._speed_dps is None:
+            return None
+        await self._speed_dps.async_set_value(self._device, percentage)
+
+    @property
+    def preset_mode(self):
+        """Return the current preset mode."""
+        if self._preset_dps is None:
+            return None
+        return self._preset_dps.get_value(self._device)
+
+    @property
+    def preset_modes(self):
+        """Return the list of presets that this device supports."""
+        if self._preset_dps is None:
+            return []
+        return self._preset_dps.values
+
+    async def async_set_preset_mode(self, preset_mode):
+        """Set the preset mode."""
+        if self._preset_dps is None:
+            raise NotImplementedError()
+        await self._preset_dps.async_set_value(self._device, preset_mode)
+
+    @property
+    def current_direction(self):
+        """Return the current direction [forward or reverse]."""
+        if self._direction_dps is None:
+            return None
+        return self._direction_dps.get_value(self._device)
+
+    async def async_set_direction(self, direction):
+        """Set the direction of the fan."""
+        if self._direction_dps is None:
+            raise NotImplementedError()
+        await self._direction_dps.async_set_value(self._device, direction)
+
+    @property
+    def oscillating(self):
+        """Return whether or not the fan is oscillating."""
+        if self._oscillate_dps is None:
+            return None
+        return self._oscillate_dps.get_value(self._device)
+
+    async def async_oscillate(self, oscillating):
+        """Oscillate the fan."""
+        if self._oscillate_dps is None:
+            raise NotImplementedError()
+        await self._oscillate_dps.async_set_value(self._device, oscillating)
+
+    @property
+    def device_state_attributes(self):
+        """Get additional attributes that the integration itself does not support."""
+        attr = {}
+        for a in self._attr_dps:
+            attr[a.name] = a.get_value(self._device)
+        return attr
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 3 - 2
custom_components/tuya_local/helpers/device_config.py

@@ -235,11 +235,12 @@ class TuyaDpsConfig:
 
     def step(self, device):
         step = 1
+        scale = 1
         mapping = self._find_map_for_dps(device.get_property(self.id))
         if mapping is not None:
             step = mapping.get("step", 1)
-
-        return step
+            scale = mapping.get("scale", 1)
+        return step / scale
 
     @property
     def readonly(self):

+ 0 - 0
custom_components/tuya_local/fan/__init__.py → custom_components/tuya_local/legacy_fan/__init__.py


+ 0 - 0
custom_components/tuya_local/fan/climate.py → custom_components/tuya_local/legacy_fan/climate.py


+ 0 - 0
custom_components/tuya_local/fan/const.py → custom_components/tuya_local/legacy_fan/const.py


+ 1 - 1
custom_components/tuya_local/manifest.json

@@ -2,7 +2,7 @@
     "domain": "tuya_local",
     "iot_class": "local_polling",
     "name": "Tuya based devices local control",
-    "version": "0.6.3", 
+    "version": "0.7.0", 
     "documentation": "https://github.com/make-all/tuya-local",
     "issue_tracker": "https://github.com/make-all/tuya-local/issues",
     "dependencies": [],

+ 4 - 2
custom_components/tuya_local/translations/en.json

@@ -15,7 +15,8 @@
           "display_light": "Include LED display as a light entity (Goldair only)",
           "child_lock": "Include child lock as a lock entity (unsupported on fans and switches)",
           "switch": "Include a switch entity (switches only)",
-          "humidifier": "Include a humidifier entity (humidifiers and dehumidifiers only)"
+          "humidifier": "Include a humidifier entity (humidifiers and dehumidifiers only)",
+	  "fan": "Include a fan entitiy (fans, humidifiers and dehumidifiers)"
         }
       }
     },
@@ -40,7 +41,8 @@
           "display_light": "Include LED display as light entity (Goldair only)",
           "child_lock": "Include child lock as lock entity (unsupported on fans and switches)",
 	    "switch": "Include device as a switch entity (switches only)",
-          "humidifier": "Include a humidifier entity (humidifiers and dehumidifiers only)"
+          "humidifier": "Include a humidifier entity (humidifiers and dehumidifiers only)",
+	  "fan": "Include a fan entitiy (fans, humidifiers and dehumidifiers)"
         }
       },
       "imported": {

+ 1 - 1
hacs.json

@@ -1,7 +1,7 @@
 {
   "name": "Tuya local devices",
   "render_readme": true,
-  "domains": ["climate", "humidifier", "light", "lock", "switch"],
+  "domains": ["climate", "fan", "humidifier", "light", "lock", "switch"],
   "homeassistant": "2021.5.0",
   "iot_class": "Local Polling"
 }

+ 176 - 76
tests/devices/test_goldair_fan.py

@@ -7,14 +7,20 @@ from homeassistant.components.climate.const import (
     PRESET_ECO,
     PRESET_SLEEP,
     SUPPORT_FAN_MODE,
-    SUPPORT_PRESET_MODE,
+    SUPPORT_PRESET_MODE as SUPPORT_CLIMATE_PRESET,
     SUPPORT_SWING_MODE,
     SWING_HORIZONTAL,
     SWING_OFF,
 )
-from homeassistant.const import STATE_UNAVAILABLE
+from homeassistant.components.fan import (
+    SUPPORT_OSCILLATE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SET_SPEED,
+)
 
+from homeassistant.const import STATE_UNAVAILABLE
 from custom_components.tuya_local.generic.climate import TuyaLocalClimate
+from custom_components.tuya_local.generic.fan import TuyaLocalFan
 from custom_components.tuya_local.generic.light import TuyaLocalLight
 from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
 
@@ -25,7 +31,7 @@ HVACMODE_DPS = "1"
 FANMODE_DPS = "2"
 PRESET_DPS = "3"
 SWING_DPS = "8"
-UNKNOWN_DPS = "11"
+TIMER_DPS = "11"
 LIGHT_DPS = "101"
 
 
@@ -35,16 +41,24 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         self.addCleanup(device_patcher.stop)
         self.mock_device = device_patcher.start()
         cfg = TuyaDeviceConfig("goldair_fan.yaml")
-        climate = cfg.primary_entity
-        light = None
+        entities = {}
+        entities[cfg.primary_entity.entity] = cfg.primary_entity
         for e in cfg.secondary_entities():
-            if e.entity == "light":
-                light = e
-        self.climate_name = climate.name
-        self.light_name = light.name
+            entities[e.entity] = e
+
+        self.climate_name = (
+            "missing" if "climate" not in entities.keys() else entities["climate"].name
+        )
+        self.fan_name = (
+            "missing" if "fan" not in entities.keys() else entities["fan"].name
+        )
+        self.light_name = (
+            "missing" if "light" not in entities.keys() else entities["light"].name
+        )
 
-        self.subject = TuyaLocalClimate(self.mock_device(), climate)
-        self.light = TuyaLocalLight(self.mock_device(), light)
+        self.subject = TuyaLocalFan(self.mock_device(), entities.get("fan"))
+        self.climate = TuyaLocalClimate(self.mock_device(), entities.get("climate"))
+        self.light = TuyaLocalLight(self.mock_device(), entities.get("light"))
 
         self.dps = FAN_PAYLOAD.copy()
         self.subject._device.get_property.side_effect = lambda id: self.dps[id]
@@ -52,82 +66,131 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
     def test_supported_features(self):
         self.assertEqual(
             self.subject.supported_features,
-            SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE,
+            SUPPORT_OSCILLATE | SUPPORT_PRESET_MODE | SUPPORT_SET_SPEED,
+        )
+        self.assertEqual(
+            self.climate.supported_features,
+            SUPPORT_FAN_MODE | SUPPORT_CLIMATE_PRESET | SUPPORT_SWING_MODE,
         )
 
     def test_should_poll(self):
         self.assertTrue(self.subject.should_poll)
+        self.assertTrue(self.climate.should_poll)
         self.assertTrue(self.light.should_poll)
 
     def test_name_returns_device_name(self):
         self.assertEqual(self.subject.name, self.subject._device.name)
+        self.assertEqual(self.climate.name, self.subject._device.name)
         self.assertEqual(self.light.name, self.subject._device.name)
 
     def test_friendly_name_returns_config_name(self):
-        self.assertEqual(self.subject.friendly_name, self.climate_name)
+        self.assertEqual(self.subject.friendly_name, self.fan_name)
+        self.assertEqual(self.climate.friendly_name, self.climate_name)
         self.assertEqual(self.light.friendly_name, self.light_name)
 
     def test_unique_id_returns_device_unique_id(self):
         self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+        self.assertEqual(self.climate.unique_id, self.subject._device.unique_id)
         self.assertEqual(self.light.unique_id, self.subject._device.unique_id)
 
     def test_device_info_returns_device_info_from_device(self):
         self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+        self.assertEqual(self.climate.device_info, self.subject._device.device_info)
         self.assertEqual(self.light.device_info, self.subject._device.device_info)
 
     @skip("Icon customisation not supported yet")
-    def test_icon_is_fan(self):
-        self.assertEqual(self.subject.icon, "mdi:fan")
+    def test_climate_icon_is_fan(self):
+        self.assertEqual(self.climate.icon, "mdi:fan")
 
     def test_temperature_unit_returns_device_temperature_unit(self):
         self.assertEqual(
-            self.subject.temperature_unit, self.subject._device.temperature_unit
+            self.climate.temperature_unit, self.climate._device.temperature_unit
         )
 
-    def test_hvac_mode(self):
+    def test_is_on(self):
         self.dps[HVACMODE_DPS] = True
-        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_FAN_ONLY)
+        self.assertEqual(self.climate.hvac_mode, HVAC_MODE_FAN_ONLY)
+        self.assertTrue(self.subject.is_on)
 
         self.dps[HVACMODE_DPS] = False
-        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+        self.assertEqual(self.climate.hvac_mode, HVAC_MODE_OFF)
+        self.assertFalse(self.subject.is_on)
 
         self.dps[HVACMODE_DPS] = None
-        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+        self.assertEqual(self.climate.hvac_mode, STATE_UNAVAILABLE)
+        self.assertEqual(self.subject.is_on, STATE_UNAVAILABLE)
 
-    def test_hvac_modes(self):
+    def test_climate_hvac_modes(self):
         self.assertCountEqual(
-            self.subject.hvac_modes, [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY]
+            self.climate.hvac_modes, [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY]
         )
 
+    async def test_climate_turn_on(self):
+        async with assert_device_properties_set(
+            self.climate._device, {HVACMODE_DPS: True}
+        ):
+            await self.climate.async_set_hvac_mode(HVAC_MODE_FAN_ONLY)
+
+    async def test_climate_turn_off(self):
+        async with assert_device_properties_set(
+            self.climate._device, {HVACMODE_DPS: False}
+        ):
+            await self.climate.async_set_hvac_mode(HVAC_MODE_OFF)
+
     async def test_turn_on(self):
         async with assert_device_properties_set(
             self.subject._device, {HVACMODE_DPS: True}
         ):
-            await self.subject.async_set_hvac_mode(HVAC_MODE_FAN_ONLY)
+            await self.subject.async_turn_on()
 
     async def test_turn_off(self):
         async with assert_device_properties_set(
             self.subject._device, {HVACMODE_DPS: False}
         ):
-            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+            await self.subject.async_turn_off()
 
     def test_preset_mode(self):
         self.dps[PRESET_DPS] = "normal"
+        self.assertEqual(self.climate.preset_mode, "normal")
         self.assertEqual(self.subject.preset_mode, "normal")
 
         self.dps[PRESET_DPS] = "nature"
-        self.assertEqual(self.subject.preset_mode, PRESET_ECO)
+        self.assertEqual(self.climate.preset_mode, PRESET_ECO)
+        self.assertEqual(self.subject.preset_mode, "nature")
 
         self.dps[PRESET_DPS] = PRESET_SLEEP
+        self.assertEqual(self.climate.preset_mode, PRESET_SLEEP)
         self.assertEqual(self.subject.preset_mode, PRESET_SLEEP)
 
         self.dps[PRESET_DPS] = None
-        self.assertIs(self.subject.preset_mode, None)
+        self.assertIs(self.climate.preset_mode, None)
 
     def test_preset_modes(self):
         self.assertCountEqual(
-            self.subject.preset_modes, ["normal", PRESET_ECO, PRESET_SLEEP]
+            self.climate.preset_modes, ["normal", PRESET_ECO, PRESET_SLEEP]
         )
+        self.assertCountEqual(self.subject.preset_modes, ["normal", "nature", "sleep"])
+
+    async def test_set_climate_preset_mode_to_normal(self):
+        async with assert_device_properties_set(
+            self.climate._device,
+            {PRESET_DPS: "normal"},
+        ):
+            await self.climate.async_set_preset_mode("normal")
+
+    async def test_set_climate_preset_mode_to_eco(self):
+        async with assert_device_properties_set(
+            self.climate._device,
+            {PRESET_DPS: "nature"},
+        ):
+            await self.climate.async_set_preset_mode(PRESET_ECO)
+
+    async def test_set_climate_preset_mode_to_sleep(self):
+        async with assert_device_properties_set(
+            self.climate._device,
+            {PRESET_DPS: PRESET_SLEEP},
+        ):
+            await self.climate.async_set_preset_mode(PRESET_SLEEP)
 
     async def test_set_preset_mode_to_normal(self):
         async with assert_device_properties_set(
@@ -136,153 +199,190 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         ):
             await self.subject.async_set_preset_mode("normal")
 
-    async def test_set_preset_mode_to_eco(self):
+    async def test_set_preset_mode_to_nature(self):
         async with assert_device_properties_set(
             self.subject._device,
             {PRESET_DPS: "nature"},
         ):
-            await self.subject.async_set_preset_mode(PRESET_ECO)
+            await self.subject.async_set_preset_mode("nature")
 
     async def test_set_preset_mode_to_sleep(self):
         async with assert_device_properties_set(
             self.subject._device,
-            {PRESET_DPS: PRESET_SLEEP},
+            {PRESET_DPS: "sleep"},
         ):
-            await self.subject.async_set_preset_mode(PRESET_SLEEP)
+            await self.subject.async_set_preset_mode("sleep")
 
     def test_swing_mode(self):
         self.dps[SWING_DPS] = False
-        self.assertEqual(self.subject.swing_mode, SWING_OFF)
+        self.assertEqual(self.climate.swing_mode, SWING_OFF)
+        self.assertFalse(self.subject.oscillating)
 
         self.dps[SWING_DPS] = True
-        self.assertEqual(self.subject.swing_mode, SWING_HORIZONTAL)
+        self.assertEqual(self.climate.swing_mode, SWING_HORIZONTAL)
+        self.assertTrue(self.subject.oscillating)
 
         self.dps[SWING_DPS] = None
-        self.assertIs(self.subject.swing_mode, None)
+        self.assertIs(self.climate.swing_mode, None)
+        self.assertFalse(self.subject.oscillating)
 
     def test_swing_modes(self):
-        self.assertCountEqual(self.subject.swing_modes, [SWING_OFF, SWING_HORIZONTAL])
+        self.assertCountEqual(self.climate.swing_modes, [SWING_OFF, SWING_HORIZONTAL])
 
-    async def test_set_swing_mode_to_off(self):
+    async def test_climate_set_swing_mode_to_off(self):
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {SWING_DPS: False},
         ):
-            await self.subject.async_set_swing_mode(SWING_OFF)
+            await self.climate.async_set_swing_mode(SWING_OFF)
 
-    async def test_set_swing_mode_to_horizontal(self):
+    async def test_climate_set_swing_mode_to_horizontal(self):
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {SWING_DPS: True},
         ):
-            await self.subject.async_set_swing_mode(SWING_HORIZONTAL)
+            await self.climate.async_set_swing_mode(SWING_HORIZONTAL)
+
+    async def test_oscillate_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {SWING_DPS: False}
+        ):
+            await self.subject.async_oscillate(False)
+
+    async def test_oscillate_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {SWING_DPS: True}
+        ):
+            await self.subject.async_oscillate(True)
+
+    def test_speed(self):
+        self.dps[PRESET_DPS] = "normal"
+        self.dps[FANMODE_DPS] = 6
+        self.assertEqual(self.subject.percentage, 50)
+
+    async def test_set_speed_in_normal_mode(self):
+        self.dps[PRESET_DPS] = "normal"
+        async with assert_device_properties_set(self.subject._device, {FANMODE_DPS: 3}):
+            await self.subject.async_set_percentage(25)
+
+    async def test_set_speed_in_normal_mode_snaps(self):
+        self.dps[PRESET_DPS] = "normal"
+        async with assert_device_properties_set(
+            self.subject._device, {FANMODE_DPS: 10}
+        ):
+            await self.subject.async_set_percentage(80)
 
     @skip("Complex conditions not supported yet")
-    def test_fan_modes(self):
+    async def test_set_speed_in_sleep_mode_snaps(self):
+        self.dps[PRESET_DPS] = "sleep"
+        async with assert_device_properties_set(self.subject._device, {FANMODE_DPS: 8}):
+            await self.subject.async_set_percentage(75)
+
+    @skip("Complex conditions not supported yet")
+    def test_climate_fan_modes(self):
         self.dps[PRESET_DPS] = "normal"
-        self.assertCountEqual(self.subject.fan_modes, list(range(1, 13)))
+        self.assertCountEqual(self.climate.fan_modes, list(range(1, 13)))
 
         self.dps[PRESET_DPS] = "nature"
-        self.assertCountEqual(self.subject.fan_modes, [1, 2, 3])
+        self.assertCountEqual(self.climate.fan_modes, [1, 2, 3])
 
         self.dps[PRESET_DPS] = PRESET_SLEEP
-        self.assertCountEqual(self.subject.fan_modes, [1, 2, 3])
+        self.assertCountEqual(self.climate.fan_modes, [1, 2, 3])
 
         self.dps[PRESET_DPS] = None
-        self.assertEqual(self.subject.fan_modes, [])
+        self.assertEqual(self.climate.fan_modes, [])
 
     @skip("Complex conditions not supported yet")
-    def test_fan_mode_for_normal_preset(self):
+    def test_climate_fan_mode_for_normal_preset(self):
         self.dps[PRESET_DPS] = "normal"
 
         self.dps[FANMODE_DPS] = "1"
-        self.assertEqual(self.subject.fan_mode, 1)
+        self.assertEqual(self.climate.fan_mode, 1)
 
         self.dps[FANMODE_DPS] = "6"
-        self.assertEqual(self.subject.fan_mode, 6)
+        self.assertEqual(self.climate.fan_mode, 6)
 
         self.dps[FANMODE_DPS] = "12"
-        self.assertEqual(self.subject.fan_mode, 12)
+        self.assertEqual(self.climate.fan_mode, 12)
 
         self.dps[FANMODE_DPS] = None
-        self.assertEqual(self.subject.fan_mode, None)
+        self.assertEqual(self.climate.fan_mode, None)
 
     @skip("Complex conditions not supported yet")
-    async def test_set_fan_mode_for_normal_preset(self):
+    async def test_climate_set_fan_mode_for_normal_preset(self):
         self.dps[PRESET_DPS] = "normal"
 
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {FANMODE_DPS: "6"},
         ):
-            await self.subject.async_set_fan_mode(6)
+            await self.climate.async_set_fan_mode(6)
 
     @skip("Complex conditions not supported yet")
-    def test_fan_mode_for_eco_preset(self):
+    def test_climate_fan_mode_for_eco_preset(self):
         self.dps[PRESET_DPS] = "nature"
 
         self.dps[FANMODE_DPS] = "4"
-        self.assertEqual(self.subject.fan_mode, 1)
+        self.assertEqual(self.climate.fan_mode, 1)
 
         self.dps[FANMODE_DPS] = "8"
-        self.assertEqual(self.subject.fan_mode, 2)
+        self.assertEqual(self.climate.fan_mode, 2)
 
         self.dps[FANMODE_DPS] = "12"
-        self.assertEqual(self.subject.fan_mode, 3)
+        self.assertEqual(self.climate.fan_mode, 3)
 
         self.dps[FANMODE_DPS] = None
-        self.assertEqual(self.subject.fan_mode, None)
+        self.assertEqual(self.climate.fan_mode, None)
 
     @skip("Complex conditions not supported yet")
-    async def test_set_fan_mode_for_eco_preset(self):
+    async def test_climate_set_fan_mode_for_eco_preset(self):
         self.dps[PRESET_DPS] = "nature"
 
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {FANMODE_DPS: "4"},
         ):
-            await self.subject.async_set_fan_mode(1)
+            await self.climate.async_set_fan_mode(1)
 
     @skip("Complex conditions not supported yet")
-    def test_fan_mode_for_sleep_preset(self):
+    def test_climate_fan_mode_for_sleep_preset(self):
         self.dps[PRESET_DPS] = PRESET_SLEEP
 
         self.dps[FANMODE_DPS] = "4"
-        self.assertEqual(self.subject.fan_mode, 1)
+        self.assertEqual(self.climate.fan_mode, 1)
 
         self.dps[FANMODE_DPS] = "8"
-        self.assertEqual(self.subject.fan_mode, 2)
+        self.assertEqual(self.climate.fan_mode, 2)
 
         self.dps[FANMODE_DPS] = "12"
-        self.assertEqual(self.subject.fan_mode, 3)
+        self.assertEqual(self.climate.fan_mode, 3)
 
         self.dps[FANMODE_DPS] = None
-        self.assertEqual(self.subject.fan_mode, None)
+        self.assertEqual(self.climate.fan_mode, None)
 
     @skip("Complex conditions not supported yet")
-    async def test_set_fan_mode_for_sleep_preset(self):
+    async def test_climate_set_fan_mode_for_sleep_preset(self):
         self.dps[PRESET_DPS] = PRESET_SLEEP
 
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {FANMODE_DPS: "8"},
         ):
-            await self.subject.async_set_fan_mode(2)
+            await self.climate.async_set_fan_mode(2)
 
     @skip("Complex conditions not supported yet")
-    async def test_set_fan_mode_does_nothing_when_preset_mode_is_not_set(self):
+    async def test_climate_set_fan_mode_does_nothing_when_preset_mode_is_not_set(self):
         self.dps[PRESET_DPS] = None
 
         with self.assertRaises(
             ValueError, msg="Fan mode can only be set when a preset mode is set"
         ):
-            await self.subject.async_set_fan_mode(2)
+            await self.climate.async_set_fan_mode(2)
 
     def test_device_state_attributes(self):
-        self.dps[UNKNOWN_DPS] = "something"
-        self.assertEqual(
-            self.subject.device_state_attributes, {"unknown_11": "something"}
-        )
+        self.dps[TIMER_DPS] = "5"
+        self.assertEqual(self.climate.device_state_attributes, {"timer": "5"})
+        self.assertEqual(self.climate.device_state_attributes, {"timer": "5"})
 
     async def test_update(self):
         result = AsyncMock()

+ 2 - 2
tests/fan/test_climate.py

@@ -18,8 +18,8 @@ from homeassistant.components.climate.const import (
 )
 from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE
 
-from custom_components.tuya_local.fan.climate import GoldairFan
-from custom_components.tuya_local.fan.const import (
+from custom_components.tuya_local.legacy_fan.climate import GoldairFan
+from custom_components.tuya_local.legacy_fan.const import (
     FAN_MODES,
     HVAC_MODE_TO_DPS_MODE,
     PRESET_MODE_TO_DPS_MODE,

+ 36 - 0
tests/test_fan.py

@@ -0,0 +1,36 @@
+"""Tests for the fan entity."""
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from unittest.mock import AsyncMock, Mock
+
+from custom_components.tuya_local.const import (
+    CONF_FAN,
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+    CONF_TYPE_AUTO,
+    CONF_TYPE_FAN,
+    DOMAIN,
+)
+from custom_components.tuya_local.generic.fan import TuyaLocalFan
+from custom_components.tuya_local.fan import async_setup_entry
+
+
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: CONF_TYPE_AUTO, CONF_DEVICE_ID: "dummy"},
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+    m_device.async_inferred_type = AsyncMock(return_value=CONF_TYPE_FAN)
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"][CONF_FAN]) == TuyaLocalFan
+    m_add_entities.assert_called_once()