Explorar el Código

Add support for Beca BAC002 Thermostat and WetAir WAW-H1210LW humidifier.

Add tests for TMWF02 Fan which was missed from a previous commit.

Support for value_mirror attribute, that redirects only for read, not for write.

Issues #78, #82 and #84
Jason Rumney hace 4 años
padre
commit
e4cb967dc5

+ 13 - 0
custom_components/tuya_local/devices/README.md

@@ -301,6 +301,19 @@ one or the other. But Home Assistant just has one `temperature` attribute for
 setting target temperature, so the mapping needs to be done before passing to
 setting target temperature, so the mapping needs to be done before passing to
 Home Assistant.
 Home Assistant.
 
 
+### `value_mirror`
+
+//Optional.//
+When `value_mirror` is set, the value of the attribute will be redirected to
+the current value of the named attribute.  Unlike `value_redirect`, this does
+not redirect attempts to set the dps to the redirected dps, but when used in
+a map, this can make the mapping dynamic.
+
+An example of how this can be useful is where a thermostat can be configured
+to control either a heating or cooling device, but it is not expected to
+change this setting during operation.  Once set up, the hvac_mode dps can
+have a mapping that mirrors the value of the configuration dps.
+
 ### `invalid`
 ### `invalid`
 
 
 //Optional. Boolean, default false.//
 //Optional. Boolean, default false.//

+ 102 - 0
custom_components/tuya_local/devices/beca_bac002_thermostat_c.yaml

@@ -0,0 +1,102 @@
+name: BAC-002 Thermostat with external sensor (C)
+primary_entity:
+  entity: climate
+  dps:
+    - id: 1
+      type: boolean
+      name: power
+      mapping:
+        - dps_val: false
+          value: "off"
+      hidden: true
+    - id: 2
+      type: integer
+      name: current_temperature
+      mapping:
+        - scale: 2
+    - id: 3
+      type: integer
+      name: temperature
+      unit: C
+      range:
+        min: 10
+        max: 70
+      mapping:
+        - scale: 2
+    - id: 4
+      type: string
+      name: hvac_mode
+      mapping:
+        - dps_val: "0"
+          constraint: power
+          conditions:
+            - dps_val: true
+              value: auto
+            - dps_val: false
+              value_redirect: power
+              value: "off"
+        - dps_val: "1"
+          constraint: power
+          conditions:
+            - dps_val: true
+              value_mirror: installation_type
+            - dps_val: false
+              value_redirect: power
+              value: "off"
+    - id: 5
+      type: boolean
+      name: preset_mode
+      mapping:
+        - dps_val: true
+          value: eco 
+        - dps_val: false
+          value: comfort
+    - id: 102
+      type: string
+      name: installation_type
+      mapping:
+        - dps_val: "0"
+          value: cool
+        - dps_val: "1"
+          value: heat
+        - dps_val: "2"
+          value: fan_only
+      hidden: true
+    - id: 103
+      type: string
+      name: fan_mode
+      mapping:
+        - dps_val: "0"
+          value: auto
+        - dps_val: "1"
+          value: high
+        - dps_val: "2"
+          value: medium
+        - dps_val: "3"
+          value: low
+secondary_entities:
+  - entity: lock
+    name: Child Lock
+    category: config
+    dps:
+      - id: 6
+        type: boolean
+        name: lock
+        mapping:
+          - dps_val: true
+            icon: "mdi:hand-back-right-off"
+          - dps_val: false
+            icon: "mdi:hand-back-right"
+  - entity: select
+    name: Installation
+    dps:
+      - id: 102
+        type: string
+        name: option
+        mapping:
+          - dps_val: "0"
+            value: Cooling
+          - dps_val: "1"
+            value: Heating
+          - dps_val: "2"
+            value: Fan

+ 110 - 0
custom_components/tuya_local/devices/wetair_wawh1210lw_humidifier.yaml

