Ver Fonte

Refactor: merge generic/* implementations into the top-level platforms.

In the early days, each device had its own implementation in Python.  After a small handful of devices, the move to using config files with a "generic" implementation was made so the classes did not need to keep multiplying.
But it took a while for some of the more complex logic in the early classes to be implemented in a way that could be configured using conditional mappings etc, so initially the basic generic implementations were alongside the old device specific implementations, with the top level platforms just doing loading of HA config and delegating to the various implementations.

For some time now, we have been rid of the device specific implementations, and just have one generic implementation for each platform. Since there is a one to one mapping, it is easier to to follow the code if the generic implementation is merged with the top level platform file.
Jason Rumney há 3 anos atrás
pai
commit
0a13b4addf
46 ficheiros alterados com 2033 adições e 2109 exclusões
  1. 46 1
      custom_components/tuya_local/binary_sensor.py
  2. 34 1
      custom_components/tuya_local/button.py
  3. 382 1
      custom_components/tuya_local/climate.py
  4. 173 1
      custom_components/tuya_local/cover.py
  5. 150 1
      custom_components/tuya_local/fan.py
  6. 0 49
      custom_components/tuya_local/generic/binary_sensor.py
  7. 0 40
      custom_components/tuya_local/generic/button.py
  8. 0 385
      custom_components/tuya_local/generic/climate.py
  9. 0 177
      custom_components/tuya_local/generic/cover.py
  10. 0 153
      custom_components/tuya_local/generic/fan.py
  11. 0 121
      custom_components/tuya_local/generic/humidifier.py
  12. 0 345
      custom_components/tuya_local/generic/light.py
  13. 0 108
      custom_components/tuya_local/generic/lock.py
  14. 0 79
      custom_components/tuya_local/generic/number.py
  15. 0 43
      custom_components/tuya_local/generic/select.py
  16. 0 69
      custom_components/tuya_local/generic/sensor.py
  17. 0 100
      custom_components/tuya_local/generic/siren.py
  18. 0 59
      custom_components/tuya_local/generic/switch.py
  19. 0 169
      custom_components/tuya_local/generic/vacuum.py
  20. 0 152
      custom_components/tuya_local/generic/water_heater.py
  21. 117 1
      custom_components/tuya_local/humidifier.py
  22. 340 1
      custom_components/tuya_local/light.py
  23. 102 1
      custom_components/tuya_local/lock.py
  24. 76 1
      custom_components/tuya_local/number.py
  25. 40 1
      custom_components/tuya_local/select.py
  26. 66 1
      custom_components/tuya_local/sensor.py
  27. 97 1
      custom_components/tuya_local/siren.py
  28. 54 1
      custom_components/tuya_local/switch.py
  29. 166 1
      custom_components/tuya_local/vacuum.py
  30. 148 1
      custom_components/tuya_local/water_heater.py
  31. 15 15
      tests/devices/base_device_tests.py
  32. 4 2
      tests/test_binary_sensor.py
  33. 4 2
      tests/test_button.py
  34. 1 2
      tests/test_climate.py
  35. 1 2
      tests/test_cover.py
  36. 1 2
      tests/test_fan.py
  37. 4 2
      tests/test_humidifier.py
  38. 1 2
      tests/test_light.py
  39. 1 2
      tests/test_lock.py
  40. 1 2
      tests/test_number.py
  41. 1 2
      tests/test_select.py
  42. 1 2
      tests/test_sensor.py
  43. 1 2
      tests/test_siren.py
  44. 1 2
      tests/test_switch.py
  45. 1 2
      tests/test_vacuum.py
  46. 4 2
      tests/test_water_heater.py

+ 46 - 1
custom_components/tuya_local/binary_sensor.py

@@ -1,8 +1,18 @@
 """
 Setup for different kinds of Tuya Binary sensors
 """
-from .generic.binary_sensor import TuyaLocalBinarySensor
+from homeassistant.components.binary_sensor import (
+    BinarySensorEntity,
+    BinarySensorDeviceClass,
+)
+import logging
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +24,38 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "binary_sensor",
         TuyaLocalBinarySensor,
     )
+
+
+class TuyaLocalBinarySensor(TuyaLocalEntity, BinarySensorEntity):
+    """Representation of a Tuya Binary Sensor"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the sensor.
+        Args:
+            device (TuyaLocalDevice): the device API instance.
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        dps_map = self._init_begin(device, config)
+        self._sensor_dps = dps_map.pop("sensor")
+        if self._sensor_dps is None:
+            raise AttributeError(f"{config.name} is missing a sensor dps")
+        self._init_end(dps_map)
+
+    @property
+    def device_class(self):
+        """Return the class of this device"""
+        dclass = self._config.device_class
+        try:
+            return BinarySensorDeviceClass(dclass)
+        except ValueError:
+            if dclass:
+                _LOGGER.warning(
+                    f"Unrecognised binary_sensor device class of {dclass} ignored"
+                )
+            return None
+
+    @property
+    def is_on(self):
+        """Return true if the binary sensor is on."""
+        return self._sensor_dps.get_value(self._device)

+ 34 - 1
custom_components/tuya_local/button.py

@@ -1,8 +1,12 @@
 """
 Setup for different kinds of Tuya button devices
 """
-from .generic.button import TuyaLocalButton
+from homeassistant.components.button import ButtonEntity, ButtonDeviceClass
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +18,32 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "button",
         TuyaLocalButton,
     )
+
+
+class TuyaLocalButton(TuyaLocalEntity, ButtonEntity):
+    """Representation of a Tuya Button"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialize the button.
+        Args:
+            device (TuyaLocalDevice): The device API instance.
+            config (TuyaEntityConfig): The config portion for this entity.
+        """
+        dps_map = self._init_begin(device, config)
+        self._button_dp = dps_map.pop("button")
+        self._init_end(dps_map)
+
+    @property
+    def device_class(self):
+        """Return the class for this device"""
+        dclass = self._config.device_class
+        try:
+            return ButtonDeviceClass(dclass)
+        except ValueError:
+            if dclass:
+                _LOGGER.warning(f"Unrecognized button device class of {dclass} ignored")
+
+    async def async_press(self):
+        """Press the button"""
+        await self._button_dp.async_set_value(self._device, True)

+ 382 - 1
custom_components/tuya_local/climate.py

@@ -1,8 +1,42 @@
 """
 Setup for different kinds of Tuya climate devices
 """
-from .generic.climate import TuyaLocalClimate
+from homeassistant.components.climate import (
+    ClimateEntity,
+    ClimateEntityFeature,
+    HVACAction,
+    HVACMode,
+)
+from homeassistant.components.climate.const import (
+    ATTR_AUX_HEAT,
+    ATTR_CURRENT_HUMIDITY,
+    ATTR_CURRENT_TEMPERATURE,
+    ATTR_FAN_MODE,
+    ATTR_HUMIDITY,
+    ATTR_HVAC_ACTION,
+    ATTR_HVAC_MODE,
+    ATTR_PRESET_MODE,
+    ATTR_SWING_MODE,
+    ATTR_TARGET_TEMP_HIGH,
+    ATTR_TARGET_TEMP_LOW,
+    DEFAULT_MAX_HUMIDITY,
+    DEFAULT_MAX_TEMP,
+    DEFAULT_MIN_HUMIDITY,
+    DEFAULT_MIN_TEMP,
+)
+from homeassistant.const import (
+    ATTR_TEMPERATURE,
+    UnitOfTemperature,
+)
+import logging
+
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity, unit_from_ascii
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +48,350 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "climate",
         TuyaLocalClimate,
     )
+
+
+def validate_temp_unit(unit):
+    unit = unit_from_ascii(unit)
+    try:
+        return UnitOfTemperature(unit)
+    except ValueError:
+        return None
+
+
+class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
+    """Representation of a Tuya Climate entity."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the climate device.
+        Args:
+           device (TuyaLocalDevice): The device API instance.
+           config (TuyaEntityConfig): The entity config.
+        """
+        dps_map = self._init_begin(device, config)
+
+        self._aux_heat_dps = dps_map.pop(ATTR_AUX_HEAT, None)
+        self._current_temperature_dps = dps_map.pop(ATTR_CURRENT_TEMPERATURE, None)
+        self._current_humidity_dps = dps_map.pop(ATTR_CURRENT_HUMIDITY, None)
+        self._fan_mode_dps = dps_map.pop(ATTR_FAN_MODE, None)
+        self._humidity_dps = dps_map.pop(ATTR_HUMIDITY, None)
+        self._hvac_mode_dps = dps_map.pop(ATTR_HVAC_MODE, None)
+        self._hvac_action_dps = dps_map.pop(ATTR_HVAC_ACTION, None)
+        self._preset_mode_dps = dps_map.pop(ATTR_PRESET_MODE, None)
+        self._swing_mode_dps = dps_map.pop(ATTR_SWING_MODE, None)
+        self._temperature_dps = dps_map.pop(ATTR_TEMPERATURE, None)
+        self._temp_high_dps = dps_map.pop(ATTR_TARGET_TEMP_HIGH, None)
+        self._temp_low_dps = dps_map.pop(ATTR_TARGET_TEMP_LOW, None)
+        self._unit_dps = dps_map.pop("temperature_unit", None)
+        self._mintemp_dps = dps_map.pop("min_temperature", None)
+        self._maxtemp_dps = dps_map.pop("max_temperature", None)
+
+        self._init_end(dps_map)
+        self._support_flags = 0
+
+        if self._aux_heat_dps:
+            self._support_flags |= ClimateEntityFeature.AUX_HEAT
+        if self._fan_mode_dps:
+            self._support_flags |= ClimateEntityFeature.FAN_MODE
+        if self._humidity_dps:
+            self._support_flags |= ClimateEntityFeature.TARGET_HUMIDITY
+        if self._preset_mode_dps:
+            self._support_flags |= ClimateEntityFeature.PRESET_MODE
+        if self._swing_mode_dps:
+            self._support_flags |= ClimateEntityFeature.SWING_MODE
+
+        if self._temp_high_dps and self._temp_low_dps:
+            self._support_flags |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
+        elif self._temperature_dps is not None:
+            self._support_flags |= ClimateEntityFeature.TARGET_TEMPERATURE
+
+    @property
+    def supported_features(self):
+        """Return the features supported by this climate device."""
+        return self._support_flags
+
+    @property
+    def temperature_unit(self):
+        """Return the unit of measurement."""
+        # If there is a separate DPS that returns the units, use that
+        if self._unit_dps is not None:
+            unit = validate_temp_unit(self._unit_dps.get_value(self._device))
+            # Only return valid units
+            if unit is not None:
+                return unit
+        # If there unit attribute configured in the temperature dps, use that
+        if self._temperature_dps:
+            unit = validate_temp_unit(self._temperature_dps.unit)
+            if unit is not None:
+                return unit
+        # Return the default unit
+        return UnitOfTemperature.CELSIUS
+
+    @property
+    def target_temperature(self):
+        """Return the currently set target temperature."""
+        if self._temperature_dps is None:
+            raise NotImplementedError()
+        return self._temperature_dps.get_value(self._device)
+
+    @property
+    def target_temperature_high(self):
+        """Return the currently set high target temperature."""
+        if self._temp_high_dps is None:
+            raise NotImplementedError()
+        return self._temp_high_dps.get_value(self._device)
+
+    @property
+    def target_temperature_low(self):
+        """Return the currently set low target temperature."""
+        if self._temp_low_dps is None:
+            raise NotImplementedError()
+        return self._temp_low_dps.get_value(self._device)
+
+    @property
+    def target_temperature_step(self):
+        """Return the supported step of target temperature."""
+        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):
+        """Return the minimum supported target temperature."""
+        # if a separate min_temperature dps is specified, the device tells us.
+        if self._mintemp_dps is not None:
+            return self._mintemp_dps.get_value(self._device)
+
+        if self._temperature_dps is None:
+            if self._temp_low_dps is None:
+                return None
+            r = self._temp_low_dps.range(self._device)
+        else:
+            r = self._temperature_dps.range(self._device)
+        return DEFAULT_MIN_TEMP if r is None else r["min"]
+
+    @property
+    def max_temp(self):
+        """Return the maximum supported target temperature."""
+        # if a separate max_temperature dps is specified, the device tells us.
+        if self._maxtemp_dps is not None:
+            return self._maxtemp_dps.get_value(self._device)
+
+        if self._temperature_dps is None:
+            if self._temp_high_dps is None:
+                return None
+            r = self._temp_high_dps.range(self._device)
+        else:
+            r = self._temperature_dps.range(self._device)
+        return DEFAULT_MAX_TEMP if r is None else r["max"]
+
+    async def async_set_temperature(self, **kwargs):
+        """Set new target temperature."""
+        if kwargs.get(ATTR_PRESET_MODE) is not None:
+            await self.async_set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
+        if kwargs.get(ATTR_TEMPERATURE) is not None:
+            await self.async_set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
+        high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+        low = kwargs.get(ATTR_TARGET_TEMP_LOW)
+        if high is not None or low is not None:
+            await self.async_set_target_temperature_range(low, high)
+
+    async def async_set_target_temperature(self, target_temperature):
+        if self._temperature_dps is None:
+            raise NotImplementedError()
+
+        await self._temperature_dps.async_set_value(self._device, target_temperature)
+
+    async def async_set_target_temperature_range(self, low, high):
+        """Set the target temperature range."""
+        dps_map = {}
+        if low is not None and self._temp_low_dps is not None:
+            dps_map.update(self._temp_low_dps.get_values_to_set(self._device, low))
+        if high is not None and self._temp_high_dps is not None:
+            dps_map.update(self._temp_high_dps.get_values_to_set(self._device, high))
+        if dps_map:
+            await self._device.async_set_properties(dps_map)
+
+    @property
+    def current_temperature(self):
+        """Return the current measured temperature."""
+        if self._current_temperature_dps is None:
+            return None
+        return self._current_temperature_dps.get_value(self._device)
+
+    @property
+    def target_humidity(self):
+        """Return the currently set target humidity."""
+        if self._humidity_dps is None:
+            raise NotImplementedError()
+        return self._humidity_dps.get_value(self._device)
+
+    @property
+    def min_humidity(self):
+        """Return the minimum supported target humidity."""
+        if self._humidity_dps is None:
+            return None
+        r = self._humidity_dps.range(self._device)
+        return DEFAULT_MIN_HUMIDITY if r is None else r["min"]
+
+    @property
+    def max_humidity(self):
+        """Return the maximum supported target humidity."""
+        if self._humidity_dps is None:
+            return None
+        r = self._humidity_dps.range(self._device)
+        return DEFAULT_MAX_HUMIDITY if r is None else r["max"]
+
+    async def async_set_humidity(self, humidity: int):
+        if self._humidity_dps is None:
+            raise NotImplementedError()
+
+        await self._humidity_dps.async_set_value(self._device, humidity)
+
+    @property
+    def current_humidity(self):
+        """Return the current measured humidity."""
+        if self._current_humidity_dps is None:
+            return None
+        return self._current_humidity_dps.get_value(self._device)
+
+    @property
+    def hvac_action(self):
+        """Return the current HVAC action."""
+        if self._hvac_action_dps is None:
+            return None
+        action = self._hvac_action_dps.get_value(self._device)
+        try:
+            return HVACAction(action)
+        except ValueError:
+            _LOGGER.warning(f"_Unrecognised HVAC Action {action} ignored")
+            return None
+
+    @property
+    def hvac_mode(self):
+        """Return current HVAC mode."""
+        if self._hvac_mode_dps is None:
+            return HVACMode.AUTO
+        hvac_mode = self._hvac_mode_dps.get_value(self._device)
+        try:
+            return HVACMode(hvac_mode)
+        except ValueError:
+            _LOGGER.warning(f"Unrecognised HVAC Mode of {hvac_mode} ignored")
+            return None
+
+    @property
+    def hvac_modes(self):
+        """Return available HVAC modes."""
+        if self._hvac_mode_dps is None:
+            return []
+        else:
+            return self._hvac_mode_dps.values(self._device)
+
+    async def async_set_hvac_mode(self, hvac_mode):
+        """Set new HVAC mode."""
+        if self._hvac_mode_dps is None:
+            raise NotImplementedError()
+        await self._hvac_mode_dps.async_set_value(self._device, hvac_mode)
+
+    async def async_turn_on(self):
+        """Turn on the climate device."""
+        # Bypass the usual dps mapping to switch the power dp directly
+        # this way the hvac_mode will be kept when toggling off and on.
+        if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
+            await self._device.async_set_property(self._hvac_mode_dps.id, True)
+        else:
+            await super().async_turn_on()
+
+    async def async_turn_off(self):
+        """Turn off the climate device."""
+        # Bypass the usual dps mapping to switch the power dp directly
+        # this way the hvac_mode will be kept when toggling off and on.
+        if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
+            await self._device.async_set_property(self._hvac_mode_dps.id, False)
+        else:
+            await super().async_turn_off()
+
+    @property
+    def is_aux_heat(self):
+        """Return state of aux heater"""
+        if self._aux_heat_dps is None:
+            return None
+        else:
+            return self._aux_heat_dps.get_value(self._device)
+
+    async def async_turn_aux_heat_on(self):
+        """Turn on aux heater."""
+        if self._aux_heat_dps is None:
+            raise NotImplementedError()
+        await self._aux_heat_dps.async_set_value(self._device, True)
+
+    async def async_turn_aux_heat_off(self):
+        """Turn off aux heater."""
+        if self._aux_heat_dps is None:
+            raise NotImplementedError()
+        await self._aux_heat_dps.async_set_value(self._device, False)
+
+    @property
+    def preset_mode(self):
+        """Return the current preset mode."""
+        if self._preset_mode_dps is None:
+            raise NotImplementedError()
+        return self._preset_mode_dps.get_value(self._device)
+
+    @property
+    def preset_modes(self):
+        """Return the list of presets that this device supports."""
+        if self._preset_mode_dps is None:
+            return None
+        return self._preset_mode_dps.values(self._device)
+
+    async def async_set_preset_mode(self, preset_mode):
+        """Set the preset mode."""
+        if self._preset_mode_dps is None:
+            raise NotImplementedError()
+        await self._preset_mode_dps.async_set_value(self._device, preset_mode)
+
+    @property
+    def swing_mode(self):
+        """Return the current swing mode."""
+        if self._swing_mode_dps is None:
+            raise NotImplementedError()
+        return self._swing_mode_dps.get_value(self._device)
+
+    @property
+    def swing_modes(self):
+        """Return the list of swing modes that this device supports."""
+        if self._swing_mode_dps is None:
+            return None
+        return self._swing_mode_dps.values(self._device)
+
+    async def async_set_swing_mode(self, swing_mode):
+        """Set the preset mode."""
+        if self._swing_mode_dps is None:
+            raise NotImplementedError()
+        await self._swing_mode_dps.async_set_value(self._device, swing_mode)
+
+    @property
+    def fan_mode(self):
+        """Return the current fan mode."""
+        if self._fan_mode_dps is None:
+            raise NotImplementedError()
+        return self._fan_mode_dps.get_value(self._device)
+
+    @property
+    def fan_modes(self):
+        """Return the list of fan modes that this device supports."""
+        if self._fan_mode_dps is None:
+            return None
+        return self._fan_mode_dps.values(self._device)
+
+    async def async_set_fan_mode(self, fan_mode):
+        """Set the fan mode."""
+        if self._fan_mode_dps is None:
+            raise NotImplementedError()
+        await self._fan_mode_dps.async_set_value(self._device, fan_mode)

+ 173 - 1
custom_components/tuya_local/cover.py

@@ -1,8 +1,19 @@
 """
 Setup for different kinds of Tuya cover devices
 """
-from .generic.cover import TuyaLocalCover
+from homeassistant.components.cover import (
+    CoverDeviceClass,
+    CoverEntity,
+    CoverEntityFeature,
+)
+import logging
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +25,164 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "cover",
         TuyaLocalCover,
     )
+
+
+class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
+    """Representation of a Tuya Cover Entity."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the cover device.
+        Args:
+          device (TuyaLocalDevice): The device API instance
+          config (TuyaEntityConfig): The entity config
+        """
+        dps_map = self._init_begin(device, config)
+        self._position_dp = dps_map.pop("position", None)
+        self._currentpos_dp = dps_map.pop("current_position", None)
+        self._control_dp = dps_map.pop("control", None)
+        self._action_dp = dps_map.pop("action", None)
+        self._open_dp = dps_map.pop("open", None)
+        self._reversed_dp = dps_map.pop("reversed", None)
+        self._init_end(dps_map)
+
+        self._support_flags = 0
+        if self._position_dp:
+            self._support_flags |= CoverEntityFeature.SET_POSITION
+        if self._control_dp:
+            if "stop" in self._control_dp.values(self._device):
+                self._support_flags |= CoverEntityFeature.STOP
+            if "open" in self._control_dp.values(self._device):
+                self._support_flags |= CoverEntityFeature.OPEN
+            if "close" in self._control_dp.values(self._device):
+                self._support_flags |= CoverEntityFeature.CLOSE
+        # Tilt not yet supported, as no test devices known
+
+    @property
+    def _is_reversed(self):
+        return self._reversed_dp and self._reversed_dp.get_value(self._device)
+
+    def _maybe_reverse(self, percent):
+        """Reverse the percentage if it should be, otherwise leave it alone"""
+        return 100 - percent if self._is_reversed else percent
+
+    @property
+    def device_class(self):
+        """Return the class of ths device"""
+        dclass = self._config.device_class
+        try:
+            return CoverDeviceClass(dclass)
+        except ValueError:
+            if dclass:
+                _LOGGER.warning(f"Unrecognised cover device class of {dclass} ignored")
+            return None
+
+    @property
+    def supported_features(self):
+        """Inform HA of the supported features."""
+        return self._support_flags
+
+    def _state_to_percent(self, state):
+        """Convert a state to percent open"""
+        if state == "opened":
+            return 100
+        elif state == "closed":
+            return 0
+        else:
+            return 50
+
+    @property
+    def current_cover_position(self):
+        """Return current position of cover."""
+        if self._currentpos_dp:
+            pos = self._currentpos_dp.get_value(self._device)
+            if pos is not None:
+                return self._maybe_reverse(pos)
+
+        if self._position_dp:
+            pos = self._position_dp.get_value(self._device)
+            return self._maybe_reverse(pos)
+
+        if self._open_dp:
+            state = self._open_dp.get_value(self._device)
+            if state is not None:
+                return 100 if state else 0
+
+        if self._action_dp:
+            state = self._action_dp.get_value(self._device)
+            return self._state_to_percent(state)
+
+    @property
+    def is_opening(self):
+        """Return if the cover is opening or not."""
+        # If dps is available to inform current action, use that
+        if self._action_dp:
+            return self._action_dp.get_value(self._device) == "opening"
+        # Otherwise use last command and check it hasn't completed
+        if self._control_dp:
+            pos = self.current_cover_position
+            if pos is not None:
+                return (
+                    self._control_dp.get_value(self._device) == "open"
+                    and self.current_cover_position < 95
+                )
+
+    @property
+    def is_closing(self):
+        """Return if the cover is closing or not."""
+        # If dps is available to inform current action, use that
+        if self._action_dp:
+            return self._action_dp.get_value(self._device) == "closing"
+        # Otherwise use last command and check it hasn't completed
+        if self._control_dp:
+            closed = self.is_closed
+            if closed is not None:
+                return (
+                    self._control_dp.get_value(self._device) == "close" and not closed
+                )
+
+    @property
+    def is_closed(self):
+        """Return if the cover is closed or not, if it can be determined."""
+        # Only use position if it is reliable, otherwise curtain can become
+        # stuck in "open" state when we don't actually know what state it is.
+        pos = self.current_cover_position
+        if isinstance(pos, int):
+            return pos < 5
+
+    async def async_open_cover(self, **kwargs):
+        """Open the cover."""
+        if self._control_dp and "open" in self._control_dp.values(self._device):
+            await self._control_dp.async_set_value(self._device, "open")
+        elif self._position_dp:
+            pos = self._maybe_reverse(100)
+            await self._position_dp.async_set_value(self._device, pos)
+        else:
+            raise NotImplementedError()
+
+    async def async_close_cover(self, **kwargs):
+        """Close the cover."""
+        if self._control_dp and "close" in self._control_dp.values(self._device):
+            await self._control_dp.async_set_value(self._device, "close")
+        elif self._position_dp:
+            pos = self._maybe_reverse(0)
+            await self._position_dp.async_set_value(self._device, pos)
+        else:
+            raise NotImplementedError()
+
+    async def async_set_cover_position(self, position, **kwargs):
+        """Set the cover to a specific position."""
+        if position is None:
+            raise AttributeError()
+        if self._position_dp:
+            position = self._maybe_reverse(position)
+            await self._position_dp.async_set_value(self._device, position)
+        else:
+            raise NotImplementedError()
+
+    async def async_stop_cover(self, **kwargs):
+        """Stop the cover."""
+        if self._control_dp and "stop" in self._control_dp.values(self._device):
+            await self._control_dp.async_set_value(self._device, "stop")
+        else:
+            raise NotImplementedError()

+ 150 - 1
custom_components/tuya_local/fan.py

@@ -1,8 +1,19 @@
 """
 Setup for different kinds of Tuya fan devices
 """
-from .generic.fan import TuyaLocalFan
+from homeassistant.components.fan import (
+    FanEntity,
+    FanEntityFeature,
+)
+import logging
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
+
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +25,141 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "fan",
         TuyaLocalFan,
     )
+
+
+class TuyaLocalFan(TuyaLocalEntity, 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.
+        """
+        dps_map = self._init_begin(device, config)
+        self._switch_dps = dps_map.pop("switch", None)
+        self._preset_dps = dps_map.pop("preset_mode", None)
+        self._speed_dps = dps_map.pop("speed", None)
+        self._oscillate_dps = dps_map.pop("oscillate", None)
+        self._direction_dps = dps_map.pop("direction", None)
+        self._init_end(dps_map)
+
+        self._support_flags = 0
+        if self._preset_dps:
+            self._support_flags |= FanEntityFeature.PRESET_MODE
+        if self._speed_dps:
+            self._support_flags |= FanEntityFeature.SET_SPEED
+        if self._oscillate_dps:
+            self._support_flags |= FanEntityFeature.OSCILLATE
+        if self._direction_dps:
+            self._support_flags |= FanEntityFeature.DIRECTION
+
+    @property
+    def supported_features(self):
+        """Return the features supported by this climate device."""
+        return self._support_flags
+
+    @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 self.available
+        return self._switch_dps.get_value(self._device)
+
+    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
+        if self._speed_dps.values(self._device) is None:
+            return self._speed_dps.step(self._device)
+        else:
+            return 100 / len(self._speed_dps.values(self._device))
+
+    @property
+    def speed_count(self):
+        """Return the number of speeds supported by the fan."""
+        if self._speed_dps is None:
+            return 0
+        if self._speed_dps.values(self._device) is not None:
+            return len(self._speed_dps.values(self._device))
+        return int(round(100 / self.percentage_step))
+
+    async def async_set_percentage(self, percentage):
+        """Set the fan speed as a percentage."""
+        if self._speed_dps is None:
+            return None
+        # If there is a fixed list of values, snap to the closest one
+        if self._speed_dps.values(self._device) is not None:
+            percentage = min(
+                self._speed_dps.values(self._device), key=lambda x: abs(x - percentage)
+            )
+
+        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(self._device)
+
+    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)

+ 0 - 49
custom_components/tuya_local/generic/binary_sensor.py

@@ -1,49 +0,0 @@
-"""
-Platform to read Tuya binary sensors.
-"""
-from homeassistant.components.binary_sensor import (
-    BinarySensorEntity,
-    BinarySensorDeviceClass,
-)
-import logging
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TuyaLocalBinarySensor(TuyaLocalEntity, BinarySensorEntity):
-    """Representation of a Tuya Binary Sensor"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the sensor.
-        Args:
-            device (TuyaLocalDevice): the device API instance.
-            config (TuyaEntityConfig): the configuration for this entity
-        """
-        dps_map = self._init_begin(device, config)
-        self._sensor_dps = dps_map.pop("sensor")
-        if self._sensor_dps is None:
-            raise AttributeError(f"{config.name} is missing a sensor dps")
-        self._init_end(dps_map)
-
-    @property
-    def device_class(self):
-        """Return the class of this device"""
-        dclass = self._config.device_class
-        try:
-            return BinarySensorDeviceClass(dclass)
-        except ValueError:
-            if dclass:
-                _LOGGER.warning(
-                    f"Unrecognised binary_sensor device class of {dclass} ignored"
-                )
-            return None
-
-    @property
-    def is_on(self):
-        """Return true if the binary sensor is on."""
-        return self._sensor_dps.get_value(self._device)

+ 0 - 40
custom_components/tuya_local/generic/button.py

@@ -1,40 +0,0 @@
-"""
-Platform to control Tuya buttons.
-Buttons provide a way to send data to a Tuya dp which may not itself
-be readable.  If the device does not return any state for the dp, then
-it should be set as optional so it is not required to be present for detection.
-"""
-from homeassistant.components.button import ButtonEntity, ButtonDeviceClass
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-
-class TuyaLocalButton(TuyaLocalEntity, ButtonEntity):
-    """Representation of a Tuya Button"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialize the button.
-        Args:
-            device (TuyaLocalDevice): The device API instance.
-            config (TuyaEntityConfig): The config portion for this entity.
-        """
-        dps_map = self._init_begin(device, config)
-        self._button_dp = dps_map.pop("button")
-        self._init_end(dps_map)
-
-    @property
-    def device_class(self):
-        """Return the class for this device"""
-        dclass = self._config.device_class
-        try:
-            return ButtonDeviceClass(dclass)
-        except ValueError:
-            if dclass:
-                _LOGGER.warning(f"Unrecognized button device class of {dclass} ignored")
-
-    async def async_press(self):
-        """Press the button"""
-        await self._button_dp.async_set_value(self._device, True)

+ 0 - 385
custom_components/tuya_local/generic/climate.py

@@ -1,385 +0,0 @@
-"""
-Platform to control tuya climate devices.
-"""
-import logging
-
-from homeassistant.components.climate import (
-    ClimateEntity,
-    ClimateEntityFeature,
-    HVACAction,
-    HVACMode,
-)
-from homeassistant.components.climate.const import (
-    ATTR_AUX_HEAT,
-    ATTR_CURRENT_HUMIDITY,
-    ATTR_CURRENT_TEMPERATURE,
-    ATTR_FAN_MODE,
-    ATTR_HUMIDITY,
-    ATTR_HVAC_ACTION,
-    ATTR_HVAC_MODE,
-    ATTR_PRESET_MODE,
-    ATTR_SWING_MODE,
-    ATTR_TARGET_TEMP_HIGH,
-    ATTR_TARGET_TEMP_LOW,
-    DEFAULT_MAX_HUMIDITY,
-    DEFAULT_MAX_TEMP,
-    DEFAULT_MIN_HUMIDITY,
-    DEFAULT_MIN_TEMP,
-)
-from homeassistant.const import (
-    ATTR_TEMPERATURE,
-    UnitOfTemperature,
-)
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity, unit_from_ascii
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def validate_temp_unit(unit):
-    unit = unit_from_ascii(unit)
-    try:
-        return UnitOfTemperature(unit)
-    except ValueError:
-        return None
-
-
-class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
-    """Representation of a Tuya Climate entity."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the climate device.
-        Args:
-           device (TuyaLocalDevice): The device API instance.
-           config (TuyaEntityConfig): The entity config.
-        """
-        dps_map = self._init_begin(device, config)
-
-        self._aux_heat_dps = dps_map.pop(ATTR_AUX_HEAT, None)
-        self._current_temperature_dps = dps_map.pop(ATTR_CURRENT_TEMPERATURE, None)
-        self._current_humidity_dps = dps_map.pop(ATTR_CURRENT_HUMIDITY, None)
-        self._fan_mode_dps = dps_map.pop(ATTR_FAN_MODE, None)
-        self._humidity_dps = dps_map.pop(ATTR_HUMIDITY, None)
-        self._hvac_mode_dps = dps_map.pop(ATTR_HVAC_MODE, None)
-        self._hvac_action_dps = dps_map.pop(ATTR_HVAC_ACTION, None)
-        self._preset_mode_dps = dps_map.pop(ATTR_PRESET_MODE, None)
-        self._swing_mode_dps = dps_map.pop(ATTR_SWING_MODE, None)
-        self._temperature_dps = dps_map.pop(ATTR_TEMPERATURE, None)
-        self._temp_high_dps = dps_map.pop(ATTR_TARGET_TEMP_HIGH, None)
-        self._temp_low_dps = dps_map.pop(ATTR_TARGET_TEMP_LOW, None)
-        self._unit_dps = dps_map.pop("temperature_unit", None)
-        self._mintemp_dps = dps_map.pop("min_temperature", None)
-        self._maxtemp_dps = dps_map.pop("max_temperature", None)
-
-        self._init_end(dps_map)
-        self._support_flags = 0
-
-        if self._aux_heat_dps:
-            self._support_flags |= ClimateEntityFeature.AUX_HEAT
-        if self._fan_mode_dps:
-            self._support_flags |= ClimateEntityFeature.FAN_MODE
-        if self._humidity_dps:
-            self._support_flags |= ClimateEntityFeature.TARGET_HUMIDITY
-        if self._preset_mode_dps:
-            self._support_flags |= ClimateEntityFeature.PRESET_MODE
-        if self._swing_mode_dps:
-            self._support_flags |= ClimateEntityFeature.SWING_MODE
-
-        if self._temp_high_dps and self._temp_low_dps:
-            self._support_flags |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
-        elif self._temperature_dps is not None:
-            self._support_flags |= ClimateEntityFeature.TARGET_TEMPERATURE
-
-    @property
-    def supported_features(self):
-        """Return the features supported by this climate device."""
-        return self._support_flags
-
-    @property
-    def temperature_unit(self):
-        """Return the unit of measurement."""
-        # If there is a separate DPS that returns the units, use that
-        if self._unit_dps is not None:
-            unit = validate_temp_unit(self._unit_dps.get_value(self._device))
-            # Only return valid units
-            if unit is not None:
-                return unit
-        # If there unit attribute configured in the temperature dps, use that
-        if self._temperature_dps:
-            unit = validate_temp_unit(self._temperature_dps.unit)
-            if unit is not None:
-                return unit
-        # Return the default unit
-        return UnitOfTemperature.CELSIUS
-
-    @property
-    def target_temperature(self):
-        """Return the currently set target temperature."""
-        if self._temperature_dps is None:
-            raise NotImplementedError()
-        return self._temperature_dps.get_value(self._device)
-
-    @property
-    def target_temperature_high(self):
-        """Return the currently set high target temperature."""
-        if self._temp_high_dps is None:
-            raise NotImplementedError()
-        return self._temp_high_dps.get_value(self._device)
-
-    @property
-    def target_temperature_low(self):
-        """Return the currently set low target temperature."""
-        if self._temp_low_dps is None:
-            raise NotImplementedError()
-        return self._temp_low_dps.get_value(self._device)
-
-    @property
-    def target_temperature_step(self):
-        """Return the supported step of target temperature."""
-        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):
-        """Return the minimum supported target temperature."""
-        # if a separate min_temperature dps is specified, the device tells us.
-        if self._mintemp_dps is not None:
-            return self._mintemp_dps.get_value(self._device)
-
-        if self._temperature_dps is None:
-            if self._temp_low_dps is None:
-                return None
-            r = self._temp_low_dps.range(self._device)
-        else:
-            r = self._temperature_dps.range(self._device)
-        return DEFAULT_MIN_TEMP if r is None else r["min"]
-
-    @property
-    def max_temp(self):
-        """Return the maximum supported target temperature."""
-        # if a separate max_temperature dps is specified, the device tells us.
-        if self._maxtemp_dps is not None:
-            return self._maxtemp_dps.get_value(self._device)
-
-        if self._temperature_dps is None:
-            if self._temp_high_dps is None:
-                return None
-            r = self._temp_high_dps.range(self._device)
-        else:
-            r = self._temperature_dps.range(self._device)
-        return DEFAULT_MAX_TEMP if r is None else r["max"]
-
-    async def async_set_temperature(self, **kwargs):
-        """Set new target temperature."""
-        if kwargs.get(ATTR_PRESET_MODE) is not None:
-            await self.async_set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
-        if kwargs.get(ATTR_TEMPERATURE) is not None:
-            await self.async_set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
-        high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
-        low = kwargs.get(ATTR_TARGET_TEMP_LOW)
-        if high is not None or low is not None:
-            await self.async_set_target_temperature_range(low, high)
-
-    async def async_set_target_temperature(self, target_temperature):
-        if self._temperature_dps is None:
-            raise NotImplementedError()
-
-        await self._temperature_dps.async_set_value(self._device, target_temperature)
-
-    async def async_set_target_temperature_range(self, low, high):
-        """Set the target temperature range."""
-        dps_map = {}
-        if low is not None and self._temp_low_dps is not None:
-            dps_map.update(self._temp_low_dps.get_values_to_set(self._device, low))
-        if high is not None and self._temp_high_dps is not None:
-            dps_map.update(self._temp_high_dps.get_values_to_set(self._device, high))
-        if dps_map:
-            await self._device.async_set_properties(dps_map)
-
-    @property
-    def current_temperature(self):
-        """Return the current measured temperature."""
-        if self._current_temperature_dps is None:
-            return None
-        return self._current_temperature_dps.get_value(self._device)
-
-    @property
-    def target_humidity(self):
-        """Return the currently set target humidity."""
-        if self._humidity_dps is None:
-            raise NotImplementedError()
-        return self._humidity_dps.get_value(self._device)
-
-    @property
-    def min_humidity(self):
-        """Return the minimum supported target humidity."""
-        if self._humidity_dps is None:
-            return None
-        r = self._humidity_dps.range(self._device)
-        return DEFAULT_MIN_HUMIDITY if r is None else r["min"]
-
-    @property
-    def max_humidity(self):
-        """Return the maximum supported target humidity."""
-        if self._humidity_dps is None:
-            return None
-        r = self._humidity_dps.range(self._device)
-        return DEFAULT_MAX_HUMIDITY if r is None else r["max"]
-
-    async def async_set_humidity(self, humidity: int):
-        if self._humidity_dps is None:
-            raise NotImplementedError()
-
-        await self._humidity_dps.async_set_value(self._device, humidity)
-
-    @property
-    def current_humidity(self):
-        """Return the current measured humidity."""
-        if self._current_humidity_dps is None:
-            return None
-        return self._current_humidity_dps.get_value(self._device)
-
-    @property
-    def hvac_action(self):
-        """Return the current HVAC action."""
-        if self._hvac_action_dps is None:
-            return None
-        action = self._hvac_action_dps.get_value(self._device)
-        try:
-            return HVACAction(action)
-        except ValueError:
-            _LOGGER.warning(f"_Unrecognised HVAC Action {action} ignored")
-            return None
-
-    @property
-    def hvac_mode(self):
-        """Return current HVAC mode."""
-        if self._hvac_mode_dps is None:
-            return HVACMode.AUTO
-        hvac_mode = self._hvac_mode_dps.get_value(self._device)
-        try:
-            return HVACMode(hvac_mode)
-        except ValueError:
-            _LOGGER.warning(f"Unrecognised HVAC Mode of {hvac_mode} ignored")
-            return None
-
-    @property
-    def hvac_modes(self):
-        """Return available HVAC modes."""
-        if self._hvac_mode_dps is None:
-            return []
-        else:
-            return self._hvac_mode_dps.values(self._device)
-
-    async def async_set_hvac_mode(self, hvac_mode):
-        """Set new HVAC mode."""
-        if self._hvac_mode_dps is None:
-            raise NotImplementedError()
-        await self._hvac_mode_dps.async_set_value(self._device, hvac_mode)
-
-    async def async_turn_on(self):
-        """Turn on the climate device."""
-        # Bypass the usual dps mapping to switch the power dp directly
-        # this way the hvac_mode will be kept when toggling off and on.
-        if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
-            await self._device.async_set_property(self._hvac_mode_dps.id, True)
-        else:
-            await super().async_turn_on()
-
-    async def async_turn_off(self):
-        """Turn off the climate device."""
-        # Bypass the usual dps mapping to switch the power dp directly
-        # this way the hvac_mode will be kept when toggling off and on.
-        if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
-            await self._device.async_set_property(self._hvac_mode_dps.id, False)
-        else:
-            await super().async_turn_off()
-
-    @property
-    def is_aux_heat(self):
-        """Return state of aux heater"""
-        if self._aux_heat_dps is None:
-            return None
-        else:
-            return self._aux_heat_dps.get_value(self._device)
-
-    async def async_turn_aux_heat_on(self):
-        """Turn on aux heater."""
-        if self._aux_heat_dps is None:
-            raise NotImplementedError()
-        await self._aux_heat_dps.async_set_value(self._device, True)
-
-    async def async_turn_aux_heat_off(self):
-        """Turn off aux heater."""
-        if self._aux_heat_dps is None:
-            raise NotImplementedError()
-        await self._aux_heat_dps.async_set_value(self._device, False)
-
-    @property
-    def preset_mode(self):
-        """Return the current preset mode."""
-        if self._preset_mode_dps is None:
-            raise NotImplementedError()
-        return self._preset_mode_dps.get_value(self._device)
-
-    @property
-    def preset_modes(self):
-        """Return the list of presets that this device supports."""
-        if self._preset_mode_dps is None:
-            return None
-        return self._preset_mode_dps.values(self._device)
-
-    async def async_set_preset_mode(self, preset_mode):
-        """Set the preset mode."""
-        if self._preset_mode_dps is None:
-            raise NotImplementedError()
-        await self._preset_mode_dps.async_set_value(self._device, preset_mode)
-
-    @property
-    def swing_mode(self):
-        """Return the current swing mode."""
-        if self._swing_mode_dps is None:
-            raise NotImplementedError()
-        return self._swing_mode_dps.get_value(self._device)
-
-    @property
-    def swing_modes(self):
-        """Return the list of swing modes that this device supports."""
-        if self._swing_mode_dps is None:
-            return None
-        return self._swing_mode_dps.values(self._device)
-
-    async def async_set_swing_mode(self, swing_mode):
-        """Set the preset mode."""
-        if self._swing_mode_dps is None:
-            raise NotImplementedError()
-        await self._swing_mode_dps.async_set_value(self._device, swing_mode)
-
-    @property
-    def fan_mode(self):
-        """Return the current fan mode."""
-        if self._fan_mode_dps is None:
-            raise NotImplementedError()
-        return self._fan_mode_dps.get_value(self._device)
-
-    @property
-    def fan_modes(self):
-        """Return the list of fan modes that this device supports."""
-        if self._fan_mode_dps is None:
-            return None
-        return self._fan_mode_dps.values(self._device)
-
-    async def async_set_fan_mode(self, fan_mode):
-        """Set the fan mode."""
-        if self._fan_mode_dps is None:
-            raise NotImplementedError()
-        await self._fan_mode_dps.async_set_value(self._device, fan_mode)

+ 0 - 177
custom_components/tuya_local/generic/cover.py

@@ -1,177 +0,0 @@
-"""
-Platform to control tuya cover devices.
-"""
-import logging
-
-from homeassistant.components.cover import (
-    CoverDeviceClass,
-    CoverEntity,
-    CoverEntityFeature,
-)
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
-    """Representation of a Tuya Cover Entity."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the cover device.
-        Args:
-          device (TuyaLocalDevice): The device API instance
-          config (TuyaEntityConfig): The entity config
-        """
-        dps_map = self._init_begin(device, config)
-        self._position_dp = dps_map.pop("position", None)
-        self._currentpos_dp = dps_map.pop("current_position", None)
-        self._control_dp = dps_map.pop("control", None)
-        self._action_dp = dps_map.pop("action", None)
-        self._open_dp = dps_map.pop("open", None)
-        self._reversed_dp = dps_map.pop("reversed", None)
-        self._init_end(dps_map)
-
-        self._support_flags = 0
-        if self._position_dp:
-            self._support_flags |= CoverEntityFeature.SET_POSITION
-        if self._control_dp:
-            if "stop" in self._control_dp.values(self._device):
-                self._support_flags |= CoverEntityFeature.STOP
-            if "open" in self._control_dp.values(self._device):
-                self._support_flags |= CoverEntityFeature.OPEN
-            if "close" in self._control_dp.values(self._device):
-                self._support_flags |= CoverEntityFeature.CLOSE
-        # Tilt not yet supported, as no test devices known
-
-    @property
-    def _is_reversed(self):
-        return self._reversed_dp and self._reversed_dp.get_value(self._device)
-
-    def _maybe_reverse(self, percent):
-        """Reverse the percentage if it should be, otherwise leave it alone"""
-        return 100 - percent if self._is_reversed else percent
-
-    @property
-    def device_class(self):
-        """Return the class of ths device"""
-        dclass = self._config.device_class
-        try:
-            return CoverDeviceClass(dclass)
-        except ValueError:
-            if dclass:
-                _LOGGER.warning(f"Unrecognised cover device class of {dclass} ignored")
-            return None
-
-    @property
-    def supported_features(self):
-        """Inform HA of the supported features."""
-        return self._support_flags
-
-    def _state_to_percent(self, state):
-        """Convert a state to percent open"""
-        if state == "opened":
-            return 100
-        elif state == "closed":
-            return 0
-        else:
-            return 50
-
-    @property
-    def current_cover_position(self):
-        """Return current position of cover."""
-        if self._currentpos_dp:
-            pos = self._currentpos_dp.get_value(self._device)
-            if pos is not None:
-                return self._maybe_reverse(pos)
-
-        if self._position_dp:
-            pos = self._position_dp.get_value(self._device)
-            return self._maybe_reverse(pos)
-
-        if self._open_dp:
-            state = self._open_dp.get_value(self._device)
-            if state is not None:
-                return 100 if state else 0
-
-        if self._action_dp:
-            state = self._action_dp.get_value(self._device)
-            return self._state_to_percent(state)
-
-    @property
-    def is_opening(self):
-        """Return if the cover is opening or not."""
-        # If dps is available to inform current action, use that
-        if self._action_dp:
-            return self._action_dp.get_value(self._device) == "opening"
-        # Otherwise use last command and check it hasn't completed
-        if self._control_dp:
-            pos = self.current_cover_position
-            if pos is not None:
-                return (
-                    self._control_dp.get_value(self._device) == "open"
-                    and self.current_cover_position < 95
-                )
-
-    @property
-    def is_closing(self):
-        """Return if the cover is closing or not."""
-        # If dps is available to inform current action, use that
-        if self._action_dp:
-            return self._action_dp.get_value(self._device) == "closing"
-        # Otherwise use last command and check it hasn't completed
-        if self._control_dp:
-            closed = self.is_closed
-            if closed is not None:
-                return (
-                    self._control_dp.get_value(self._device) == "close" and not closed
-                )
-
-    @property
-    def is_closed(self):
-        """Return if the cover is closed or not, if it can be determined."""
-        # Only use position if it is reliable, otherwise curtain can become
-        # stuck in "open" state when we don't actually know what state it is.
-        pos = self.current_cover_position
-        if isinstance(pos, int):
-            return pos < 5
-
-    async def async_open_cover(self, **kwargs):
-        """Open the cover."""
-        if self._control_dp and "open" in self._control_dp.values(self._device):
-            await self._control_dp.async_set_value(self._device, "open")
-        elif self._position_dp:
-            pos = self._maybe_reverse(100)
-            await self._position_dp.async_set_value(self._device, pos)
-        else:
-            raise NotImplementedError()
-
-    async def async_close_cover(self, **kwargs):
-        """Close the cover."""
-        if self._control_dp and "close" in self._control_dp.values(self._device):
-            await self._control_dp.async_set_value(self._device, "close")
-        elif self._position_dp:
-            pos = self._maybe_reverse(0)
-            await self._position_dp.async_set_value(self._device, pos)
-        else:
-            raise NotImplementedError()
-
-    async def async_set_cover_position(self, position, **kwargs):
-        """Set the cover to a specific position."""
-        if position is None:
-            raise AttributeError()
-        if self._position_dp:
-            position = self._maybe_reverse(position)
-            await self._position_dp.async_set_value(self._device, position)
-        else:
-            raise NotImplementedError()
-
-    async def async_stop_cover(self, **kwargs):
-        """Stop the cover."""
-        if self._control_dp and "stop" in self._control_dp.values(self._device):
-            await self._control_dp.async_set_value(self._device, "stop")
-        else:
-            raise NotImplementedError()

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

@@ -1,153 +0,0 @@
-"""
-Platform to control tuya fan devices.
-"""
-import logging
-
-from homeassistant.components.fan import (
-    FanEntity,
-    FanEntityFeature,
-)
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TuyaLocalFan(TuyaLocalEntity, 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.
-        """
-        dps_map = self._init_begin(device, config)
-        self._switch_dps = dps_map.pop("switch", None)
-        self._preset_dps = dps_map.pop("preset_mode", None)
-        self._speed_dps = dps_map.pop("speed", None)
-        self._oscillate_dps = dps_map.pop("oscillate", None)
-        self._direction_dps = dps_map.pop("direction", None)
-        self._init_end(dps_map)
-
-        self._support_flags = 0
-        if self._preset_dps:
-            self._support_flags |= FanEntityFeature.PRESET_MODE
-        if self._speed_dps:
-            self._support_flags |= FanEntityFeature.SET_SPEED
-        if self._oscillate_dps:
-            self._support_flags |= FanEntityFeature.OSCILLATE
-        if self._direction_dps:
-            self._support_flags |= FanEntityFeature.DIRECTION
-
-    @property
-    def supported_features(self):
-        """Return the features supported by this climate device."""
-        return self._support_flags
-
-    @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 self.available
-        return self._switch_dps.get_value(self._device)
-
-    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
-        if self._speed_dps.values(self._device) is None:
-            return self._speed_dps.step(self._device)
-        else:
-            return 100 / len(self._speed_dps.values(self._device))
-
-    @property
-    def speed_count(self):
-        """Return the number of speeds supported by the fan."""
-        if self._speed_dps is None:
-            return 0
-        if self._speed_dps.values(self._device) is not None:
-            return len(self._speed_dps.values(self._device))
-        return int(round(100 / self.percentage_step))
-
-    async def async_set_percentage(self, percentage):
-        """Set the fan speed as a percentage."""
-        if self._speed_dps is None:
-            return None
-        # If there is a fixed list of values, snap to the closest one
-        if self._speed_dps.values(self._device) is not None:
-            percentage = min(
-                self._speed_dps.values(self._device), key=lambda x: abs(x - percentage)
-            )
-
-        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(self._device)
-
-    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)

+ 0 - 121
custom_components/tuya_local/generic/humidifier.py

@@ -1,121 +0,0 @@
-"""
-Platform to control tuya humidifier and dehumidifier devices.
-"""
-import logging
-
-from homeassistant.components.humidifier import (
-    HumidifierDeviceClass,
-    HumidifierEntity,
-    HumidifierEntityFeature,
-)
-
-from homeassistant.components.humidifier.const import (
-    DEFAULT_MAX_HUMIDITY,
-    DEFAULT_MIN_HUMIDITY,
-)
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TuyaLocalHumidifier(TuyaLocalEntity, HumidifierEntity):
-    """Representation of a Tuya Humidifier entity."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the humidifier device.
-        Args:
-           device (TuyaLocalDevice): The device API instance.
-           config (TuyaEntityConfig): The entity config.
-        """
-        dps_map = self._init_begin(device, config)
-        self._humidity_dps = dps_map.pop("humidity", None)
-        self._mode_dps = dps_map.pop("mode", None)
-        self._switch_dps = dps_map.pop("switch", None)
-        self._init_end(dps_map)
-
-        self._support_flags = 0
-        if self._mode_dps:
-            self._support_flags |= HumidifierEntityFeature.MODES
-
-    @property
-    def supported_features(self):
-        """Return the features supported by this climate device."""
-        return self._support_flags
-
-    @property
-    def device_class(self):
-        """Return the class of this device"""
-        return (
-            HumidifierDeviceClass.DEHUMIDIFIER
-            if self._config.device_class == "dehumidifier"
-            else HumidifierDeviceClass.HUMIDIFIER
-        )
-
-    @property
-    def is_on(self):
-        """Return whether the switch is on or not."""
-        # If there is no switch, it is always on if available
-        if self._switch_dps is None:
-            return self.available
-        return self._switch_dps.get_value(self._device)
-
-    async def async_turn_on(self, **kwargs):
-        """Turn the switch on"""
-        await self._switch_dps.async_set_value(self._device, True)
-
-    async def async_turn_off(self, **kwargs):
-        """Turn the switch off"""
-        await self._switch_dps.async_set_value(self._device, False)
-
-    @property
-    def target_humidity(self):
-        """Return the currently set target humidity."""
-        if self._humidity_dps is None:
-            raise NotImplementedError()
-        return self._humidity_dps.get_value(self._device)
-
-    @property
-    def min_humidity(self):
-        """Return the minimum supported target humidity."""
-        if self._humidity_dps is None:
-            return None
-        r = self._humidity_dps.range(self._device)
-        return DEFAULT_MIN_HUMIDITY if r is None else r["min"]
-
-    @property
-    def max_humidity(self):
-        """Return the maximum supported target humidity."""
-        if self._humidity_dps is None:
-            return None
-        r = self._humidity_dps.range(self._device)
-        return DEFAULT_MAX_HUMIDITY if r is None else r["max"]
-
-    async def async_set_humidity(self, humidity):
-        if self._humidity_dps is None:
-            raise NotImplementedError()
-
-        await self._humidity_dps.async_set_value(self._device, humidity)
-
-    @property
-    def mode(self):
-        """Return the current preset mode."""
-        if self._mode_dps is None:
-            raise NotImplementedError()
-        return self._mode_dps.get_value(self._device)
-
-    @property
-    def available_modes(self):
-        """Return the list of presets that this device supports."""
-        if self._mode_dps is None:
-            return None
-        return self._mode_dps.values(self._device)
-
-    async def async_set_mode(self, mode):
-        """Set the preset mode."""
-        if self._mode_dps is None:
-            raise NotImplementedError()
-        await self._mode_dps.async_set_value(self._device, mode)

+ 0 - 345
custom_components/tuya_local/generic/light.py

@@ -1,345 +0,0 @@
-"""
-Platform to control Tuya lights.
-Initially based on the secondary panel lighting control on some climate
-devices, so only providing simple on/off control.
-"""
-from homeassistant.components.light import (
-    ATTR_BRIGHTNESS,
-    ATTR_COLOR_MODE,
-    ATTR_COLOR_TEMP,
-    ATTR_EFFECT,
-    ATTR_RGBW_COLOR,
-    ColorMode,
-    LightEntity,
-    LightEntityFeature,
-)
-import homeassistant.util.color as color_util
-
-import logging
-from struct import pack, unpack
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TuyaLocalLight(TuyaLocalEntity, LightEntity):
-    """Representation of a Tuya WiFi-connected light."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialize the light.
-        Args:
-            device (TuyaLocalDevice): The device API instance.
-            config (TuyaEntityConfig): The configuration for this entity.
-        """
-        dps_map = self._init_begin(device, config)
-        self._switch_dps = dps_map.pop("switch", None)
-        self._brightness_dps = dps_map.pop("brightness", None)
-        self._color_mode_dps = dps_map.pop("color_mode", None)
-        self._color_temp_dps = dps_map.pop("color_temp", None)
-        self._rgbhsv_dps = dps_map.pop("rgbhsv", None)
-        self._effect_dps = dps_map.pop("effect", None)
-        self._init_end(dps_map)
-
-    @property
-    def supported_color_modes(self):
-        """Return the supported color modes for this light."""
-        if self._color_mode_dps:
-            return [
-                ColorMode(mode)
-                for mode in self._color_mode_dps.values(self._device)
-                if mode and hasattr(ColorMode, mode.upper())
-            ]
-        else:
-            try:
-                mode = ColorMode(self.color_mode)
-                if mode and mode != ColorMode.UNKNOWN:
-                    return [mode]
-            except ValueError:
-                _LOGGER.warning(f"Unrecognised color mode {self.color_mode} ignored")
-        return []
-
-    @property
-    def supported_features(self):
-        """Return the supported features for this light."""
-        if self.effect_list:
-            return LightEntityFeature.EFFECT
-        else:
-            return 0
-
-    @property
-    def color_mode(self):
-        """Return the color mode of the light"""
-        from_dp = self.raw_color_mode
-        if from_dp:
-            return from_dp
-
-        if self._rgbhsv_dps:
-            return ColorMode.RGBW
-        elif self._color_temp_dps:
-            return ColorMode.COLOR_TEMP
-        elif self._brightness_dps:
-            return ColorMode.BRIGHTNESS
-        elif self._switch_dps:
-            return ColorMode.ONOFF
-        else:
-            return ColorMode.UNKNOWN
-
-    @property
-    def raw_color_mode(self):
-        """Return the color_mode as set from the dps."""
-        if self._color_mode_dps:
-            mode = self._color_mode_dps.get_value(self._device)
-            if mode and hasattr(ColorMode, mode.upper()):
-                return ColorMode(mode)
-
-    @property
-    def color_temp(self):
-        """Return the color temperature in mireds"""
-        if self._color_temp_dps:
-            unscaled = self._color_temp_dps.get_value(self._device)
-            r = self._color_temp_dps.range(self._device)
-            if r and isinstance(unscaled, (int, float)):
-                return round(unscaled * 347 / (r["max"] - r["min"]) + 153 - r["min"])
-            else:
-                return unscaled
-
-    @property
-    def is_on(self):
-        """Return the current state."""
-        if self._switch_dps:
-            return self._switch_dps.get_value(self._device)
-        elif self._brightness_dps:
-            b = self.brightness
-            return isinstance(b, int) and b > 0
-        else:
-            # There shouldn't be lights without control, but if there are,
-            # assume always on if they are responding
-            return self.available
-
-    @property
-    def brightness(self):
-        """Get the current brightness of the light"""
-        if self._brightness_dps:
-            return self._brightness_dps.get_value(self._device)
-
-    @property
-    def rgbw_color(self):
-        """Get the current RGBW color of the light"""
-        if self._rgbhsv_dps:
-            # color data in hex format RRGGBBHHHHSSVV (14 digit hex)
-            # can also be base64 encoded.
-            # Either RGB or HSV can be used.
-            color = self._rgbhsv_dps.decoded_value(self._device)
-
-            fmt = self._rgbhsv_dps.format
-            if fmt and color:
-                vals = unpack(fmt.get("format"), color)
-                rgbhsv = {}
-                idx = 0
-                for v in vals:
-                    # Range in HA is 0-100 for s, 0-255 for rgb and v, 0-360
-                    # for h
-                    n = fmt["names"][idx]
-                    r = fmt["ranges"][idx]
-                    if r["min"] != 0:
-                        raise AttributeError(
-                            f"Unhandled minimum range for {n} in RGBW value"
-                        )
-                    mx = r["max"]
-                    scale = 1
-                    if n == "h":
-                        scale = 360 / mx
-                    elif n == "s":
-                        scale = 100 / mx
-                    else:
-                        scale = 255 / mx
-
-                    rgbhsv[n] = round(scale * v)
-                    idx += 1
-
-                h = rgbhsv["h"]
-                s = rgbhsv["s"]
-                # convert RGB from H and S to seperate out the V component
-                r, g, b = color_util.color_hs_to_RGB(h, s)
-                w = rgbhsv["v"]
-                return (r, g, b, w)
-
-    @property
-    def effect_list(self):
-        """Return the list of valid effects for the light"""
-        if self._effect_dps:
-            return self._effect_dps.values(self._device)
-        elif self._color_mode_dps:
-            return [
-                effect
-                for effect in self._color_mode_dps.values(self._device)
-                if effect and not hasattr(ColorMode, effect.upper())
-            ]
-
-    @property
-    def effect(self):
-        """Return the current effect setting of this light"""
-        if self._effect_dps:
-            return self._effect_dps.get_value(self._device)
-        elif self._color_mode_dps:
-            mode = self._color_mode_dps.get_value(self._device)
-            if mode and not hasattr(ColorMode, mode.upper()):
-                return mode
-
-    async def async_turn_on(self, **params):
-        settings = {}
-        color_mode = None
-
-        if self._color_temp_dps and ATTR_COLOR_TEMP in params:
-            if self.color_mode != ColorMode.COLOR_TEMP:
-                color_mode = ColorMode.COLOR_TEMP
-
-            color_temp = params.get(ATTR_COLOR_TEMP)
-            r = self._color_temp_dps.range(self._device)
-
-            if r and color_temp:
-                color_temp = round(
-                    (color_temp - 153 + r["min"]) * (r["max"] - r["min"]) / 347
-                )
-
-            _LOGGER.debug(f"Setting color temp to {color_temp}")
-            settings = {
-                **settings,
-                **self._color_temp_dps.get_values_to_set(self._device, color_temp),
-            }
-        elif self._rgbhsv_dps and (
-            ATTR_RGBW_COLOR in params
-            or (ATTR_BRIGHTNESS in params and self.raw_color_mode == ColorMode.RGBW)
-        ):
-            if self.raw_color_mode != ColorMode.RGBW:
-                color_mode = ColorMode.RGBW
-
-            rgbw = params.get(ATTR_RGBW_COLOR, self.rgbw_color or (0, 0, 0, 0))
-            brightness = params.get(ATTR_BRIGHTNESS, self.brightness or 255)
-            fmt = self._rgbhsv_dps.format
-            if rgbw and fmt:
-                rgb = (rgbw[0], rgbw[1], rgbw[2])
-                hs = color_util.color_RGB_to_hs(rgbw[0], rgbw[1], rgbw[2])
-                rgbhsv = {
-                    "r": rgb[0],
-                    "g": rgb[1],
-                    "b": rgb[2],
-                    "h": hs[0],
-                    "s": hs[1],
-                    "v": brightness,
-                }
-                _LOGGER.debug(
-                    f"Setting RGBW as {rgb[0]},{rgb[1]},{rgb[2]},{hs[0]},{hs[1]},{brightness}"
-                )
-                ordered = []
-                idx = 0
-                for n in fmt["names"]:
-                    r = fmt["ranges"][idx]
-                    scale = 1
-                    if n == "s":
-                        scale = r["max"] / 100
-                    elif n == "h":
-                        scale = r["max"] / 360
-                    else:
-                        scale = r["max"] / 255
-                    ordered.append(round(rgbhsv[n] * scale))
-                    idx += 1
-                binary = pack(fmt["format"], *ordered)
-                settings = {
-                    **settings,
-                    **self._rgbhsv_dps.get_values_to_set(
-                        self._device,
-                        self._rgbhsv_dps.encode_value(binary),
-                    ),
-                }
-        if self._color_mode_dps:
-            if color_mode:
-                _LOGGER.debug(f"Auto setting color mode to {color_mode}")
-                settings = {
-                    **settings,
-                    **self._color_mode_dps.get_values_to_set(self._device, color_mode),
-                }
-            elif not self._effect_dps:
-                effect = params.get(ATTR_EFFECT)
-                if effect:
-                    _LOGGER.debug(f"Emulating effect using color mode of {effect}")
-                    settings = {
-                        **settings,
-                        **self._color_mode_dps.get_values_to_set(
-                            self._device,
-                            effect,
-                        ),
-                    }
-
-        if (
-            ATTR_BRIGHTNESS in params
-            and self.raw_color_mode != ColorMode.RGBW
-            and self._brightness_dps
-        ):
-            bright = params.get(ATTR_BRIGHTNESS)
-            _LOGGER.debug(f"Setting brightness to {bright}")
-            settings = {
-                **settings,
-                **self._brightness_dps.get_values_to_set(
-                    self._device,
-                    bright,
-                ),
-            }
-
-        if self._effect_dps:
-            effect = params.get(ATTR_EFFECT, None)
-            if effect:
-                _LOGGER.debug(f"Setting effect to {effect}")
-                settings = {
-                    **settings,
-                    **self._effect_dps.get_values_to_set(
-                        self._device,
-                        effect,
-                    ),
-                }
-
-        if self._switch_dps and not self.is_on:
-            if (
-                self._switch_dps.readonly
-                and self._effect_dps
-                and "on" in self._effect_dps.values(self._device)
-            ):
-                # Special case for motion sensor lights with readonly switch
-                # that have tristate switch available as effect
-                if self._effect_dps.id not in settings:
-                    settings = settings | self._effect_dps.get_values_to_set(
-                        self._device, "on"
-                    )
-            else:
-                settings = settings | self._switch_dps.get_values_to_set(
-                    self._device, True
-                )
-
-        if settings:
-            await self._device.async_set_properties(settings)
-
-    async def async_turn_off(self):
-        if self._switch_dps:
-            if (
-                self._switch_dps.readonly
-                and self._effect_dps
-                and "off" in self._effect_dps.values(self._device)
-            ):
-                # Special case for motion sensor lights with readonly switch
-                # that have tristate switch available as effect
-                await self._effect_dps.async_set_value(self._device, "off")
-            else:
-                await self._switch_dps.async_set_value(self._device, False)
-        elif self._brightness_dps:
-            await self._brightness_dps.async_set_value(self._device, 0)
-        else:
-            raise NotImplementedError()
-
-    async def async_toggle(self):
-        disp_on = self.is_on
-
-        await (self.async_turn_on() if not disp_on else self.async_turn_off())

+ 0 - 108
custom_components/tuya_local/generic/lock.py

@@ -1,108 +0,0 @@
-"""
-Platform to control Tuya lock devices.
-
-Initial implementation is based on the secondary child-lock feature of Goldair
-climate devices.
-"""
-from homeassistant.components.lock import LockEntity
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-
-class TuyaLocalLock(TuyaLocalEntity, LockEntity):
-    """Representation of a Tuya Wi-Fi connected lock."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the lock.
-        Args:
-          device (TuyaLocalDevice): The device API instance.
-          config (TuyaEntityConfig): The configuration for this entity.
-        """
-        dps_map = self._init_begin(device, config)
-        self._lock_dp = dps_map.pop("lock", None)
-        self._unlock_fp_dp = dps_map.pop("unlock_fingerprint", None)
-        self._unlock_pw_dp = dps_map.pop("unlock_password", None)
-        self._unlock_tmppw_dp = dps_map.pop("unlock_temp_pwd", None)
-        self._unlock_dynpw_dp = dps_map.pop("unlock_dynamic_pwd", None)
-        self._unlock_card_dp = dps_map.pop("unlock_card", None)
-        self._unlock_app_dp = dps_map.pop("unlock_app", None)
-        self._unlock_key_dp = dps_map.pop("unlock_key", None)
-        self._req_unlock_dp = dps_map.pop("request_unlock", None)
-        self._approve_unlock_dp = dps_map.pop("approve_unlock", None)
-        self._req_intercom_dp = dps_map.pop("request_intercom", None)
-        self._approve_intercom_dp = dps_map.pop("approve_intercom", None)
-        self._jam_dp = dps_map.pop("jammed", None)
-        self._init_end(dps_map)
-
-    @property
-    def is_locked(self):
-        """Return the a boolean representing whether the lock is locked."""
-        lock = None
-        if self._lock_dp:
-            lock = self._lock_dp.get_value(self._device)
-        else:
-            for d in (
-                self._unlock_card_dp,
-                self._unlock_dynpw_dp,
-                self._unlock_fp_dp,
-                self._unlock_pw_dp,
-                self._unlock_tmppw_dp,
-                self._unlock_app_dp,
-                self._unlock_key_dp,
-            ):
-                if d:
-                    if d.get_value(self._device):
-                        lock = False
-                    elif lock is None:
-                        lock = True
-        return lock
-
-    @property
-    def is_jammed(self):
-        if self._jam_dp:
-            return self._jam_dp.get_value(self._device)
-
-    def unlocker_id(self, dp, type):
-        if dp:
-            unlock = dp.get_value(self._device)
-            if unlock:
-                if unlock is True:
-                    return f"{type}"
-                else:
-                    return f"{type} #{unlock}"
-
-    @property
-    def changed_by(self):
-        for dp, desc in {
-            self._unlock_app_dp: "App",
-            self._unlock_card_dp: "Card",
-            self._unlock_dynpw_dp: "Dynamic Password",
-            self._unlock_fp_dp: "Finger",
-            self._unlock_key_dp: "Key",
-            self._unlock_pw_dp: "Password",
-            self._unlock_tmppw_dp: "Temporary Password",
-        }.items():
-            by = self.unlocker_id(dp, desc)
-            if by:
-                return by
-
-    async def async_lock(self, **kwargs):
-        """Lock the lock."""
-        if self._lock_dp:
-            await self._lock_dp.async_set_value(self._device, True)
-        else:
-            raise NotImplementedError()
-
-    async def async_unlock(self, **kwargs):
-        """Unlock the lock."""
-        if self._lock_dp:
-            await self._lock_dp.async_set_value(self._device, False)
-        elif self._approve_unlock_dp:
-            if self._req_unlock_dp and not self._req_unlock_dp.get_value(self._device):
-                raise TimeoutError()
-            await self._approve_unlock_dp.async_set_value(self._device, True)
-        else:
-            raise NotImplementedError()

+ 0 - 79
custom_components/tuya_local/generic/number.py

@@ -1,79 +0,0 @@
-"""
-Platform for Tuya Number options that don't fit into other entity types.
-"""
-from homeassistant.components.number import NumberEntity
-from homeassistant.components.number.const import (
-    DEFAULT_MIN_VALUE,
-    DEFAULT_MAX_VALUE,
-)
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity, unit_from_ascii
-
-MODE_AUTO = "auto"
-
-
-class TuyaLocalNumber(TuyaLocalEntity, NumberEntity):
-    """Representation of a Tuya Number"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the sensor.
-        Args:
-            device (TuyaLocalDevice): the device API instance
-            config (TuyaEntityConfig): the configuration for this entity
-        """
-        dps_map = self._init_begin(device, config)
-        self._value_dps = dps_map.pop("value")
-        if self._value_dps is None:
-            raise AttributeError(f"{config.name} is missing a value dps")
-        self._unit_dps = dps_map.pop("unit", None)
-        self._min_dps = dps_map.pop("minimum", None)
-        self._max_dps = dps_map.pop("maximum", None)
-        self._init_end(dps_map)
-
-    @property
-    def native_min_value(self):
-        if self._min_dps is not None:
-            return self._min_dps.get_value(self._device)
-        r = self._value_dps.range(self._device)
-        return DEFAULT_MIN_VALUE if r is None else r["min"]
-
-    @property
-    def native_max_value(self):
-        if self._max_dps is not None:
-            return self._max_dps.get_value(self._device)
-        r = self._value_dps.range(self._device)
-        return DEFAULT_MAX_VALUE if r is None else r["max"]
-
-    @property
-    def native_step(self):
-        return self._value_dps.step(self._device)
-
-    @property
-    def mode(self):
-        """Return the mode."""
-        m = self._config.mode
-        if m is None:
-            m = MODE_AUTO
-        return m
-
-    @property
-    def native_unit_of_measurement(self):
-        """Return the unit associated with this number."""
-        if self._unit_dps is None:
-            unit = self._value_dps.unit
-        else:
-            unit = self._unit_dps.get_value(self._device)
-
-        return unit_from_ascii(unit)
-
-    @property
-    def native_value(self):
-        """Return the current value of the number."""
-        return self._value_dps.get_value(self._device)
-
-    async def async_set_native_value(self, value):
-        """Set the number."""
-        await self._value_dps.async_set_value(self._device, value)

+ 0 - 43
custom_components/tuya_local/generic/select.py

@@ -1,43 +0,0 @@
-"""
-Platform for Tuya Select options that don't fit into other entity types.
-"""
-from homeassistant.components.select import SelectEntity
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-
-class TuyaLocalSelect(TuyaLocalEntity, SelectEntity):
-    """Representation of a Tuya Select"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the select.
-        Args:
-            device (TuyaLocalDevice): the device API instance
-            config (TuyaEntityConfig): the configuration for this entity
-        """
-        dps_map = self._init_begin(device, config)
-        self._option_dps = dps_map.pop("option")
-        if self._option_dps is None:
-            raise AttributeError(f"{config.name} is missing an option dps")
-        if not self._option_dps.values(device):
-            raise AttributeError(
-                f"{config.name} does not have a mapping to a list of options"
-            )
-        self._init_end(dps_map)
-
-    @property
-    def options(self):
-        "Return the list of possible options."
-        return self._option_dps.values(self._device)
-
-    @property
-    def current_option(self):
-        "Return the currently selected option"
-        return self._option_dps.get_value(self._device)
-
-    async def async_select_option(self, option):
-        "Set the option"
-        await self._option_dps.async_set_value(self._device, option)

+ 0 - 69
custom_components/tuya_local/generic/sensor.py

@@ -1,69 +0,0 @@
-"""
-Platform to read Tuya sensors.
-"""
-from homeassistant.components.sensor import (
-    SensorDeviceClass,
-    SensorEntity,
-    STATE_CLASSES,
-)
-import logging
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity, unit_from_ascii
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TuyaLocalSensor(TuyaLocalEntity, SensorEntity):
-    """Representation of a Tuya Sensor"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the sensor.
-        Args:
-            device (TuyaLocalDevice): the device API instance.
-            config (TuyaEntityConfig): the configuration for this entity
-        """
-        dps_map = self._init_begin(device, config)
-        self._sensor_dps = dps_map.pop("sensor", None)
-        if self._sensor_dps is None:
-            raise AttributeError(f"{config.name} is missing a sensor dps")
-        self._unit_dps = dps_map.pop("unit", None)
-
-        self._init_end(dps_map)
-
-    @property
-    def device_class(self):
-        """Return the class of this device"""
-        dclass = self._config.device_class
-        try:
-            return SensorDeviceClass(dclass)
-        except ValueError:
-            if dclass:
-                _LOGGER.warning(f"Unrecognized sensor device class of {dclass} ignored")
-            return None
-
-    @property
-    def state_class(self):
-        """Return the state class of this entity"""
-        sclass = self._sensor_dps.state_class
-        if sclass in STATE_CLASSES:
-            return sclass
-        else:
-            return None
-
-    @property
-    def native_value(self):
-        """Return the value reported by the sensor"""
-        return self._sensor_dps.get_value(self._device)
-
-    @property
-    def native_unit_of_measurement(self):
-        """Return the unit for the sensor"""
-        if self._unit_dps is None:
-            unit = self._sensor_dps.unit
-        else:
-            unit = self._unit_dps.get_value(self._device)
-
-        return unit_from_ascii(unit)

+ 0 - 100
custom_components/tuya_local/generic/siren.py

@@ -1,100 +0,0 @@
-"""
-Platform to control Tuya sirens.
-"""
-from homeassistant.components.siren import (
-    SirenEntity,
-    SirenEntityDescription,
-    SirenEntityFeature,
-)
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-
-class TuyaLocalSiren(TuyaLocalEntity, SirenEntity):
-    """Representation of a Tuya siren"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialize the siren.
-        Args:
-           device (TuyaLocalDevice): The device API instance.
-           config (TuyaEntityConfig): The config for this entity.
-        """
-        dps_map = self._init_begin(device, config)
-        self._tone_dp = dps_map.get("tone", None)
-        self._volume_dp = dps_map.get("volume_level", None)
-        self._duration_dp = dps_map.get("duration", None)
-        self._init_end(dps_map)
-        # All control of features is through the turn_on service, so we need to
-        # support that, even if the siren does not support direct control
-        support = 0
-        if self._tone_dp:
-            support |= (
-                SirenEntityFeature.TONES
-                | SirenEntityFeature.TURN_ON
-                | SirenEntityFeature.TURN_OFF
-            )
-            self.entity_description = SirenEntityDescription
-            self.entity_description.available_tones = [
-                x for x in self._tone_dp.values(device) if x != "off"
-            ]
-            self._default_tone = self._tone_dp.default()
-
-        if self._volume_dp:
-            support |= SirenEntityFeature.VOLUME_SET
-        if self._duration_dp:
-            support |= SirenEntityFeature.DURATION
-        self._attr_supported_features = support
-
-    @property
-    def is_on(self):
-        """Return whether the siren is on."""
-        if self._tone_dp:
-            return self._tone_dp.get_value(self._device) != "off"
-
-    async def async_turn_on(self, **kwargs) -> None:
-        tone = kwargs.get("tone", None)
-        duration = kwargs.get("duration", None)
-        volume = kwargs.get("volume", None)
-        set_dps = {}
-
-        if self._tone_dp:
-            if tone is None:
-                tone = self._tone_dp.get_value(self._device)
-                if tone == "off":
-                    tone = self._default_tone
-
-            set_dps = {
-                **set_dps,
-                **self._tone_dp.get_values_to_set(self._device, tone),
-            }
-
-        if duration is not None and self._duration_dp:
-            set_dps = {
-                **set_dps,
-                **self._duration_dp.get_values_to_set(self._device, duration),
-            }
-
-        if volume is not None and self._volume_dp:
-            # Volume is a float, range 0.0-1.0 in Home Assistant
-            # In tuya it is likely an integer or a fixed list of values.
-            # For integer, expect scale and step to do the conversion,
-            # for fixed values, we need to snap to closest value.
-            if self._volume_dp.values(self._device) is not None:
-                volume = min(
-                    self._volume_dp.values(self._device), key=lambda x: abs(x - volume)
-                )
-
-            set_dps = {
-                **set_dps,
-                **self._volume_dp.get_values_to_set(self._device, volume),
-            }
-
-        await self._device.async_set_properties(set_dps)
-
-    async def async_turn_off(self) -> None:
-        """Turn off the siren"""
-        if self._tone_dp:
-            await self._tone_dp.async_set_value(self._device, "off")

+ 0 - 59
custom_components/tuya_local/generic/switch.py

@@ -1,59 +0,0 @@
-"""
-Platform to control Tuya switches.
-Initially based on the Kogan Switch and secondary switch for Purline M100
-heater open window detector toggle.
-"""
-from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-
-class TuyaLocalSwitch(TuyaLocalEntity, SwitchEntity):
-    """Representation of a Tuya Switch"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialize the switch.
-        Args:
-            device (TuyaLocalDevice): The device API instance.
-        """
-        dps_map = self._init_begin(device, config)
-        self._switch_dps = dps_map.pop("switch")
-        self._power_dps = dps_map.get("current_power_w", None)
-        self._init_end(dps_map)
-
-    @property
-    def device_class(self):
-        """Return the class of this device"""
-        return (
-            SwitchDeviceClass.OUTLET
-            if self._config.device_class == "outlet"
-            else SwitchDeviceClass.SWITCH
-        )
-
-    @property
-    def is_on(self):
-        """Return whether the switch is on or not."""
-        # if there is no switch, it is always on if available.
-        if self._switch_dps is None:
-            return self.available
-        return self._switch_dps.get_value(self._device)
-
-    @property
-    def current_power_w(self):
-        """Return the current power consumption in Watts."""
-        if self._power_dps is None:
-            return None
-
-        pwr = self._power_dps.get_value(self._device)
-        return pwr
-
-    async def async_turn_on(self, **kwargs):
-        """Turn the switch on"""
-        await self._switch_dps.async_set_value(self._device, True)
-
-    async def async_turn_off(self, **kwargs):
-        """Turn the switch off"""
-        await self._switch_dps.async_set_value(self._device, False)

+ 0 - 169
custom_components/tuya_local/generic/vacuum.py

@@ -1,169 +0,0 @@
-"""
-Platform to control Tuya robot vacuums.
-"""
-from homeassistant.components.vacuum import (
-    SERVICE_CLEAN_SPOT,
-    SERVICE_RETURN_TO_BASE,
-    STATE_CLEANING,
-    STATE_DOCKED,
-    STATE_RETURNING,
-    STATE_ERROR,
-    StateVacuumEntity,
-    VacuumEntityFeature,
-)
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-
-class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
-    """Representation of a Tuya Vacuum Cleaner"""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the sensor.
-        Args:
-            device (TuyaLocalDevice): the device API instance.
-            config (TuyaEntityConfig): the configuration for this entity
-        """
-        dps_map = self._init_begin(device, config)
-        self._status_dps = dps_map.get("status")
-        self._command_dps = dps_map.get("command")
-        self._locate_dps = dps_map.get("locate")
-        self._power_dps = dps_map.get("power")
-        self._active_dps = dps_map.get("activate")
-        self._battery_dps = dps_map.pop("battery", None)
-        self._direction_dps = dps_map.get("direction_control")
-        self._error_dps = dps_map.get("error")
-        self._fan_dps = dps_map.pop("fan_speed", None)
-
-        if self._status_dps is None:
-            raise AttributeError(f"{config.name} is missing a status dps")
-        self._init_end(dps_map)
-
-    @property
-    def supported_features(self):
-        """Return the features supported by this vacuum cleaner."""
-        support = (
-            VacuumEntityFeature.STATE
-            | VacuumEntityFeature.STATUS
-            | VacuumEntityFeature.SEND_COMMAND
-        )
-        if self._battery_dps:
-            support |= VacuumEntityFeature.BATTERY
-        if self._fan_dps:
-            support |= VacuumEntityFeature.FAN_SPEED
-        if self._power_dps:
-            support |= VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF
-        if self._active_dps:
-            support |= VacuumEntityFeature.START | VacuumEntityFeature.PAUSE
-        if self._locate_dps:
-            support |= VacuumEntityFeature.LOCATE
-
-        cmd_dps = self._command_dps or self._status_dps
-        cmd_support = cmd_dps.values(self._device)
-        if SERVICE_RETURN_TO_BASE in cmd_support:
-            support |= VacuumEntityFeature.RETURN_HOME
-        if SERVICE_CLEAN_SPOT in cmd_support:
-            support |= VacuumEntityFeature.CLEAN_SPOT
-        return support
-
-    @property
-    def battery_level(self):
-        """Return the battery level of the vacuum cleaner."""
-        if self._battery_dps:
-            return self._battery_dps.get_value(self._device)
-
-    @property
-    def status(self):
-        """Return the status of the vacuum cleaner."""
-        return self._status_dps.get_value(self._device)
-
-    @property
-    def state(self):
-        """Return the state of the vacuum cleaner."""
-        status = self.status
-        if self._error_dps and self._error_dps.get_value(self._device) != 0:
-            return STATE_ERROR
-        elif status in [SERVICE_RETURN_TO_BASE, "returning"]:
-            return STATE_RETURNING
-        elif status in ["standby", "charging"]:
-            return STATE_DOCKED
-        elif self._power_dps and not self._power_dps.get_value(self._device):
-            return STATE_DOCKED
-        elif self._active_dps and not self._active_dps.get_value(self._device):
-            return STATE_DOCKED
-        else:
-            return STATE_CLEANING
-
-    async def async_turn_on(self, **kwargs):
-        """Turn on the vacuum cleaner."""
-        if self._power_dps:
-            await self._power_dps.async_set_value(self._device, True)
-
-    async def async_turn_off(self, **kwargs):
-        """Turn off the vacuum cleaner."""
-        if self._power_dps:
-            await self._power_dps.async_set_value(self._device, False)
-
-    async def async_toggle(self, **kwargs):
-        """Toggle the vacuum cleaner."""
-        dps = self._power_dps
-        if not dps:
-            dps = self._activate_dps
-        if dps:
-            switch_to = not dps.get_value(self._device)
-            await dps.async_set_value(self._device, switch_to)
-
-    async def async_start(self):
-        if self._active_dps:
-            await self._active_dps.async_set_value(self._device, True)
-
-    async def async_pause(self):
-        """Pause the vacuum cleaner."""
-        if self._active_dps:
-            await self._active_dps.async_set_value(self._device, False)
-
-    async def async_return_to_base(self, **kwargs):
-        """Tell the vacuum cleaner to return to its base."""
-        dps = self._command_dps or self._status_dps
-        if dps and SERVICE_RETURN_TO_BASE in dps.values(self._device):
-            await dps.async_set_value(self._device, SERVICE_RETURN_TO_BASE)
-
-    async def async_clean_spot(self, **kwargs):
-        """Tell the vacuum cleaner do a spot clean."""
-        dps = self._command_dps or self._status_dps
-        if dps and SERVICE_CLEAN_SPOT in dps.values(self._device):
-            await dps.async_set_value(self._device, SERVICE_CLEAN_SPOT)
-
-    async def async_locate(self, **kwargs):
-        """Locate the vacuum cleaner."""
-        if self._locate_dps:
-            await self._locate_dps.async_set_value(self._device, True)
-
-    async def async_send_command(self, command, params=None, **kwargs):
-        """Send a command to the vacuum cleaner."""
-        dps = self._command_dps or self._status_dps
-        if command in dps.values(self._device):
-            await dps.async_set_value(self._device, command)
-        elif self._direction_dps and command in self._direction_dps.values(
-            self._device
-        ):
-            await self._direction_dps.async_set_value(self._device, command)
-
-    @property
-    def fan_speed_list(self):
-        """Return the list of fan speeds supported"""
-        if self._fan_dps:
-            return self._fan_dps.values(self._device)
-
-    @property
-    def fan_speed(self):
-        """Return the current fan speed"""
-        if self._fan_dps:
-            return self._fan_dps.get_value(self._device)
-
-    async def async_set_fan_speed(self, fan_speed, **kwargs):
-        """Set the fan speed of the vacuum."""
-        if self._fan_dps:
-            await self._fan_dps.async_set_value(self._device, fan_speed)

+ 0 - 152
custom_components/tuya_local/generic/water_heater.py

@@ -1,152 +0,0 @@
-"""
-Platform to control tuya water heater devices.
-"""
-import logging
-
-from homeassistant.components.water_heater import (
-    ATTR_CURRENT_TEMPERATURE,
-    ATTR_OPERATION_MODE,
-    WaterHeaterEntity,
-    WaterHeaterEntityFeature,
-)
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity, unit_from_ascii
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def validate_temp_unit(unit):
-    unit = unit_from_ascii(unit)
-    try:
-        return UnitOfTemperature(unit)
-    except ValueError:
-        return None
-
-
-class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
-    """Representation of a Tuya water heater entity."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the water heater device.
-        Args:
-           device (TuyaLocalDevice): The device API instance.
-           config (TuyaEntityConfig): The entity config.
-        """
-        dps_map = self._init_begin(device, config)
-
-        self._current_temperature_dps = dps_map.pop(ATTR_CURRENT_TEMPERATURE, None)
-        self._temperature_dps = dps_map.pop(ATTR_TEMPERATURE, None)
-        self._unit_dps = dps_map.pop("temperature_unit", None)
-        self._mintemp_dps = dps_map.pop("min_temperature", None)
-        self._maxtemp_dps = dps_map.pop("max_temperature", None)
-        self._operation_mode_dps = dps_map.pop("operation_mode", None)
-        self._init_end(dps_map)
-        self._support_flags = 0
-
-        if self._operation_mode_dps:
-            self._support_flags |= WaterHeaterEntityFeature.OPERATION_MODE
-        if self._temperature_dps and not self._temperature_dps.readonly:
-            self._support_flags |= WaterHeaterEntityFeature.TARGET_TEMPERATURE
-
-    @property
-    def supported_features(self):
-        """Return the features supported by this climate device."""
-        return self._support_flags
-
-    @property
-    def temperature_unit(self):
-        """Return the unit of measurement."""
-        # If there is a separate DPS that returns the units, use that
-        if self._unit_dps is not None:
-            unit = validate_temp_unit(self._unit_dps.get_value(self._device))
-            # Only return valid units
-            if unit is not None:
-                return unit
-        # If there unit attribute configured in the temperature dps, use that
-        if self._temperature_dps:
-            unit = validate_temp_unit(self._temperature_dps.unit)
-            if unit is not None:
-                return unit
-        # Return the default unit from the device
-        return UnitOfTemperature.CELSIUS
-
-    @property
-    def current_operation(self):
-        """Return current operation ie. eco, electric, performance, ..."""
-        return self._operation_mode_dps.get_value(self._device)
-
-    @property
-    def operation_list(self):
-        """Return the list of available operation modes."""
-        if self._operation_mode_dps is None:
-            return []
-        else:
-            return self._operation_mode_dps.values(self._device)
-
-    @property
-    def current_temperature(self):
-        """Return the current temperature."""
-        if self._current_temperature_dps is None:
-            return None
-        return self._current_temperature_dps.get_value(self._device)
-
-    @property
-    def target_temperature(self):
-        """Return the temperature we try to reach."""
-        if self._temperature_dps is None:
-            raise NotImplementedError()
-        return self._temperature_dps.get_value(self._device)
-
-    @property
-    def target_temperature_step(self):
-        """Return the supported step of target temperature."""
-        dps = self._temperature_dps
-        if dps is None:
-            return 1
-        return dps.step(self._device)
-
-    async def async_set_temperature(self, **kwargs):
-        """Set the target temperature of the water heater."""
-        if kwargs.get(ATTR_OPERATION_MODE) is not None:
-            if self._operation_mode_dps is None:
-                raise NotImplementedError()
-            await self.async_set_operation_mode(kwargs.get(ATTR_OPERATION_MODE))
-
-        if kwargs.get(ATTR_TEMPERATURE) is not None:
-            if self._temperature_dps is None:
-                raise NotImplementedError()
-            await self._temperature_dps.async_set_value(
-                self._device, kwargs.get(ATTR_TEMPERATURE)
-            )
-
-    async def async_set_operation_mode(self, operation_mode):
-        """Set new target operation mode."""
-        if self._operation_mode_dps is None:
-            raise NotImplementedError()
-        await self._operation_mode_dps.async_set_value(self._device, operation_mode)
-
-    @property
-    def min_temp(self):
-        """Return the minimum supported target temperature."""
-        # if a separate min_temperature dps is specified, the device tells us.
-        if self._mintemp_dps is not None:
-            return self._mintemp_dps.get_value(self._device)
-
-        if self._temperature_dps:
-            r = self._temperature_dps.range(self._device)
-            return r.get("min")
-
-    @property
-    def max_temp(self):
-        """Return the maximum supported target temperature."""
-        # if a separate max_temperature dps is specified, the device tells us.
-        if self._maxtemp_dps is not None:
-            return self._maxtemp_dps.get_value(self._device)
-
-        if self._temperature_dps:
-            r = self._temperature_dps.range(self._device)
-            return r.get("max")

+ 117 - 1
custom_components/tuya_local/humidifier.py

@@ -1,9 +1,25 @@
 """
 Setup for different kinds of Tuya humidifier devices
 """
-from .generic.humidifier import TuyaLocalHumidifier
+from homeassistant.components.humidifier import (
+    HumidifierDeviceClass,
+    HumidifierEntity,
+    HumidifierEntityFeature,
+)
+
+from homeassistant.components.humidifier.const import (
+    DEFAULT_MAX_HUMIDITY,
+    DEFAULT_MIN_HUMIDITY,
+)
+import logging
+
+from .device import TuyaLocalDevice
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
 from .helpers.config import async_tuya_setup_platform
 
+_LOGGER = logging.getLogger(__name__)
+
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
     config = {**config_entry.data, **config_entry.options}
@@ -14,3 +30,103 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "humidifier",
         TuyaLocalHumidifier,
     )
+
+
+class TuyaLocalHumidifier(TuyaLocalEntity, HumidifierEntity):
+    """Representation of a Tuya Humidifier entity."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the humidifier device.
+        Args:
+           device (TuyaLocalDevice): The device API instance.
+           config (TuyaEntityConfig): The entity config.
+        """
+        dps_map = self._init_begin(device, config)
+        self._humidity_dps = dps_map.pop("humidity", None)
+        self._mode_dps = dps_map.pop("mode", None)
+        self._switch_dps = dps_map.pop("switch", None)
+        self._init_end(dps_map)
+
+        self._support_flags = 0
+        if self._mode_dps:
+            self._support_flags |= HumidifierEntityFeature.MODES
+
+    @property
+    def supported_features(self):
+        """Return the features supported by this climate device."""
+        return self._support_flags
+
+    @property
+    def device_class(self):
+        """Return the class of this device"""
+        return (
+            HumidifierDeviceClass.DEHUMIDIFIER
+            if self._config.device_class == "dehumidifier"
+            else HumidifierDeviceClass.HUMIDIFIER
+        )
+
+    @property
+    def is_on(self):
+        """Return whether the switch is on or not."""
+        # If there is no switch, it is always on if available
+        if self._switch_dps is None:
+            return self.available
+        return self._switch_dps.get_value(self._device)
+
+    async def async_turn_on(self, **kwargs):
+        """Turn the switch on"""
+        await self._switch_dps.async_set_value(self._device, True)
+
+    async def async_turn_off(self, **kwargs):
+        """Turn the switch off"""
+        await self._switch_dps.async_set_value(self._device, False)
+
+    @property
+    def target_humidity(self):
+        """Return the currently set target humidity."""
+        if self._humidity_dps is None:
+            raise NotImplementedError()
+        return self._humidity_dps.get_value(self._device)
+
+    @property
+    def min_humidity(self):
+        """Return the minimum supported target humidity."""
+        if self._humidity_dps is None:
+            return None
+        r = self._humidity_dps.range(self._device)
+        return DEFAULT_MIN_HUMIDITY if r is None else r["min"]
+
+    @property
+    def max_humidity(self):
+        """Return the maximum supported target humidity."""
+        if self._humidity_dps is None:
+            return None
+        r = self._humidity_dps.range(self._device)
+        return DEFAULT_MAX_HUMIDITY if r is None else r["max"]
+
+    async def async_set_humidity(self, humidity):
+        if self._humidity_dps is None:
+            raise NotImplementedError()
+
+        await self._humidity_dps.async_set_value(self._device, humidity)
+
+    @property
+    def mode(self):
+        """Return the current preset mode."""
+        if self._mode_dps is None:
+            raise NotImplementedError()
+        return self._mode_dps.get_value(self._device)
+
+    @property
+    def available_modes(self):
+        """Return the list of presets that this device supports."""
+        if self._mode_dps is None:
+            return None
+        return self._mode_dps.values(self._device)
+
+    async def async_set_mode(self, mode):
+        """Set the preset mode."""
+        if self._mode_dps is None:
+            raise NotImplementedError()
+        await self._mode_dps.async_set_value(self._device, mode)

+ 340 - 1
custom_components/tuya_local/light.py

@@ -1,8 +1,27 @@
 """
 Setup for different kinds of Tuya light devices
 """
-from .generic.light import TuyaLocalLight
+from homeassistant.components.light import (
+    ATTR_BRIGHTNESS,
+    ATTR_COLOR_MODE,
+    ATTR_COLOR_TEMP,
+    ATTR_EFFECT,
+    ATTR_RGBW_COLOR,
+    ColorMode,
+    LightEntity,
+    LightEntityFeature,
+)
+import homeassistant.util.color as color_util
+
+import logging
+from struct import pack, unpack
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +33,323 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "light",
         TuyaLocalLight,
     )
+
+
+class TuyaLocalLight(TuyaLocalEntity, LightEntity):
+    """Representation of a Tuya WiFi-connected light."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialize the light.
+        Args:
+            device (TuyaLocalDevice): The device API instance.
+            config (TuyaEntityConfig): The configuration for this entity.
+        """
+        dps_map = self._init_begin(device, config)
+        self._switch_dps = dps_map.pop("switch", None)
+        self._brightness_dps = dps_map.pop("brightness", None)
+        self._color_mode_dps = dps_map.pop("color_mode", None)
+        self._color_temp_dps = dps_map.pop("color_temp", None)
+        self._rgbhsv_dps = dps_map.pop("rgbhsv", None)
+        self._effect_dps = dps_map.pop("effect", None)
+        self._init_end(dps_map)
+
+    @property
+    def supported_color_modes(self):
+        """Return the supported color modes for this light."""
+        if self._color_mode_dps:
+            return [
+                ColorMode(mode)
+                for mode in self._color_mode_dps.values(self._device)
+                if mode and hasattr(ColorMode, mode.upper())
+            ]
+        else:
+            try:
+                mode = ColorMode(self.color_mode)
+                if mode and mode != ColorMode.UNKNOWN:
+                    return [mode]
+            except ValueError:
+                _LOGGER.warning(f"Unrecognised color mode {self.color_mode} ignored")
+        return []
+
+    @property
+    def supported_features(self):
+        """Return the supported features for this light."""
+        if self.effect_list:
+            return LightEntityFeature.EFFECT
+        else:
+            return 0
+
+    @property
+    def color_mode(self):
+        """Return the color mode of the light"""
+        from_dp = self.raw_color_mode
+        if from_dp:
+            return from_dp
+
+        if self._rgbhsv_dps:
+            return ColorMode.RGBW
+        elif self._color_temp_dps:
+            return ColorMode.COLOR_TEMP
+        elif self._brightness_dps:
+            return ColorMode.BRIGHTNESS
+        elif self._switch_dps:
+            return ColorMode.ONOFF
+        else:
+            return ColorMode.UNKNOWN
+
+    @property
+    def raw_color_mode(self):
+        """Return the color_mode as set from the dps."""
+        if self._color_mode_dps:
+            mode = self._color_mode_dps.get_value(self._device)
+            if mode and hasattr(ColorMode, mode.upper()):
+                return ColorMode(mode)
+
+    @property
+    def color_temp(self):
+        """Return the color temperature in mireds"""
+        if self._color_temp_dps:
+            unscaled = self._color_temp_dps.get_value(self._device)
+            r = self._color_temp_dps.range(self._device)
+            if r and isinstance(unscaled, (int, float)):
+                return round(unscaled * 347 / (r["max"] - r["min"]) + 153 - r["min"])
+            else:
+                return unscaled
+
+    @property
+    def is_on(self):
+        """Return the current state."""
+        if self._switch_dps:
+            return self._switch_dps.get_value(self._device)
+        elif self._brightness_dps:
+            b = self.brightness
+            return isinstance(b, int) and b > 0
+        else:
+            # There shouldn't be lights without control, but if there are,
+            # assume always on if they are responding
+            return self.available
+
+    @property
+    def brightness(self):
+        """Get the current brightness of the light"""
+        if self._brightness_dps:
+            return self._brightness_dps.get_value(self._device)
+
+    @property
+    def rgbw_color(self):
+        """Get the current RGBW color of the light"""
+        if self._rgbhsv_dps:
+            # color data in hex format RRGGBBHHHHSSVV (14 digit hex)
+            # can also be base64 encoded.
+            # Either RGB or HSV can be used.
+            color = self._rgbhsv_dps.decoded_value(self._device)
+
+            fmt = self._rgbhsv_dps.format
+            if fmt and color:
+                vals = unpack(fmt.get("format"), color)
+                rgbhsv = {}
+                idx = 0
+                for v in vals:
+                    # Range in HA is 0-100 for s, 0-255 for rgb and v, 0-360
+                    # for h
+                    n = fmt["names"][idx]
+                    r = fmt["ranges"][idx]
+                    if r["min"] != 0:
+                        raise AttributeError(
+                            f"Unhandled minimum range for {n} in RGBW value"
+                        )
+                    mx = r["max"]
+                    scale = 1
+                    if n == "h":
+                        scale = 360 / mx
+                    elif n == "s":
+                        scale = 100 / mx
+                    else:
+                        scale = 255 / mx
+
+                    rgbhsv[n] = round(scale * v)
+                    idx += 1
+
+                h = rgbhsv["h"]
+                s = rgbhsv["s"]
+                # convert RGB from H and S to seperate out the V component
+                r, g, b = color_util.color_hs_to_RGB(h, s)
+                w = rgbhsv["v"]
+                return (r, g, b, w)
+
+    @property
+    def effect_list(self):
+        """Return the list of valid effects for the light"""
+        if self._effect_dps:
+            return self._effect_dps.values(self._device)
+        elif self._color_mode_dps:
+            return [
+                effect
+                for effect in self._color_mode_dps.values(self._device)
+                if effect and not hasattr(ColorMode, effect.upper())
+            ]
+
+    @property
+    def effect(self):
+        """Return the current effect setting of this light"""
+        if self._effect_dps:
+            return self._effect_dps.get_value(self._device)
+        elif self._color_mode_dps:
+            mode = self._color_mode_dps.get_value(self._device)
+            if mode and not hasattr(ColorMode, mode.upper()):
+                return mode
+
+    async def async_turn_on(self, **params):
+        settings = {}
+        color_mode = None
+
+        if self._color_temp_dps and ATTR_COLOR_TEMP in params:
+            if self.color_mode != ColorMode.COLOR_TEMP:
+                color_mode = ColorMode.COLOR_TEMP
+
+            color_temp = params.get(ATTR_COLOR_TEMP)
+            r = self._color_temp_dps.range(self._device)
+
+            if r and color_temp:
+                color_temp = round(
+                    (color_temp - 153 + r["min"]) * (r["max"] - r["min"]) / 347
+                )
+
+            _LOGGER.debug(f"Setting color temp to {color_temp}")
+            settings = {
+                **settings,
+                **self._color_temp_dps.get_values_to_set(self._device, color_temp),
+            }
+        elif self._rgbhsv_dps and (
+            ATTR_RGBW_COLOR in params
+            or (ATTR_BRIGHTNESS in params and self.raw_color_mode == ColorMode.RGBW)
+        ):
+            if self.raw_color_mode != ColorMode.RGBW:
+                color_mode = ColorMode.RGBW
+
+            rgbw = params.get(ATTR_RGBW_COLOR, self.rgbw_color or (0, 0, 0, 0))
+            brightness = params.get(ATTR_BRIGHTNESS, self.brightness or 255)
+            fmt = self._rgbhsv_dps.format
+            if rgbw and fmt:
+                rgb = (rgbw[0], rgbw[1], rgbw[2])
+                hs = color_util.color_RGB_to_hs(rgbw[0], rgbw[1], rgbw[2])
+                rgbhsv = {
+                    "r": rgb[0],
+                    "g": rgb[1],
+                    "b": rgb[2],
+                    "h": hs[0],
+                    "s": hs[1],
+                    "v": brightness,
+                }
+                _LOGGER.debug(
+                    f"Setting RGBW as {rgb[0]},{rgb[1]},{rgb[2]},{hs[0]},{hs[1]},{brightness}"
+                )
+                ordered = []
+                idx = 0
+                for n in fmt["names"]:
+                    r = fmt["ranges"][idx]
+                    scale = 1
+                    if n == "s":
+                        scale = r["max"] / 100
+                    elif n == "h":
+                        scale = r["max"] / 360
+                    else:
+                        scale = r["max"] / 255
+                    ordered.append(round(rgbhsv[n] * scale))
+                    idx += 1
+                binary = pack(fmt["format"], *ordered)
+                settings = {
+                    **settings,
+                    **self._rgbhsv_dps.get_values_to_set(
+                        self._device,
+                        self._rgbhsv_dps.encode_value(binary),
+                    ),
+                }
+        if self._color_mode_dps:
+            if color_mode:
+                _LOGGER.debug(f"Auto setting color mode to {color_mode}")
+                settings = {
+                    **settings,
+                    **self._color_mode_dps.get_values_to_set(self._device, color_mode),
+                }
+            elif not self._effect_dps:
+                effect = params.get(ATTR_EFFECT)
+                if effect:
+                    _LOGGER.debug(f"Emulating effect using color mode of {effect}")
+                    settings = {
+                        **settings,
+                        **self._color_mode_dps.get_values_to_set(
+                            self._device,
+                            effect,
+                        ),
+                    }
+
+        if (
+            ATTR_BRIGHTNESS in params
+            and self.raw_color_mode != ColorMode.RGBW
+            and self._brightness_dps
+        ):
+            bright = params.get(ATTR_BRIGHTNESS)
+            _LOGGER.debug(f"Setting brightness to {bright}")
+            settings = {
+                **settings,
+                **self._brightness_dps.get_values_to_set(
+                    self._device,
+                    bright,
+                ),
+            }
+
+        if self._effect_dps:
+            effect = params.get(ATTR_EFFECT, None)
+            if effect:
+                _LOGGER.debug(f"Setting effect to {effect}")
+                settings = {
+                    **settings,
+                    **self._effect_dps.get_values_to_set(
+                        self._device,
+                        effect,
+                    ),
+                }
+
+        if self._switch_dps and not self.is_on:
+            if (
+                self._switch_dps.readonly
+                and self._effect_dps
+                and "on" in self._effect_dps.values(self._device)
+            ):
+                # Special case for motion sensor lights with readonly switch
+                # that have tristate switch available as effect
+                if self._effect_dps.id not in settings:
+                    settings = settings | self._effect_dps.get_values_to_set(
+                        self._device, "on"
+                    )
+            else:
+                settings = settings | self._switch_dps.get_values_to_set(
+                    self._device, True
+                )
+
+        if settings:
+            await self._device.async_set_properties(settings)
+
+    async def async_turn_off(self):
+        if self._switch_dps:
+            if (
+                self._switch_dps.readonly
+                and self._effect_dps
+                and "off" in self._effect_dps.values(self._device)
+            ):
+                # Special case for motion sensor lights with readonly switch
+                # that have tristate switch available as effect
+                await self._effect_dps.async_set_value(self._device, "off")
+            else:
+                await self._switch_dps.async_set_value(self._device, False)
+        elif self._brightness_dps:
+            await self._brightness_dps.async_set_value(self._device, 0)
+        else:
+            raise NotImplementedError()
+
+    async def async_toggle(self):
+        disp_on = self.is_on
+
+        await (self.async_turn_on() if not disp_on else self.async_turn_off())

+ 102 - 1
custom_components/tuya_local/lock.py

@@ -1,8 +1,12 @@
 """
 Setup for different kinds of Tuya lock devices
 """
-from .generic.lock import TuyaLocalLock
+from homeassistant.components.lock import LockEntity
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +18,100 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "lock",
         TuyaLocalLock,
     )
+
+
+class TuyaLocalLock(TuyaLocalEntity, LockEntity):
+    """Representation of a Tuya Wi-Fi connected lock."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the lock.
+        Args:
+          device (TuyaLocalDevice): The device API instance.
+          config (TuyaEntityConfig): The configuration for this entity.
+        """
+        dps_map = self._init_begin(device, config)
+        self._lock_dp = dps_map.pop("lock", None)
+        self._unlock_fp_dp = dps_map.pop("unlock_fingerprint", None)
+        self._unlock_pw_dp = dps_map.pop("unlock_password", None)
+        self._unlock_tmppw_dp = dps_map.pop("unlock_temp_pwd", None)
+        self._unlock_dynpw_dp = dps_map.pop("unlock_dynamic_pwd", None)
+        self._unlock_card_dp = dps_map.pop("unlock_card", None)
+        self._unlock_app_dp = dps_map.pop("unlock_app", None)
+        self._unlock_key_dp = dps_map.pop("unlock_key", None)
+        self._req_unlock_dp = dps_map.pop("request_unlock", None)
+        self._approve_unlock_dp = dps_map.pop("approve_unlock", None)
+        self._req_intercom_dp = dps_map.pop("request_intercom", None)
+        self._approve_intercom_dp = dps_map.pop("approve_intercom", None)
+        self._jam_dp = dps_map.pop("jammed", None)
+        self._init_end(dps_map)
+
+    @property
+    def is_locked(self):
+        """Return the a boolean representing whether the lock is locked."""
+        lock = None
+        if self._lock_dp:
+            lock = self._lock_dp.get_value(self._device)
+        else:
+            for d in (
+                self._unlock_card_dp,
+                self._unlock_dynpw_dp,
+                self._unlock_fp_dp,
+                self._unlock_pw_dp,
+                self._unlock_tmppw_dp,
+                self._unlock_app_dp,
+                self._unlock_key_dp,
+            ):
+                if d:
+                    if d.get_value(self._device):
+                        lock = False
+                    elif lock is None:
+                        lock = True
+        return lock
+
+    @property
+    def is_jammed(self):
+        if self._jam_dp:
+            return self._jam_dp.get_value(self._device)
+
+    def unlocker_id(self, dp, type):
+        if dp:
+            unlock = dp.get_value(self._device)
+            if unlock:
+                if unlock is True:
+                    return f"{type}"
+                else:
+                    return f"{type} #{unlock}"
+
+    @property
+    def changed_by(self):
+        for dp, desc in {
+            self._unlock_app_dp: "App",
+            self._unlock_card_dp: "Card",
+            self._unlock_dynpw_dp: "Dynamic Password",
+            self._unlock_fp_dp: "Finger",
+            self._unlock_key_dp: "Key",
+            self._unlock_pw_dp: "Password",
+            self._unlock_tmppw_dp: "Temporary Password",
+        }.items():
+            by = self.unlocker_id(dp, desc)
+            if by:
+                return by
+
+    async def async_lock(self, **kwargs):
+        """Lock the lock."""
+        if self._lock_dp:
+            await self._lock_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_unlock(self, **kwargs):
+        """Unlock the lock."""
+        if self._lock_dp:
+            await self._lock_dp.async_set_value(self._device, False)
+        elif self._approve_unlock_dp:
+            if self._req_unlock_dp and not self._req_unlock_dp.get_value(self._device):
+                raise TimeoutError()
+            await self._approve_unlock_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()

+ 76 - 1
custom_components/tuya_local/number.py

@@ -1,8 +1,18 @@
 """
 Setup for different kinds of Tuya numbers
 """
-from .generic.number import TuyaLocalNumber
+from homeassistant.components.number import NumberEntity
+from homeassistant.components.number.const import (
+    DEFAULT_MIN_VALUE,
+    DEFAULT_MAX_VALUE,
+)
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity, unit_from_ascii
+
+MODE_AUTO = "auto"
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +24,68 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "number",
         TuyaLocalNumber,
     )
+
+
+class TuyaLocalNumber(TuyaLocalEntity, NumberEntity):
+    """Representation of a Tuya Number"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the sensor.
+        Args:
+            device (TuyaLocalDevice): the device API instance
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        dps_map = self._init_begin(device, config)
+        self._value_dps = dps_map.pop("value")
+        if self._value_dps is None:
+            raise AttributeError(f"{config.name} is missing a value dps")
+        self._unit_dps = dps_map.pop("unit", None)
+        self._min_dps = dps_map.pop("minimum", None)
+        self._max_dps = dps_map.pop("maximum", None)
+        self._init_end(dps_map)
+
+    @property
+    def native_min_value(self):
+        if self._min_dps is not None:
+            return self._min_dps.get_value(self._device)
+        r = self._value_dps.range(self._device)
+        return DEFAULT_MIN_VALUE if r is None else r["min"]
+
+    @property
+    def native_max_value(self):
+        if self._max_dps is not None:
+            return self._max_dps.get_value(self._device)
+        r = self._value_dps.range(self._device)
+        return DEFAULT_MAX_VALUE if r is None else r["max"]
+
+    @property
+    def native_step(self):
+        return self._value_dps.step(self._device)
+
+    @property
+    def mode(self):
+        """Return the mode."""
+        m = self._config.mode
+        if m is None:
+            m = MODE_AUTO
+        return m
+
+    @property
+    def native_unit_of_measurement(self):
+        """Return the unit associated with this number."""
+        if self._unit_dps is None:
+            unit = self._value_dps.unit
+        else:
+            unit = self._unit_dps.get_value(self._device)
+
+        return unit_from_ascii(unit)
+
+    @property
+    def native_value(self):
+        """Return the current value of the number."""
+        return self._value_dps.get_value(self._device)
+
+    async def async_set_native_value(self, value):
+        """Set the number."""
+        await self._value_dps.async_set_value(self._device, value)

+ 40 - 1
custom_components/tuya_local/select.py

@@ -1,8 +1,12 @@
 """
 Setup for different kinds of Tuya selects
 """
-from .generic.select import TuyaLocalSelect
+from homeassistant.components.select import SelectEntity
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +18,38 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "select",
         TuyaLocalSelect,
     )
+
+
+class TuyaLocalSelect(TuyaLocalEntity, SelectEntity):
+    """Representation of a Tuya Select"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the select.
+        Args:
+            device (TuyaLocalDevice): the device API instance
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        dps_map = self._init_begin(device, config)
+        self._option_dps = dps_map.pop("option")
+        if self._option_dps is None:
+            raise AttributeError(f"{config.name} is missing an option dps")
+        if not self._option_dps.values(device):
+            raise AttributeError(
+                f"{config.name} does not have a mapping to a list of options"
+            )
+        self._init_end(dps_map)
+
+    @property
+    def options(self):
+        "Return the list of possible options."
+        return self._option_dps.values(self._device)
+
+    @property
+    def current_option(self):
+        "Return the currently selected option"
+        return self._option_dps.get_value(self._device)
+
+    async def async_select_option(self, option):
+        "Set the option"
+        await self._option_dps.async_set_value(self._device, option)

+ 66 - 1
custom_components/tuya_local/sensor.py

@@ -1,8 +1,19 @@
 """
 Setup for different kinds of Tuya sensors
 """
-from .generic.sensor import TuyaLocalSensor
+from homeassistant.components.sensor import (
+    SensorDeviceClass,
+    SensorEntity,
+    STATE_CLASSES,
+)
+import logging
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity, unit_from_ascii
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +25,57 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "sensor",
         TuyaLocalSensor,
     )
+
+
+class TuyaLocalSensor(TuyaLocalEntity, SensorEntity):
+    """Representation of a Tuya Sensor"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the sensor.
+        Args:
+            device (TuyaLocalDevice): the device API instance.
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        dps_map = self._init_begin(device, config)
+        self._sensor_dps = dps_map.pop("sensor", None)
+        if self._sensor_dps is None:
+            raise AttributeError(f"{config.name} is missing a sensor dps")
+        self._unit_dps = dps_map.pop("unit", None)
+
+        self._init_end(dps_map)
+
+    @property
+    def device_class(self):
+        """Return the class of this device"""
+        dclass = self._config.device_class
+        try:
+            return SensorDeviceClass(dclass)
+        except ValueError:
+            if dclass:
+                _LOGGER.warning(f"Unrecognized sensor device class of {dclass} ignored")
+            return None
+
+    @property
+    def state_class(self):
+        """Return the state class of this entity"""
+        sclass = self._sensor_dps.state_class
+        if sclass in STATE_CLASSES:
+            return sclass
+        else:
+            return None
+
+    @property
+    def native_value(self):
+        """Return the value reported by the sensor"""
+        return self._sensor_dps.get_value(self._device)
+
+    @property
+    def native_unit_of_measurement(self):
+        """Return the unit for the sensor"""
+        if self._unit_dps is None:
+            unit = self._sensor_dps.unit
+        else:
+            unit = self._unit_dps.get_value(self._device)
+
+        return unit_from_ascii(unit)

+ 97 - 1
custom_components/tuya_local/siren.py

@@ -1,8 +1,16 @@
 """
 Setup for Tuya siren devices
 """
-from .generic.siren import TuyaLocalSiren
+from homeassistant.components.siren import (
+    SirenEntity,
+    SirenEntityDescription,
+    SirenEntityFeature,
+)
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +22,91 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "siren",
         TuyaLocalSiren,
     )
+
+
+class TuyaLocalSiren(TuyaLocalEntity, SirenEntity):
+    """Representation of a Tuya siren"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialize the siren.
+        Args:
+           device (TuyaLocalDevice): The device API instance.
+           config (TuyaEntityConfig): The config for this entity.
+        """
+        dps_map = self._init_begin(device, config)
+        self._tone_dp = dps_map.get("tone", None)
+        self._volume_dp = dps_map.get("volume_level", None)
+        self._duration_dp = dps_map.get("duration", None)
+        self._init_end(dps_map)
+        # All control of features is through the turn_on service, so we need to
+        # support that, even if the siren does not support direct control
+        support = 0
+        if self._tone_dp:
+            support |= (
+                SirenEntityFeature.TONES
+                | SirenEntityFeature.TURN_ON
+                | SirenEntityFeature.TURN_OFF
+            )
+            self.entity_description = SirenEntityDescription
+            self.entity_description.available_tones = [
+                x for x in self._tone_dp.values(device) if x != "off"
+            ]
+            self._default_tone = self._tone_dp.default()
+
+        if self._volume_dp:
+            support |= SirenEntityFeature.VOLUME_SET
+        if self._duration_dp:
+            support |= SirenEntityFeature.DURATION
+        self._attr_supported_features = support
+
+    @property
+    def is_on(self):
+        """Return whether the siren is on."""
+        if self._tone_dp:
+            return self._tone_dp.get_value(self._device) != "off"
+
+    async def async_turn_on(self, **kwargs) -> None:
+        tone = kwargs.get("tone", None)
+        duration = kwargs.get("duration", None)
+        volume = kwargs.get("volume", None)
+        set_dps = {}
+
+        if self._tone_dp:
+            if tone is None:
+                tone = self._tone_dp.get_value(self._device)
+                if tone == "off":
+                    tone = self._default_tone
+
+            set_dps = {
+                **set_dps,
+                **self._tone_dp.get_values_to_set(self._device, tone),
+            }
+
+        if duration is not None and self._duration_dp:
+            set_dps = {
+                **set_dps,
+                **self._duration_dp.get_values_to_set(self._device, duration),
+            }
+
+        if volume is not None and self._volume_dp:
+            # Volume is a float, range 0.0-1.0 in Home Assistant
+            # In tuya it is likely an integer or a fixed list of values.
+            # For integer, expect scale and step to do the conversion,
+            # for fixed values, we need to snap to closest value.
+            if self._volume_dp.values(self._device) is not None:
+                volume = min(
+                    self._volume_dp.values(self._device), key=lambda x: abs(x - volume)
+                )
+
+            set_dps = {
+                **set_dps,
+                **self._volume_dp.get_values_to_set(self._device, volume),
+            }
+
+        await self._device.async_set_properties(set_dps)
+
+    async def async_turn_off(self) -> None:
+        """Turn off the siren"""
+        if self._tone_dp:
+            await self._tone_dp.async_set_value(self._device, "off")

+ 54 - 1
custom_components/tuya_local/switch.py

@@ -1,8 +1,12 @@
 """
 Setup for different kinds of Tuya switch devices
 """
-from .generic.switch import TuyaLocalSwitch
+from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +18,52 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "switch",
         TuyaLocalSwitch,
     )
+
+
+class TuyaLocalSwitch(TuyaLocalEntity, SwitchEntity):
+    """Representation of a Tuya Switch"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialize the switch.
+        Args:
+            device (TuyaLocalDevice): The device API instance.
+        """
+        dps_map = self._init_begin(device, config)
+        self._switch_dps = dps_map.pop("switch")
+        self._power_dps = dps_map.get("current_power_w", None)
+        self._init_end(dps_map)
+
+    @property
+    def device_class(self):
+        """Return the class of this device"""
+        return (
+            SwitchDeviceClass.OUTLET
+            if self._config.device_class == "outlet"
+            else SwitchDeviceClass.SWITCH
+        )
+
+    @property
+    def is_on(self):
+        """Return whether the switch is on or not."""
+        # if there is no switch, it is always on if available.
+        if self._switch_dps is None:
+            return self.available
+        return self._switch_dps.get_value(self._device)
+
+    @property
+    def current_power_w(self):
+        """Return the current power consumption in Watts."""
+        if self._power_dps is None:
+            return None
+
+        pwr = self._power_dps.get_value(self._device)
+        return pwr
+
+    async def async_turn_on(self, **kwargs):
+        """Turn the switch on"""
+        await self._switch_dps.async_set_value(self._device, True)
+
+    async def async_turn_off(self, **kwargs):
+        """Turn the switch off"""
+        await self._switch_dps.async_set_value(self._device, False)

+ 166 - 1
custom_components/tuya_local/vacuum.py

@@ -1,8 +1,20 @@
 """
 Setup for different kinds of Tuya vacuum cleaners
 """
-from .generic.vacuum import TuyaLocalVacuum
+from homeassistant.components.vacuum import (
+    SERVICE_CLEAN_SPOT,
+    SERVICE_RETURN_TO_BASE,
+    STATE_CLEANING,
+    STATE_DOCKED,
+    STATE_RETURNING,
+    STATE_ERROR,
+    StateVacuumEntity,
+    VacuumEntityFeature,
+)
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +26,156 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "vacuum",
         TuyaLocalVacuum,
     )
+
+
+class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
+    """Representation of a Tuya Vacuum Cleaner"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the sensor.
+        Args:
+            device (TuyaLocalDevice): the device API instance.
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        dps_map = self._init_begin(device, config)
+        self._status_dps = dps_map.get("status")
+        self._command_dps = dps_map.get("command")
+        self._locate_dps = dps_map.get("locate")
+        self._power_dps = dps_map.get("power")
+        self._active_dps = dps_map.get("activate")
+        self._battery_dps = dps_map.pop("battery", None)
+        self._direction_dps = dps_map.get("direction_control")
+        self._error_dps = dps_map.get("error")
+        self._fan_dps = dps_map.pop("fan_speed", None)
+
+        if self._status_dps is None:
+            raise AttributeError(f"{config.name} is missing a status dps")
+        self._init_end(dps_map)
+
+    @property
+    def supported_features(self):
+        """Return the features supported by this vacuum cleaner."""
+        support = (
+            VacuumEntityFeature.STATE
+            | VacuumEntityFeature.STATUS
+            | VacuumEntityFeature.SEND_COMMAND
+        )
+        if self._battery_dps:
+            support |= VacuumEntityFeature.BATTERY
+        if self._fan_dps:
+            support |= VacuumEntityFeature.FAN_SPEED
+        if self._power_dps:
+            support |= VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF
+        if self._active_dps:
+            support |= VacuumEntityFeature.START | VacuumEntityFeature.PAUSE
+        if self._locate_dps:
+            support |= VacuumEntityFeature.LOCATE
+
+        cmd_dps = self._command_dps or self._status_dps
+        cmd_support = cmd_dps.values(self._device)
+        if SERVICE_RETURN_TO_BASE in cmd_support:
+            support |= VacuumEntityFeature.RETURN_HOME
+        if SERVICE_CLEAN_SPOT in cmd_support:
+            support |= VacuumEntityFeature.CLEAN_SPOT
+        return support
+
+    @property
+    def battery_level(self):
+        """Return the battery level of the vacuum cleaner."""
+        if self._battery_dps:
+            return self._battery_dps.get_value(self._device)
+
+    @property
+    def status(self):
+        """Return the status of the vacuum cleaner."""
+        return self._status_dps.get_value(self._device)
+
+    @property
+    def state(self):
+        """Return the state of the vacuum cleaner."""
+        status = self.status
+        if self._error_dps and self._error_dps.get_value(self._device) != 0:
+            return STATE_ERROR
+        elif status in [SERVICE_RETURN_TO_BASE, "returning"]:
+            return STATE_RETURNING
+        elif status in ["standby", "charging"]:
+            return STATE_DOCKED
+        elif self._power_dps and not self._power_dps.get_value(self._device):
+            return STATE_DOCKED
+        elif self._active_dps and not self._active_dps.get_value(self._device):
+            return STATE_DOCKED
+        else:
+            return STATE_CLEANING
+
+    async def async_turn_on(self, **kwargs):
+        """Turn on the vacuum cleaner."""
+        if self._power_dps:
+            await self._power_dps.async_set_value(self._device, True)
+
+    async def async_turn_off(self, **kwargs):
+        """Turn off the vacuum cleaner."""
+        if self._power_dps:
+            await self._power_dps.async_set_value(self._device, False)
+
+    async def async_toggle(self, **kwargs):
+        """Toggle the vacuum cleaner."""
+        dps = self._power_dps
+        if not dps:
+            dps = self._activate_dps
+        if dps:
+            switch_to = not dps.get_value(self._device)
+            await dps.async_set_value(self._device, switch_to)
+
+    async def async_start(self):
+        if self._active_dps:
+            await self._active_dps.async_set_value(self._device, True)
+
+    async def async_pause(self):
+        """Pause the vacuum cleaner."""
+        if self._active_dps:
+            await self._active_dps.async_set_value(self._device, False)
+
+    async def async_return_to_base(self, **kwargs):
+        """Tell the vacuum cleaner to return to its base."""
+        dps = self._command_dps or self._status_dps
+        if dps and SERVICE_RETURN_TO_BASE in dps.values(self._device):
+            await dps.async_set_value(self._device, SERVICE_RETURN_TO_BASE)
+
+    async def async_clean_spot(self, **kwargs):
+        """Tell the vacuum cleaner do a spot clean."""
+        dps = self._command_dps or self._status_dps
+        if dps and SERVICE_CLEAN_SPOT in dps.values(self._device):
+            await dps.async_set_value(self._device, SERVICE_CLEAN_SPOT)
+
+    async def async_locate(self, **kwargs):
+        """Locate the vacuum cleaner."""
+        if self._locate_dps:
+            await self._locate_dps.async_set_value(self._device, True)
+
+    async def async_send_command(self, command, params=None, **kwargs):
+        """Send a command to the vacuum cleaner."""
+        dps = self._command_dps or self._status_dps
+        if command in dps.values(self._device):
+            await dps.async_set_value(self._device, command)
+        elif self._direction_dps and command in self._direction_dps.values(
+            self._device
+        ):
+            await self._direction_dps.async_set_value(self._device, command)
+
+    @property
+    def fan_speed_list(self):
+        """Return the list of fan speeds supported"""
+        if self._fan_dps:
+            return self._fan_dps.values(self._device)
+
+    @property
+    def fan_speed(self):
+        """Return the current fan speed"""
+        if self._fan_dps:
+            return self._fan_dps.get_value(self._device)
+
+    async def async_set_fan_speed(self, fan_speed, **kwargs):
+        """Set the fan speed of the vacuum."""
+        if self._fan_dps:
+            await self._fan_dps.async_set_value(self._device, fan_speed)

+ 148 - 1
custom_components/tuya_local/water_heater.py

@@ -1,8 +1,21 @@
 """
 Setup for different kinds of Tuya water heater devices
 """
-from .generic.water_heater import TuyaLocalWaterHeater
+from homeassistant.components.water_heater import (
+    ATTR_CURRENT_TEMPERATURE,
+    ATTR_OPERATION_MODE,
+    WaterHeaterEntity,
+    WaterHeaterEntityFeature,
+)
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+import logging
+
+from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity, unit_from_ascii
+
+_LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -14,3 +27,137 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
         "water_heater",
         TuyaLocalWaterHeater,
     )
+
+
+def validate_temp_unit(unit):
+    unit = unit_from_ascii(unit)
+    try:
+        return UnitOfTemperature(unit)
+    except ValueError:
+        return None
+
+
+class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
+    """Representation of a Tuya water heater entity."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the water heater device.
+        Args:
+           device (TuyaLocalDevice): The device API instance.
+           config (TuyaEntityConfig): The entity config.
+        """
+        dps_map = self._init_begin(device, config)
+
+        self._current_temperature_dps = dps_map.pop(ATTR_CURRENT_TEMPERATURE, None)
+        self._temperature_dps = dps_map.pop(ATTR_TEMPERATURE, None)
+        self._unit_dps = dps_map.pop("temperature_unit", None)
+        self._mintemp_dps = dps_map.pop("min_temperature", None)
+        self._maxtemp_dps = dps_map.pop("max_temperature", None)
+        self._operation_mode_dps = dps_map.pop("operation_mode", None)
+        self._init_end(dps_map)
+        self._support_flags = 0
+
+        if self._operation_mode_dps:
+            self._support_flags |= WaterHeaterEntityFeature.OPERATION_MODE
+        if self._temperature_dps and not self._temperature_dps.readonly:
+            self._support_flags |= WaterHeaterEntityFeature.TARGET_TEMPERATURE
+
+    @property
+    def supported_features(self):
+        """Return the features supported by this climate device."""
+        return self._support_flags
+
+    @property
+    def temperature_unit(self):
+        """Return the unit of measurement."""
+        # If there is a separate DPS that returns the units, use that
+        if self._unit_dps is not None:
+            unit = validate_temp_unit(self._unit_dps.get_value(self._device))
+            # Only return valid units
+            if unit is not None:
+                return unit
+        # If there unit attribute configured in the temperature dps, use that
+        if self._temperature_dps:
+            unit = validate_temp_unit(self._temperature_dps.unit)
+            if unit is not None:
+                return unit
+        # Return the default unit from the device
+        return UnitOfTemperature.CELSIUS
+
+    @property
+    def current_operation(self):
+        """Return current operation ie. eco, electric, performance, ..."""
+        return self._operation_mode_dps.get_value(self._device)
+
+    @property
+    def operation_list(self):
+        """Return the list of available operation modes."""
+        if self._operation_mode_dps is None:
+            return []
+        else:
+            return self._operation_mode_dps.values(self._device)
+
+    @property
+    def current_temperature(self):
+        """Return the current temperature."""
+        if self._current_temperature_dps is None:
+            return None
+        return self._current_temperature_dps.get_value(self._device)
+
+    @property
+    def target_temperature(self):
+        """Return the temperature we try to reach."""
+        if self._temperature_dps is None:
+            raise NotImplementedError()
+        return self._temperature_dps.get_value(self._device)
+
+    @property
+    def target_temperature_step(self):
+        """Return the supported step of target temperature."""
+        dps = self._temperature_dps
+        if dps is None:
+            return 1
+        return dps.step(self._device)
+
+    async def async_set_temperature(self, **kwargs):
+        """Set the target temperature of the water heater."""
+        if kwargs.get(ATTR_OPERATION_MODE) is not None:
+            if self._operation_mode_dps is None:
+                raise NotImplementedError()
+            await self.async_set_operation_mode(kwargs.get(ATTR_OPERATION_MODE))
+
+        if kwargs.get(ATTR_TEMPERATURE) is not None:
+            if self._temperature_dps is None:
+                raise NotImplementedError()
+            await self._temperature_dps.async_set_value(
+                self._device, kwargs.get(ATTR_TEMPERATURE)
+            )
+
+    async def async_set_operation_mode(self, operation_mode):
+        """Set new target operation mode."""
+        if self._operation_mode_dps is None:
+            raise NotImplementedError()
+        await self._operation_mode_dps.async_set_value(self._device, operation_mode)
+
+    @property
+    def min_temp(self):
+        """Return the minimum supported target temperature."""
+        # if a separate min_temperature dps is specified, the device tells us.
+        if self._mintemp_dps is not None:
+            return self._mintemp_dps.get_value(self._device)
+
+        if self._temperature_dps:
+            r = self._temperature_dps.range(self._device)
+            return r.get("min")
+
+    @property
+    def max_temp(self):
+        """Return the maximum supported target temperature."""
+        # if a separate max_temperature dps is specified, the device tells us.
+        if self._maxtemp_dps is not None:
+            return self._maxtemp_dps.get_value(self._device)
+
+        if self._temperature_dps:
+            r = self._temperature_dps.range(self._device)
+            return r.get("max")

+ 15 - 15
tests/devices/base_device_tests.py

@@ -4,21 +4,21 @@ from uuid import uuid4
 
 from homeassistant.helpers.entity import EntityCategory
 
-from custom_components.tuya_local.generic.binary_sensor import TuyaLocalBinarySensor
-from custom_components.tuya_local.generic.button import TuyaLocalButton
-from custom_components.tuya_local.generic.climate import TuyaLocalClimate
-from custom_components.tuya_local.generic.cover import TuyaLocalCover
-from custom_components.tuya_local.generic.fan import TuyaLocalFan
-from custom_components.tuya_local.generic.humidifier import TuyaLocalHumidifier
-from custom_components.tuya_local.generic.light import TuyaLocalLight
-from custom_components.tuya_local.generic.lock import TuyaLocalLock
-from custom_components.tuya_local.generic.number import TuyaLocalNumber
-from custom_components.tuya_local.generic.select import TuyaLocalSelect
-from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
-from custom_components.tuya_local.generic.siren import TuyaLocalSiren
-from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
-from custom_components.tuya_local.generic.vacuum import TuyaLocalVacuum
-from custom_components.tuya_local.generic.water_heater import TuyaLocalWaterHeater
+from custom_components.tuya_local.binary_sensor import TuyaLocalBinarySensor
+from custom_components.tuya_local.button import TuyaLocalButton
+from custom_components.tuya_local.climate import TuyaLocalClimate
+from custom_components.tuya_local.cover import TuyaLocalCover
+from custom_components.tuya_local.fan import TuyaLocalFan
+from custom_components.tuya_local.humidifier import TuyaLocalHumidifier
+from custom_components.tuya_local.light import TuyaLocalLight
+from custom_components.tuya_local.lock import TuyaLocalLock
+from custom_components.tuya_local.number import TuyaLocalNumber
+from custom_components.tuya_local.select import TuyaLocalSelect
+from custom_components.tuya_local.sensor import TuyaLocalSensor
+from custom_components.tuya_local.siren import TuyaLocalSiren
+from custom_components.tuya_local.switch import TuyaLocalSwitch
+from custom_components.tuya_local.vacuum import TuyaLocalVacuum
+from custom_components.tuya_local.water_heater import TuyaLocalWaterHeater
 
 from custom_components.tuya_local.helpers.device_config import (
     TuyaDeviceConfig,

+ 4 - 2
tests/test_binary_sensor.py

@@ -9,8 +9,10 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.binary_sensor import TuyaLocalBinarySensor
-from custom_components.tuya_local.binary_sensor import async_setup_entry
+from custom_components.tuya_local.binary_sensor import (
+    async_setup_entry,
+    TuyaLocalBinarySensor,
+)
 
 
 @pytest.mark.asyncio

+ 4 - 2
tests/test_button.py

@@ -9,8 +9,10 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.button import TuyaLocalButton
-from custom_components.tuya_local.button import async_setup_entry
+from custom_components.tuya_local.button import (
+    async_setup_entry,
+    TuyaLocalButton,
+)
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_climate.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.climate import TuyaLocalClimate
-from custom_components.tuya_local.climate import async_setup_entry
+from custom_components.tuya_local.climate import async_setup_entry, TuyaLocalClimate
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_cover.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_PROTOCOL_VERSION,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.cover import TuyaLocalCover
-from custom_components.tuya_local.cover import async_setup_entry
+from custom_components.tuya_local.cover import async_setup_entry, TuyaLocalCover
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_fan.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.fan import TuyaLocalFan
-from custom_components.tuya_local.fan import async_setup_entry
+from custom_components.tuya_local.fan import async_setup_entry, TuyaLocalFan
 
 
 @pytest.mark.asyncio

+ 4 - 2
tests/test_humidifier.py

@@ -9,8 +9,10 @@ from custom_components.tuya_local.const import (
     CONF_PROTOCOL_VERSION,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.humidifier import TuyaLocalHumidifier
-from custom_components.tuya_local.humidifier import async_setup_entry
+from custom_components.tuya_local.humidifier import (
+    async_setup_entry,
+    TuyaLocalHumidifier,
+)
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_light.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_PROTOCOL_VERSION,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.light import TuyaLocalLight
-from custom_components.tuya_local.light import async_setup_entry
+from custom_components.tuya_local.light import async_setup_entry, TuyaLocalLight
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_lock.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_PROTOCOL_VERSION,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.lock import TuyaLocalLock
-from custom_components.tuya_local.lock import async_setup_entry
+from custom_components.tuya_local.lock import async_setup_entry, TuyaLocalLock
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_number.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.number import TuyaLocalNumber
-from custom_components.tuya_local.number import async_setup_entry
+from custom_components.tuya_local.number import async_setup_entry, TuyaLocalNumber
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_select.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_PROTOCOL_VERSION,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.select import TuyaLocalSelect
-from custom_components.tuya_local.select import async_setup_entry
+from custom_components.tuya_local.select import async_setup_entry, TuyaLocalSelect
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_sensor.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
-from custom_components.tuya_local.sensor import async_setup_entry
+from custom_components.tuya_local.sensor import async_setup_entry, TuyaLocalSensor
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_siren.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.siren import TuyaLocalSiren
-from custom_components.tuya_local.siren import async_setup_entry
+from custom_components.tuya_local.siren import async_setup_entry, TuyaLocalSiren
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_switch.py

@@ -9,8 +9,7 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
-from custom_components.tuya_local.switch import async_setup_entry
+from custom_components.tuya_local.switch import async_setup_entry, TuyaLocalSwitch
 
 
 @pytest.mark.asyncio

+ 1 - 2
tests/test_vacuum.py

@@ -8,8 +8,7 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.vacuum import TuyaLocalVacuum
-from custom_components.tuya_local.vacuum import async_setup_entry
+from custom_components.tuya_local.vacuum import async_setup_entry, TuyaLocalVacuum
 
 
 @pytest.mark.asyncio

+ 4 - 2
tests/test_water_heater.py

@@ -9,8 +9,10 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.generic.water_heater import TuyaLocalWaterHeater
-from custom_components.tuya_local.water_heater import async_setup_entry
+from custom_components.tuya_local.water_heater import (
+    async_setup_entry,
+    TuyaLocalWaterHeater,
+)
 
 
 @pytest.mark.asyncio