Просмотр исходного кода

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 лет назад
Родитель
Сommit
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)
 - Remora pool heatpumps (partially also BWT FI 45, which differs in its presets)
 
 
 #### Fans
 #### Fans
-- Goldair GPCF315 fans
+- Goldair GCPF315 fans
 
 
 #### Dehumidifiers
 #### Dehumidifiers
 - Goldair GPDH420 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)
     _(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
 ## 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.
 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_CLIMATE,
     CONF_DEVICE_ID,
     CONF_DEVICE_ID,
     CONF_DISPLAY_LIGHT,
     CONF_DISPLAY_LIGHT,
+    CONF_FAN,
     CONF_HUMIDIFIER,
     CONF_HUMIDIFIER,
     CONF_SWITCH,
     CONF_SWITCH,
     DOMAIN,
     DOMAIN,
@@ -69,7 +70,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
         hass.async_create_task(
         hass.async_create_task(
             hass.config_entries.async_forward_entry_setup(entry, "humidifier")
             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)
     entry.add_update_listener(async_update_entry)
 
 
     return True
     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")
         await hass.config_entries.async_forward_entry_unload(entry, "switch")
     if CONF_HUMIDIFIER in data:
     if CONF_HUMIDIFIER in data:
         await hass.config_entries.async_forward_entry_unload(entry, "humidifier")
         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)
     delete_device(hass, config)
     del hass.data[DOMAIN][config[CONF_DEVICE_ID]]
     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_CLIMATE,
     CONF_DEVICE_ID,
     CONF_DEVICE_ID,
     CONF_DISPLAY_LIGHT,
     CONF_DISPLAY_LIGHT,
+    CONF_FAN,
     CONF_HUMIDIFIER,
     CONF_HUMIDIFIER,
     CONF_LOCAL_KEY,
     CONF_LOCAL_KEY,
     CONF_SWITCH,
     CONF_SWITCH,
@@ -20,6 +21,7 @@ from .const import (
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GPPH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_GSH_HEATER,
     CONF_TYPE_GARDENPAC_HEATPUMP,
     CONF_TYPE_GARDENPAC_HEATPUMP,
+    CONF_TYPE_INKBIRD_THERMOSTAT,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_HEATER,
     CONF_TYPE_KOGAN_SWITCH,
     CONF_TYPE_KOGAN_SWITCH,
     CONF_TYPE_PURLINE_M100_HEATER,
     CONF_TYPE_PURLINE_M100_HEATER,
@@ -45,6 +47,7 @@ INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
                 CONF_TYPE_GPPH_HEATER,
                 CONF_TYPE_GPPH_HEATER,
                 CONF_TYPE_GSH_HEATER,
                 CONF_TYPE_GSH_HEATER,
                 CONF_TYPE_GARDENPAC_HEATPUMP,
                 CONF_TYPE_GARDENPAC_HEATPUMP,
+                CONF_TYPE_INKBIRD_THERMOSTAT,
                 CONF_TYPE_KOGAN_HEATER,
                 CONF_TYPE_KOGAN_HEATER,
                 CONF_TYPE_KOGAN_SWITCH,
                 CONF_TYPE_KOGAN_SWITCH,
                 CONF_TYPE_PURLINE_M100_HEATER,
                 CONF_TYPE_PURLINE_M100_HEATER,
@@ -90,6 +93,13 @@ INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [
         "default": False,
         "default": False,
         "option": True,
         "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_CLIMATE = "climate"
 CONF_DISPLAY_LIGHT = "display_light"
 CONF_DISPLAY_LIGHT = "display_light"
 CONF_CHILD_LOCK = "child_lock"
 CONF_CHILD_LOCK = "child_lock"
+CONF_FAN = "fan"
 CONF_SWITCH = "switch"
 CONF_SWITCH = "switch"
 CONF_HUMIDIFIER = "humidifier"
 CONF_HUMIDIFIER = "humidifier"
 API_PROTOCOL_VERSIONS = [3.3, 3.1]
 API_PROTOCOL_VERSIONS = [3.3, 3.1]

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

@@ -1,65 +1,102 @@
 name: Goldair Fan
 name: Goldair Fan
 legacy_type: fan
 legacy_type: fan
 primary_entity:
 primary_entity:
-  entity: climate
-  legacy_class: ".fan.climate.GoldairFan"
-  icon: "mdi:fan"
+  entity: fan
   dps:
   dps:
     - id: 1
     - id: 1
       type: boolean
       type: boolean
-      mapping:
-        - dps_val: false
-          value: "off"
-        - dps_val: true
-          value: "fan_only"
-      name: hvac_mode
+      name: switch
     - id: 2
     - id: 2
       type: integer
       type: integer
+      name: speed
+      range:
+        min: 1
+        max: 12
+      mapping:
+        - scale: 0.12
       constraint: preset_mode
       constraint: preset_mode
       conditions:
       conditions:
-        - dps_val: normal
-          range:
-            min: 1
-            max: 12
         - dps_val: nature
         - dps_val: nature
           mapping:
           mapping:
-            - dps_val: 4
-              value: low
-            - dps_val: 8
-              value: medium
-            - dps_val: 12
-              value: high
+            - step: 4
         - dps_val: sleep
         - dps_val: sleep
           mapping:
           mapping:
-            - dps_val: 4
-              value: low
-            - dps_val: 8
-              value: medium
-            - dps_val: 12
-              value: high            
-      name: fan_mode
+            - step: 4
     - id: 3
     - id: 3
       type: string
       type: string
       mapping:
       mapping:
         - dps_val: normal
         - dps_val: normal
           value: normal
           value: normal
         - dps_val: nature
         - dps_val: nature
-          value: eco
+          value: nature
         - dps_val: sleep
         - dps_val: sleep
           value: sleep
           value: sleep
       name: preset_mode
       name: preset_mode
     - id: 8
     - id: 8
       type: boolean
       type: boolean
-      mapping:
-        - dps_val: false
-          value: "off"
-        - dps_val: true
-          value: "horizontal"
-      name: swing_mode
+      name: oscillate
     - id: 11
     - id: 11
       type: string
       type: string
-      name: unknown_11
+      name: timer
 secondary_entities:
 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
   - entity: light
     name: Panel Light
     name: Panel Light
     dps:
     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._hvac_mode_dps = None
         self._unit_dps = None
         self._unit_dps = None
         self._attr_dps = []
         self._attr_dps = []
-        self._temperature_step = 1
 
 
         for d in config.dps():
         for d in config.dps():
             if d.name == "hvac_mode":
             if d.name == "hvac_mode":
@@ -178,7 +177,14 @@ class TuyaLocalClimate(ClimateEntity):
     @property
     @property
     def target_temperature_step(self):
     def target_temperature_step(self):
         """Return the supported step of target temperature."""
         """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
     @property
     def min_temp(self):
     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):
     def step(self, device):
         step = 1
         step = 1
+        scale = 1
         mapping = self._find_map_for_dps(device.get_property(self.id))
         mapping = self._find_map_for_dps(device.get_property(self.id))
         if mapping is not None:
         if mapping is not None:
             step = mapping.get("step", 1)
             step = mapping.get("step", 1)
-
-        return step
+            scale = mapping.get("scale", 1)
+        return step / scale
 
 
     @property
     @property
     def readonly(self):
     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",
     "domain": "tuya_local",
     "iot_class": "local_polling",
     "iot_class": "local_polling",
     "name": "Tuya based devices local control",
     "name": "Tuya based devices local control",
-    "version": "0.6.3", 
+    "version": "0.7.0", 
     "documentation": "https://github.com/make-all/tuya-local",
     "documentation": "https://github.com/make-all/tuya-local",
     "issue_tracker": "https://github.com/make-all/tuya-local/issues",
     "issue_tracker": "https://github.com/make-all/tuya-local/issues",
     "dependencies": [],
     "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)",
           "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)",
           "child_lock": "Include child lock as a lock entity (unsupported on fans and switches)",
           "switch": "Include a switch entity (switches only)",
           "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)",
           "display_light": "Include LED display as light entity (Goldair only)",
           "child_lock": "Include child lock as lock entity (unsupported on fans and switches)",
           "child_lock": "Include child lock as lock entity (unsupported on fans and switches)",
 	    "switch": "Include device as a switch entity (switches only)",
 	    "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": {
       "imported": {

+ 1 - 1
hacs.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "Tuya local devices",
   "name": "Tuya local devices",
   "render_readme": true,
   "render_readme": true,
-  "domains": ["climate", "humidifier", "light", "lock", "switch"],
+  "domains": ["climate", "fan", "humidifier", "light", "lock", "switch"],
   "homeassistant": "2021.5.0",
   "homeassistant": "2021.5.0",
   "iot_class": "Local Polling"
   "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_ECO,
     PRESET_SLEEP,
     PRESET_SLEEP,
     SUPPORT_FAN_MODE,
     SUPPORT_FAN_MODE,
-    SUPPORT_PRESET_MODE,
+    SUPPORT_PRESET_MODE as SUPPORT_CLIMATE_PRESET,
     SUPPORT_SWING_MODE,
     SUPPORT_SWING_MODE,
     SWING_HORIZONTAL,
     SWING_HORIZONTAL,
     SWING_OFF,
     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.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.generic.light import TuyaLocalLight
 from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
 from custom_components.tuya_local.helpers.device_config import TuyaDeviceConfig
 
 
@@ -25,7 +31,7 @@ HVACMODE_DPS = "1"
 FANMODE_DPS = "2"
 FANMODE_DPS = "2"
 PRESET_DPS = "3"
 PRESET_DPS = "3"
 SWING_DPS = "8"
 SWING_DPS = "8"
-UNKNOWN_DPS = "11"
+TIMER_DPS = "11"
 LIGHT_DPS = "101"
 LIGHT_DPS = "101"
 
 
 
 
@@ -35,16 +41,24 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         self.addCleanup(device_patcher.stop)
         self.addCleanup(device_patcher.stop)
         self.mock_device = device_patcher.start()
         self.mock_device = device_patcher.start()
         cfg = TuyaDeviceConfig("goldair_fan.yaml")
         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():
         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.dps = FAN_PAYLOAD.copy()
         self.subject._device.get_property.side_effect = lambda id: self.dps[id]
         self.subject._device.get_property.side_effect = lambda id: self.dps[id]
@@ -52,82 +66,131 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
     def test_supported_features(self):
     def test_supported_features(self):
         self.assertEqual(
         self.assertEqual(
             self.subject.supported_features,
             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):
     def test_should_poll(self):
         self.assertTrue(self.subject.should_poll)
         self.assertTrue(self.subject.should_poll)
+        self.assertTrue(self.climate.should_poll)
         self.assertTrue(self.light.should_poll)
         self.assertTrue(self.light.should_poll)
 
 
     def test_name_returns_device_name(self):
     def test_name_returns_device_name(self):
         self.assertEqual(self.subject.name, self.subject._device.name)
         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)
         self.assertEqual(self.light.name, self.subject._device.name)
 
 
     def test_friendly_name_returns_config_name(self):
     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)
         self.assertEqual(self.light.friendly_name, self.light_name)
 
 
     def test_unique_id_returns_device_unique_id(self):
     def test_unique_id_returns_device_unique_id(self):
         self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
         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)
         self.assertEqual(self.light.unique_id, self.subject._device.unique_id)
 
 
     def test_device_info_returns_device_info_from_device(self):
     def test_device_info_returns_device_info_from_device(self):
         self.assertEqual(self.subject.device_info, self.subject._device.device_info)
         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)
         self.assertEqual(self.light.device_info, self.subject._device.device_info)
 
 
     @skip("Icon customisation not supported yet")
     @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):
     def test_temperature_unit_returns_device_temperature_unit(self):
         self.assertEqual(
         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.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.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.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.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 def test_turn_on(self):
         async with assert_device_properties_set(
         async with assert_device_properties_set(
             self.subject._device, {HVACMODE_DPS: True}
             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 def test_turn_off(self):
         async with assert_device_properties_set(
         async with assert_device_properties_set(
             self.subject._device, {HVACMODE_DPS: False}
             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):
     def test_preset_mode(self):
         self.dps[PRESET_DPS] = "normal"
         self.dps[PRESET_DPS] = "normal"
+        self.assertEqual(self.climate.preset_mode, "normal")
         self.assertEqual(self.subject.preset_mode, "normal")
         self.assertEqual(self.subject.preset_mode, "normal")
 
 
         self.dps[PRESET_DPS] = "nature"
         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.dps[PRESET_DPS] = PRESET_SLEEP
+        self.assertEqual(self.climate.preset_mode, PRESET_SLEEP)
         self.assertEqual(self.subject.preset_mode, PRESET_SLEEP)
         self.assertEqual(self.subject.preset_mode, PRESET_SLEEP)
 
 
         self.dps[PRESET_DPS] = None
         self.dps[PRESET_DPS] = None
-        self.assertIs(self.subject.preset_mode, None)
+        self.assertIs(self.climate.preset_mode, None)
 
 
     def test_preset_modes(self):
     def test_preset_modes(self):
         self.assertCountEqual(
         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 def test_set_preset_mode_to_normal(self):
         async with assert_device_properties_set(
         async with assert_device_properties_set(
@@ -136,153 +199,190 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         ):
         ):
             await self.subject.async_set_preset_mode("normal")
             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(
         async with assert_device_properties_set(
             self.subject._device,
             self.subject._device,
             {PRESET_DPS: "nature"},
             {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 def test_set_preset_mode_to_sleep(self):
         async with assert_device_properties_set(
         async with assert_device_properties_set(
             self.subject._device,
             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):
     def test_swing_mode(self):
         self.dps[SWING_DPS] = False
         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.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.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):
     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(
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {SWING_DPS: False},
             {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(
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {SWING_DPS: True},
             {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")
     @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.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.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.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.dps[PRESET_DPS] = None
-        self.assertEqual(self.subject.fan_modes, [])
+        self.assertEqual(self.climate.fan_modes, [])
 
 
     @skip("Complex conditions not supported yet")
     @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[PRESET_DPS] = "normal"
 
 
         self.dps[FANMODE_DPS] = "1"
         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.dps[FANMODE_DPS] = "6"
-        self.assertEqual(self.subject.fan_mode, 6)
+        self.assertEqual(self.climate.fan_mode, 6)
 
 
         self.dps[FANMODE_DPS] = "12"
         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.dps[FANMODE_DPS] = None
-        self.assertEqual(self.subject.fan_mode, None)
+        self.assertEqual(self.climate.fan_mode, None)
 
 
     @skip("Complex conditions not supported yet")
     @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"
         self.dps[PRESET_DPS] = "normal"
 
 
         async with assert_device_properties_set(
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {FANMODE_DPS: "6"},
             {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")
     @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[PRESET_DPS] = "nature"
 
 
         self.dps[FANMODE_DPS] = "4"
         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.dps[FANMODE_DPS] = "8"
-        self.assertEqual(self.subject.fan_mode, 2)
+        self.assertEqual(self.climate.fan_mode, 2)
 
 
         self.dps[FANMODE_DPS] = "12"
         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.dps[FANMODE_DPS] = None
-        self.assertEqual(self.subject.fan_mode, None)
+        self.assertEqual(self.climate.fan_mode, None)
 
 
     @skip("Complex conditions not supported yet")
     @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"
         self.dps[PRESET_DPS] = "nature"
 
 
         async with assert_device_properties_set(
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {FANMODE_DPS: "4"},
             {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")
     @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[PRESET_DPS] = PRESET_SLEEP
 
 
         self.dps[FANMODE_DPS] = "4"
         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.dps[FANMODE_DPS] = "8"
-        self.assertEqual(self.subject.fan_mode, 2)
+        self.assertEqual(self.climate.fan_mode, 2)
 
 
         self.dps[FANMODE_DPS] = "12"
         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.dps[FANMODE_DPS] = None
-        self.assertEqual(self.subject.fan_mode, None)
+        self.assertEqual(self.climate.fan_mode, None)
 
 
     @skip("Complex conditions not supported yet")
     @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
         self.dps[PRESET_DPS] = PRESET_SLEEP
 
 
         async with assert_device_properties_set(
         async with assert_device_properties_set(
-            self.subject._device,
+            self.climate._device,
             {FANMODE_DPS: "8"},
             {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")
     @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
         self.dps[PRESET_DPS] = None
 
 
         with self.assertRaises(
         with self.assertRaises(
             ValueError, msg="Fan mode can only be set when a preset mode is set"
             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):
     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):
     async def test_update(self):
         result = AsyncMock()
         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 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,
     FAN_MODES,
     HVAC_MODE_TO_DPS_MODE,
     HVAC_MODE_TO_DPS_MODE,
     PRESET_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()