@@ -0,0 +1,110 @@
+name: WetAir-1210 Humidifier
+primary_entity:
+  entity: humidifier
+  class: humidifier
+  dps:
+    - id: 1
+      name: switch
+      type: boolean
+      mapping:
+        - dps_val: true
+          icon: "mdi:air-humidifier"
+        - dps_val: false
+          icon: "mdi:air-humidifier-off"
+    - id: 13
+      name: humidity
+      type: integer
+      range:
+        min: 30
+        max: 80
+    - id: 24
+      type: string
+      name: mode
+      mapping:
+        - dps_val: AUTO
+          value: auto
+        - dps_val: MIDDLE
+          value: normal
+        - dps_val: HIGH
+          value: boost
+        - dps_val: SLEEP
+          value: sleep
+    - id: 22
+      type: integer
+      name: unknown_22
+secondary_entities:
+  - entity: light
+    name: Display
+    category: config
+    dps:
+      - id: 5
+        type: boolean
+        name: switch
+        mapping:
+            - dps_val: true
+              icon: "mdi:led-on"
+            - dps_val: false
+              icon: "mdi:led-off"
+              
+  - entity: switch
+    name: Sound
+    category: config
+    dps:
+      - id: 8
+        name: "switch"
+        type: boolean
+        mapping:
+            - dps_val: true
+              icon: "mdi:volume-high"
+            - dps_val: false
+              icon: "mdi:volume-off"
+
+  - entity: sensor
+    name: Current Humidity
+    class: humidity
+    dps:
+      - id: 14
+        name: sensor
+        type: integer 
+        class: measurement
+        unit: "%"
+
+  - entity: sensor
+    name: Water Level
+    category: diagnostic
+    dps:
+      - id: 101
+        name: sensor
+        type: string
+        unit: "%"
+        mapping:
+          - dps_val: No_water
+            icon: "mdi:cup-outline"
+            value: 0
+          - dps_val: Have_water
+            icon: "mdi:cup-water"
+            value: 50
+          - dps_val: Full_water
+            icon: "mdi-cup"
+            value: 100
+
+  - entity: switch
+    name: Ionizer
+    icon: "mdi:creation"
+    dps:
+      - id: 25
+        name: switch
+        type: boolean
+
+  - entity: lock
+    name: Child Lock
+    category: config
+    dps:
+      - id: 29
+        type: boolean
+        name: lock
+        mapping:
+          - dps_val: true
+            icon: "mdi:hand-back-right-off"
+          - dps_val: false
+            icon: "mdi:hand-back-right"

+ 38 - 8
custom_components/tuya_local/helpers/device_config.py

@@ -287,9 +287,17 @@ class TuyaDpsConfig:
         for m in self._config["mapping"]:
         for m in self._config["mapping"]:
             if "value" in m:
             if "value" in m:
                 val.append(m["value"])
                 val.append(m["value"])
+            # If there is a mirroring with no value override, use current value
+            elif "value_mirror" in m:
+                r_dps = self._entity.find_dps(m["value_mirror"])
+                val.append(r_dps.get_value(device))
             for c in m.get("conditions", {}):
             for c in m.get("conditions", {}):
                 if "value" in c:
                 if "value" in c:
                     val.append(c["value"])
                     val.append(c["value"])
+                elif "value_mirror" in c:
+                    r_dps = self._entity.find_dps(c["value_mirror"])
+                    val.append(r_dps.get_value(device))
+
             cond = self._active_condition(m, device)
             cond = self._active_condition(m, device)
             if cond and "mapping" in cond:
             if cond and "mapping" in cond:
                 _LOGGER.debug("Considering conditional mappings")
                 _LOGGER.debug("Considering conditional mappings")
@@ -297,6 +305,9 @@ class TuyaDpsConfig:
                 for m2 in cond["mapping"]:
                 for m2 in cond["mapping"]:
                     if "value" in m2:
                     if "value" in m2:
                         c_val.append(m2["value"])
                         c_val.append(m2["value"])
+                    elif "value_mirror" in m:
+                        r_dps = self._entity.find_dps(m["value_mirror"])
+                        c_val.append(r_dps.get_value(device))
                 # if given, the conditional mapping is an override
                 # if given, the conditional mapping is an override
                 if c_val:
                 if c_val:
                     _LOGGER.debug(f"Overriding {self.name} values {val} with {c_val}")
                     _LOGGER.debug(f"Overriding {self.name} values {val} with {c_val}")
