Bläddra i källkod

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 4 år sedan
förälder
incheckning
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
 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`
 
 //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"]:
             if "value" in m:
                 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", {}):
                 if "value" in c:
                     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)
             if cond and "mapping" in cond:
                 _LOGGER.debug("Considering conditional mappings")
@@ -297,6 +305,9 @@ class TuyaDpsConfig:
                 for m2 in cond["mapping"]:
                     if "value" in m2:
                         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 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)
 
     def invalid_for(self, value, device):
-        mapping = self._find_map_for_value(value)
+        mapping = self._find_map_for_value(value, device)
         if mapping:
             cond = self._active_condition(mapping, device)
             if cond:
@@ -402,6 +413,7 @@ class TuyaDpsConfig:
             if not isinstance(scale, (int, float)):
                 scale = 1
             redirect = mapping.get("value_redirect")
+            mirror = mapping.get("value_mirror")
             replaced = "value" in mapping
             result = mapping.get("value", result)
             cond = self._active_condition(mapping, device)
@@ -412,7 +424,7 @@ class TuyaDpsConfig:
                 result = cond.get("value", result)
                 scale = cond.get("scale", scale)
                 redirect = cond.get("value_redirect", redirect)
-
+                mirror = cond.get("value_mirror", mirror)
                 for m in cond.get("mapping", {}):
                     if str(m.get("dps_val")) == str(result):
                         replaced = "value" in m
@@ -422,6 +434,9 @@ class TuyaDpsConfig:
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 r_dps = self._entity.find_dps(redirect)
                 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)):
                 result = result / scale
@@ -438,16 +453,25 @@ class TuyaDpsConfig:
 
         return result
 
-    def _find_map_for_value(self, value):
+    def _find_map_for_value(self, value, device):
         default = None
         for m in self._config.get("mapping", {}):
             if "dps_val" not in m:
                 default = m
             if "value" in m and str(m["value"]) == str(value):
                 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", {}):
-                if "value" in c and c["value"] == value:
+                if "value" in c and str(c["value"]) == str(value):
                     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
 
     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"""
         result = value
         dps_map = {}
-        mapping = self._find_map_for_value(value)
+        mapping = self._find_map_for_value(value, device)
         if mapping:
             replaced = False
             scale = mapping.get("scale", 1)
@@ -487,7 +511,13 @@ class TuyaDpsConfig:
             # Conditions may have side effect of setting another value.
             cond = self._active_condition(mapping, device, value)
             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_val = c_dps._map_from_dps(
                         cond.get("dps_val", device.get_property(c_dps.id)),
@@ -512,7 +542,7 @@ class TuyaDpsConfig:
             if scale != 1 and isinstance(result, (int, float)):
                 _LOGGER.debug(f"Scaling {result} by {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:
                     result = remap["dps_val"]
                 replaced = True
@@ -520,7 +550,7 @@ class TuyaDpsConfig:
             if step and isinstance(result, (int, float)):
                 _LOGGER.debug(f"Stepping {result} to {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:
                     result = remap["dps_val"]
                 replaced = True

+ 11 - 0
tests/const.py

@@ -424,6 +424,17 @@ MOES_BHT002_PAYLOAD = {
     "104": True,
 }
 
+BECA_BAC002_PAYLOAD = {
+    "1": True,
+    "2": 39,
+    "3": 45,
+    "4": "1",
+    "5": False,
+    "6": False,
+    "102": "1",
+    "103": "2",
+}
+
 LEXY_F501_PAYLOAD = {
     "1": True,
     "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},
+        )