@@ -356,7 +367,7 @@ class TuyaDpsConfig:
         return self._config.get("readonly", False)
         return self._config.get("readonly", False)
 
 
     def invalid_for(self, value, device):
     def invalid_for(self, value, device):
-        mapping = self._find_map_for_value(value)
+        mapping = self._find_map_for_value(value, device)
         if mapping:
         if mapping:
             cond = self._active_condition(mapping, device)
             cond = self._active_condition(mapping, device)
             if cond:
             if cond:
@@ -402,6 +413,7 @@ class TuyaDpsConfig:
             if not isinstance(scale, (int, float)):
             if not isinstance(scale, (int, float)):
                 scale = 1
                 scale = 1
             redirect = mapping.get("value_redirect")
             redirect = mapping.get("value_redirect")
+            mirror = mapping.get("value_mirror")
             replaced = "value" in mapping
             replaced = "value" in mapping
             result = mapping.get("value", result)
             result = mapping.get("value", result)
             cond = self._active_condition(mapping, device)
             cond = self._active_condition(mapping, device)
@@ -412,7 +424,7 @@ class TuyaDpsConfig:
                 result = cond.get("value", result)
                 result = cond.get("value", result)
                 scale = cond.get("scale", scale)
                 scale = cond.get("scale", scale)
                 redirect = cond.get("value_redirect", redirect)
                 redirect = cond.get("value_redirect", redirect)
-
+                mirror = cond.get("value_mirror", mirror)
                 for m in cond.get("mapping", {}):
                 for m in cond.get("mapping", {}):
                     if str(m.get("dps_val")) == str(result):
                     if str(m.get("dps_val")) == str(result):
                         replaced = "value" in m
                         replaced = "value" in m
@@ -422,6 +434,9 @@ class TuyaDpsConfig:
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 r_dps = self._entity.find_dps(redirect)
                 r_dps = self._entity.find_dps(redirect)
                 return r_dps.get_value(device)
                 return r_dps.get_value(device)
+            if mirror:
+                r_dps = self._entity.find_dps(mirror)
+                return r_dps.get_value(device)
 
 
             if scale != 1 and isinstance(result, (int, float)):
             if scale != 1 and isinstance(result, (int, float)):
                 result = result / scale
                 result = result / scale
@@ -438,16 +453,25 @@ class TuyaDpsConfig:
 
 
         return result
         return result
 
 
-    def _find_map_for_value(self, value):
+    def _find_map_for_value(self, value, device):
         default = None
         default = None
         for m in self._config.get("mapping", {}):
         for m in self._config.get("mapping", {}):
             if "dps_val" not in m:
             if "dps_val" not in m:
                 default = m
                 default = m
             if "value" in m and str(m["value"]) == str(value):
             if "value" in m and str(m["value"]) == str(value):
                 return m
                 return m
+            if "value" not in m and "value_mirror" in m:
+                r_dps = self._entity.find_dps(m["value_mirror"])
+                if str(r_dps.get_value(device)) == str(value):
+                    return m
+
             for c in m.get("conditions", {}):
             for c in m.get("conditions", {}):
-                if "value" in c and c["value"] == value:
+                if "value" in c and str(c["value"]) == str(value):
                     return m
                     return m
+                if "value" not in c and "value_mirror" in c:
+                    r_dps = self._entity.find_dps(c["value_mirror"])
+                    if str(r_dps.get_value(device)) == str(value):
+                        return m
         return default
         return default
 
 
     def _active_condition(self, mapping, device, value=None):
     def _active_condition(self, mapping, device, value=None):
@@ -471,7 +495,7 @@ class TuyaDpsConfig:
         """Return the dps values that would be set when setting to value"""
         """Return the dps values that would be set when setting to value"""
         result = value
         result = value
         dps_map = {}
         dps_map = {}
-        mapping = self._find_map_for_value(value)
+        mapping = self._find_map_for_value(value, device)
         if mapping:
         if mapping:
             replaced = False
             replaced = False
             scale = mapping.get("scale", 1)
             scale = mapping.get("scale", 1)
@@ -487,7 +511,13 @@ class TuyaDpsConfig:
             # Conditions may have side effect of setting another value.
             # Conditions may have side effect of setting another value.
             cond = self._active_condition(mapping, device, value)
             cond = self._active_condition(mapping, device, value)
             if cond:
             if cond:
-                if cond.get("value") == value:
+                cval = cond.get("value")
+                if cval is None:
+                    r_dps = cond.get("value_mirror")
+                    if r_dps:
+                        cval = self._entity.find_dps(r_dps).get_value(device)
+
+                if cval == value:
                     c_dps = self._entity.find_dps(mapping["constraint"])
                     c_dps = self._entity.find_dps(mapping["constraint"])
                     c_val = c_dps._map_from_dps(
                     c_val = c_dps._map_from_dps(
                         cond.get("dps_val", device.get_property(c_dps.id)),
                         cond.get("dps_val", device.get_property(c_dps.id)),
@@ -512,7 +542,7 @@ class TuyaDpsConfig:
             if scale != 1 and isinstance(result, (int, float)):
             if scale != 1 and isinstance(result, (int, float)):
                 _LOGGER.debug(f"Scaling {result} by {scale}")
                 _LOGGER.debug(f"Scaling {result} by {scale}")
                 result = result * scale
                 result = result * scale
-                remap = self._find_map_for_value(result)
+                remap = self._find_map_for_value(result, device)
                 if remap and "dps_val" in remap and "dps_val" not in mapping:
                 if remap and "dps_val" in remap and "dps_val" not in mapping:
                     result = remap["dps_val"]
                     result = remap["dps_val"]
                 replaced = True
                 replaced = True
@@ -520,7 +550,7 @@ class TuyaDpsConfig:
             if step and isinstance(result, (int, float)):
             if step and isinstance(result, (int, float)):
                 _LOGGER.debug(f"Stepping {result} to {step}")
                 _LOGGER.debug(f"Stepping {result} to {step}")
                 result = step * round(float(result) / step)
                 result = step * round(float(result) / step)
-                remap = self._find_map_for_value(result)
+                remap = self._find_map_for_value(result, device)
                 if remap and "dps_val" in remap and "dps_val" not in mapping:
                 if remap and "dps_val" in remap and "dps_val" not in mapping:
                     result = remap["dps_val"]
                     result = remap["dps_val"]
                 replaced = True
                 replaced = True

+ 11 - 0
tests/const.py

@@ -424,6 +424,17 @@ MOES_BHT002_PAYLOAD = {
     "104": True,
     "104": True,
 }
 }
 
 
+BECA_BAC002_PAYLOAD = {
+    "1": True,
+    "2": 39,
+    "3": 45,
+    "4": "1",
+    "5": False,
+    "6": False,
+    "102": "1",
+    "103": "2",
+}
+
 LEXY_F501_PAYLOAD = {
 LEXY_F501_PAYLOAD = {
     "1": True,
     "1": True,
     "2": "forestwindhigh",
     "2": "forestwindhigh",

+ 218 - 0
tests/devices/test_beca_bac002_thermostat.py

@@ -0,0 +1,218 @@
+from homeassistant.components.climate.const import (
+    FAN_AUTO,
+    FAN_HIGH,
+    FAN_LOW,
+    FAN_MEDIUM,
+    HVAC_MODE_AUTO,
+    HVAC_MODE_COOL,
+    HVAC_MODE_HEAT,
+    HVAC_MODE_FAN_ONLY,
+    HVAC_MODE_OFF,
+    PRESET_COMFORT,
+    PRESET_ECO,
+    SUPPORT_FAN_MODE,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import STATE_UNAVAILABLE, TEMP_CELSIUS
+
+from ..const import BECA_BAC002_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.climate import TargetTemperatureTests
+from ..mixins.lock import BasicLockTests
+from ..mixins.select import BasicSelectTests
+from .base_device_tests import TuyaDeviceTestCase
+
+SWITCH_DPS = "1"
+CURRENTTEMP_DPS = "2"
+TEMPERATURE_DPS = "3"
+HVACMODE_DPS = "4"
+PRESET_DPS = "5"
+LOCK_DPS = "6"
+INSTALL_DPS = "102"
+FAN_DPS = "103"
+
+
+class TestBecaBAC002Thermostat(
+    BasicLockTests,
+    BasicSelectTests,
+    TargetTemperatureTests,
+    TuyaDeviceTestCase,
+):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("beca_bac002_thermostat_c.yaml", BECA_BAC002_PAYLOAD)
+        self.subject = self.entities.get("climate")
+        self.setUpTargetTemperature(
+            TEMPERATURE_DPS,
+            self.subject,
+            min=5.0,
+            max=35.0,
+            scale=2,
+        )
+        self.setUpBasicLock(LOCK_DPS, self.entities.get("lock_child_lock"))
+        self.setUpBasicSelect(
+            INSTALL_DPS,
+            self.entities.get("select_installation"),
+            {
+                "0": "Cooling",
+                "1": "Heating",
+                "2": "Fan",
+            },
+        )
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE,
+        )
+
+    def test_temperature_unit_returns_configured_temperature_unit(self):
+        self.assertEqual(self.subject.temperature_unit, TEMP_CELSIUS)
+
+    async def test_legacy_set_temperature_with_preset_mode(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PRESET_DPS: True}
+        ):
+            await self.subject.async_set_temperature(preset_mode=PRESET_ECO)
+
+    async def test_legacy_set_temperature_with_both_properties(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {
+                TEMPERATURE_DPS: 58,
+                PRESET_DPS: False,
+            },
+        ):
+            await self.subject.async_set_temperature(
+                temperature=29, preset_mode=PRESET_COMFORT
+            )
+
+    def test_current_temperature(self):
+        self.dps[CURRENTTEMP_DPS] = 70
+        self.assertEqual(self.subject.current_temperature, 35.0)
+
+    def test_hvac_mode(self):
+        self.dps[SWITCH_DPS] = True
+        self.dps[INSTALL_DPS] = "0"
+        self.dps[HVACMODE_DPS] = "1"
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_COOL)
+        self.dps[INSTALL_DPS] = "1"
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_HEAT)
+        self.dps[INSTALL_DPS] = "2"
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_FAN_ONLY)
+        self.dps[HVACMODE_DPS] = "0"
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_AUTO)
+        self.dps[SWITCH_DPS] = False
+        self.assertEqual(self.subject.hvac_mode, HVAC_MODE_OFF)
+        self.dps[HVACMODE_DPS] = None
+        self.assertEqual(self.subject.hvac_mode, STATE_UNAVAILABLE)
+
+    def test_hvac_modes(self):
+        self.dps[INSTALL_DPS] = "1"
+        self.assertCountEqual(
+            self.subject.hvac_modes,
+            [
+                HVAC_MODE_AUTO,
+                HVAC_MODE_HEAT,
+                HVAC_MODE_OFF,
+            ],
+        )
+
+    async def test_set_hvac_mode_to_auto(self):
+        self.dps[INSTALL_DPS] = "1"
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWITCH_DPS: True, HVACMODE_DPS: "0"},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_AUTO)
+
+    async def test_set_hvac_mode_to_heat(self):
+        self.dps[INSTALL_DPS] = "1"
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWITCH_DPS: True, HVACMODE_DPS: "1"},
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_HEAT)
+
+    async def test_set_hvac_mode_to_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {SWITCH_DPS: False}
+        ):
+            await self.subject.async_set_hvac_mode(HVAC_MODE_OFF)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(self.subject.preset_modes, [PRESET_COMFORT, PRESET_ECO])
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = False
+        self.assertEqual(self.subject.preset_mode, PRESET_COMFORT)
+        self.dps[PRESET_DPS] = True
+        self.assertEqual(self.subject.preset_mode, PRESET_ECO)
+
+    async def test_set_preset_to_comfort(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: False},
+        ):
+            await self.subject.async_set_preset_mode(PRESET_COMFORT)
+
+    async def test_set_preset_to_eco(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: True},
+        ):
+            await self.subject.async_set_preset_mode(PRESET_ECO)
+
+    def test_fan_mode(self):
+        self.dps[FAN_DPS] = "0"
+        self.assertEqual(self.subject.fan_mode, FAN_AUTO)
+        self.dps[FAN_DPS] = "1"
+        self.assertEqual(self.subject.fan_mode, FAN_HIGH)
+        self.dps[FAN_DPS] = "2"
+        self.assertEqual(self.subject.fan_mode, FAN_MEDIUM)
+        self.dps[FAN_DPS] = "3"
+        self.assertEqual(self.subject.fan_mode, FAN_LOW)
+
+    def test_fan_modes(self):
+        self.assertCountEqual(
+            self.subject.fan_modes,
+            [
+                FAN_AUTO,
+                FAN_LOW,
+                FAN_MEDIUM,
+                FAN_HIGH,
+            ],
+        )
+
+    async def test_set_fan_mode_to_auto(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FAN_DPS: "0"},
+        ):
+            await self.subject.async_set_fan_mode(FAN_AUTO)
+
+    async def test_set_fan_mode_to_high(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FAN_DPS: "1"},
+        ):
+            await self.subject.async_set_fan_mode(FAN_HIGH)
+
+    async def test_set_fan_mode_to_medium(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FAN_DPS: "2"},
+        ):
+            await self.subject.async_set_fan_mode(FAN_MEDIUM)
+
+    async def test_set_fan_mode_to_low(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {FAN_DPS: "3"},
+        ):
+            await self.subject.async_set_fan_mode(FAN_LOW)
+
+    def test_device_state_attribures(self):
+        self.assertEqual(self.subject.device_state_attributes, {})

+ 58 - 0
tests/devices/test_tmwf02_fan.py

@@ -0,0 +1,58 @@
+from homeassistant.components.fan import SUPPORT_SET_SPEED
+from homeassistant.const import (
+    DEVICE_CLASS_POWER_FACTOR,
+    PERCENTAGE,
+)
+from ..const import TMWF02_FAN_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.number import BasicNumberTests
+from ..mixins.switch import SwitchableTests
+from .base_device_tests import TuyaDeviceTestCase
+
+SWITCH_DPS = "1"
+TIMER_DPS = "2"
+LEVEL_DPS = "3"
+SPEED_DPS = "4"
+
+
+class TestTMWF02Fan(BasicNumberTests, SwitchableTests, TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("tmwf02_fan.yaml", TMWF02_FAN_PAYLOAD)
+        self.subject = self.entities["fan"]
+        self.setUpSwitchable(SWITCH_DPS, self.subject)
+        self.setUpBasicNumber(
+            TIMER_DPS,
+            self.entities.get("number_timer"),
+            max=1440,
+            scale=60,
+        )
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_SET_SPEED,
+        )
+
+    def test_speed(self):
+        self.dps[SPEED_DPS] = 35
+        self.assertEqual(self.subject.percentage, 35)
+
+    def test_speed_step(self):
+        self.assertEqual(self.subject.percentage_step, 1)
+
+    async def test_set_speed(self):
+        async with assert_device_properties_set(self.subject._device, {SPEED_DPS: 70}):
+            await self.subject.async_set_percentage(70)
+
+    async def test_set_speed_snaps(self):
+        async with assert_device_properties_set(self.subject._device, {SPEED_DPS: 25}):
+            await self.subject.async_set_percentage(24.8)
+
+    def test_device_state_attributes(self):
+        self.dps[LEVEL_DPS] = "level_3"
+        self.assertDictEqual(
+            self.subject.device_state_attributes,
+            {"fan_level": "level_3"},
+        )

+ 145 - 0
tests/devices/test_wetair_wawh1210lw_humidifier.py

@@ -0,0 +1,145 @@
+from homeassistant.components.humidifier.const import (
+    MODE_AUTO,
+    MODE_BOOST,
+    MODE_NORMAL,
+    MODE_SLEEP,
+    SUPPORT_MODES,
+)
+from homeassistant.const import (
+    DEVICE_CLASS_HUMIDITY,
+    PERCENTAGE,
+    STATE_UNAVAILABLE,
+)
+
+from ..const import WETAIR_WAWH1210_HUMIDIFIER_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.light import BasicLightTests
+from ..mixins.lock import BasicLockTests
+from ..mixins.sensor import MultiSensorTests
+from ..mixins.switch import MultiSwitchTests, SwitchableTests
+from .base_device_tests import TuyaDeviceTestCase
+
+SWITCH_DPS = "1"
+LIGHT_DPS = "5"
+SOUND_DPS = "8"
+HUMIDITY_DPS = "13"
+CURRENTHUMID_DPS = "14"
+UNKNOWN22_DPS = "22"
+PRESET_DPS = "24"
+IONIZER_DPS = "25"
+LOCK_DPS = "29"
+LEVEL_DPS = "101"
+
+
+class TestWetairWAWH1210LWHumidifier(
+    BasicLightTests,
+    BasicLockTests,
+    MultiSensorTests,
+    MultiSwitchTests,
+    SwitchableTests,
+    TuyaDeviceTestCase,
+):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig(
+            "wetair_wawh1210lw_humidifier.yaml", WETAIR_WAWH1210_HUMIDIFIER_PAYLOAD
+        )
+        self.subject = self.entities.get("humidifier")
+        self.setUpSwitchable(SWITCH_DPS, self.subject)
+        self.setUpBasicLight(LIGHT_DPS, self.entities.get("light_display"))
+        self.setUpBasicLock(LOCK_DPS, self.entities.get("lock_child_lock"))
+        self.setUpMultiSensors(
+            [
+                {
+                    "dps": CURRENTHUMID_DPS,
+                    "name": "sensor_current_humidity",
+                    "device_class": DEVICE_CLASS_HUMIDITY,
+                    "state_class": "measurement",
+                    "unit": PERCENTAGE,
+                },
+                {
+                    "dps": LEVEL_DPS,
+                    "name": "sensor_water_level",
+                    "unit": PERCENTAGE,
+                },
+            ]
+        )
+        self.setUpMultiSwitch(
+            [
+                {
+                    "dps": SOUND_DPS,
+                    "name": "switch_sound",
+                },
+                {
+                    "dps": IONIZER_DPS,
+                    "name": "switch_ionizer",
+                },
+            ]
+        )
+
+    def test_supported_features(self):
+        self.assertEqual(self.subject.supported_features, SUPPORT_MODES)
+
+    def test_icons(self):
+        self.dps[SWITCH_DPS] = True
+        self.assertEqual(self.subject.icon, "mdi:air-humidifier")
+        self.dps[SWITCH_DPS] = False
+        self.assertEqual(self.subject.icon, "mdi:air-humidifier-off")
+
+    def test_min_target_humidity(self):
+        self.assertEqual(self.subject.min_humidity, 30)
+
+    def test_max_target_humidity(self):
+        self.assertEqual(self.subject.max_humidity, 80)
+
+    def test_target_humidity(self):
+        self.dps[HUMIDITY_DPS] = 55
+        self.assertEqual(self.subject.target_humidity, 55)
+
+    def test_available_modes(self):
+        self.assertCountEqual(
+            self.subject.available_modes,
+            [MODE_AUTO, MODE_BOOST, MODE_NORMAL, MODE_SLEEP],
+        )
+
+    def test_mode(self):
+        self.dps[PRESET_DPS] = "AUTO"
+        self.assertEqual(self.subject.mode, MODE_AUTO)
+        self.dps[PRESET_DPS] = "MIDDLE"
+        self.assertEqual(self.subject.mode, MODE_NORMAL)
+        self.dps[PRESET_DPS] = "HIGH"
+        self.assertEqual(self.subject.mode, MODE_BOOST)
+        self.dps[PRESET_DPS] = "SLEEP"
+        self.assertEqual(self.subject.mode, MODE_SLEEP)
+
+    async def test_set_mode_to_auto(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PRESET_DPS: "AUTO"}
+        ):
+            await self.subject.async_set_mode(MODE_AUTO)
+
+    async def test_set_mode_to_normal(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PRESET_DPS: "MIDDLE"}
+        ):
+            await self.subject.async_set_mode(MODE_NORMAL)
+
+    async def test_set_mode_to_boost(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PRESET_DPS: "HIGH"}
+        ):
+            await self.subject.async_set_mode(MODE_BOOST)
+
+    async def test_set_mode_to_sleep(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PRESET_DPS: "SLEEP"}
+        ):
+            await self.subject.async_set_mode(MODE_SLEEP)
+
+    def test_device_state_attributes(self):
+        self.dps[UNKNOWN22_DPS] = 22
+        self.assertDictEqual(
+            self.subject.device_state_attributes,
+            {"unknown_22": 22},
+        )