Quellcode durchsuchen

Optimize evaporative humidifier test (#346)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Retha Runolfsson vor 1 Woche
Ursprung
Commit
2f654e67bd

+ 6 - 0
switchbot/__init__.py

@@ -12,6 +12,9 @@ from .adv_parser import SwitchbotSupportedType, parse_advertisement_data
 from .const import (
     AirPurifierMode,
     FanMode,
+    HumidifierAction,
+    HumidifierMode,
+    HumidifierWaterLevel,
     LockStatus,
     SwitchbotAccountConnectionError,
     SwitchbotApiError,
@@ -43,6 +46,9 @@ __all__ = [
     "ColorMode",
     "FanMode",
     "GetSwitchbotDevices",
+    "HumidifierAction",
+    "HumidifierMode",
+    "HumidifierWaterLevel",
     "LockStatus",
     "SwitchBotAdvertisement",
     "Switchbot",

+ 2 - 1
switchbot/adv_parsers/hub2.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 from typing import Any
 
 from ..const.hub2 import LIGHT_INTENSITY_MAP
+from ..helpers import celsius_to_fahrenheit
 
 
 def process_wohub2(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
@@ -22,7 +23,7 @@ def process_wohub2(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]
     _temp_c = _temp_sign * (
         (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
     )
-    _temp_f = (_temp_c * 9 / 5) + 32
+    _temp_f = celsius_to_fahrenheit(_temp_c)
     _temp_f = (_temp_f * 10) / 10
     humidity = temp_data[2] & 0b01111111
     light_level = status & 0b11111

+ 2 - 1
switchbot/adv_parsers/hub3.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 from typing import Any
 
 from ..const.hub3 import LIGHT_INTENSITY_MAP
+from ..helpers import celsius_to_fahrenheit
 
 
 def process_hub3(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
@@ -26,7 +27,7 @@ def process_hub3(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
     _temp_c = _temp_sign * (
         (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
     )
-    _temp_f = round(((_temp_c * 9 / 5) + 32), 1)
+    _temp_f = round(celsius_to_fahrenheit(_temp_c), 1)
     humidity = temp_data[2] & 0b01111111
     motion_detected = bool(device_data[10] & 0b10000000)
 

+ 3 - 1
switchbot/adv_parsers/hubmini_matter.py

@@ -4,6 +4,8 @@ from __future__ import annotations
 
 from typing import Any
 
+from ..helpers import celsius_to_fahrenheit
+
 
 def process_hubmini_matter(
     data: bytes | None, mfr_data: bytes | None
@@ -21,7 +23,7 @@ def process_hubmini_matter(
     _temp_c = _temp_sign * (
         (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
     )
-    _temp_f = (_temp_c * 9 / 5) + 32
+    _temp_f = celsius_to_fahrenheit(_temp_c)
     _temp_f = (_temp_f * 10) / 10
     humidity = temp_data[2] & 0b01111111
 

+ 53 - 39
switchbot/adv_parsers/humidifier.py

@@ -6,11 +6,10 @@ import logging
 from datetime import timedelta
 
 from ..const.evaporative_humidifier import (
-    OVER_HUMIDIFY_PROTECTION_MODES,
-    TARGET_HUMIDITY_MODES,
     HumidifierMode,
     HumidifierWaterLevel,
 )
+from ..helpers import celsius_to_fahrenheit
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -23,6 +22,26 @@ _LOGGER = logging.getLogger(__name__)
 # Low:  658000c5222b6300
 # Med:  658000c5432b6300
 # High: 658000c5642b6300
+
+
+def calculate_temperature_and_humidity(
+    data: bytes, is_meter_binded: bool = True
+) -> tuple[float | None, float | None, int | None]:
+    """Calculate temperature and humidity based on the given flag."""
+    if len(data) < 3 or not is_meter_binded:
+        return None, None, None
+
+    humidity = data[0] & 0b01111111
+    if humidity > 100:
+        return None, None, None
+
+    _temp_sign = 1 if data[1] & 0b10000000 else -1
+    _temp_c = _temp_sign * ((data[1] & 0b01111111) + ((data[2] >> 4) / 10))
+    _temp_f = celsius_to_fahrenheit(_temp_c)
+
+    return _temp_c, _temp_f, humidity
+
+
 def process_wohumidifier(
     data: bytes | None, mfr_data: bytes | None
 ) -> dict[str, bool | int]:
@@ -46,48 +65,43 @@ def process_evaporative_humidifier(
 ) -> dict[str, bool | int]:
     """Process WoHumi services data."""
     if mfr_data is None:
-        return {
-            "isOn": None,
-            "mode": None,
-            "target_humidity": None,
-            "child_lock": None,
-            "over_humidify_protection": None,
-            "tank_removed": None,
-            "tilted_alert": None,
-            "filter_missing": None,
-            "humidity": None,
-            "temperature": None,
-            "filter_run_time": None,
-            "filter_alert": None,
-            "water_level": None,
-        }
+        return {}
 
+    seq_number = mfr_data[6]
     is_on = bool(mfr_data[7] & 0b10000000)
     mode = HumidifierMode(mfr_data[7] & 0b00001111)
-    filter_run_time = timedelta(hours=int.from_bytes(mfr_data[12:14], byteorder="big"))
-    has_humidity = bool(mfr_data[9] & 0b10000000)
-    has_temperature = bool(mfr_data[10] & 0b10000000)
-    is_tank_removed = bool(mfr_data[8] & 0b00000100)
+    over_humidify_protection = bool(mfr_data[8] & 0b10000000)
+    child_lock = bool(mfr_data[8] & 0b00100000)
+    tank_removed = bool(mfr_data[8] & 0b00000100)
+    tilted_alert = bool(mfr_data[8] & 0b00000010)
+    filter_missing = bool(mfr_data[8] & 0b00000001)
+    is_meter_binded = bool(mfr_data[9] & 0b10000000)
+
+    _temp_c, _temp_f, humidity = calculate_temperature_and_humidity(
+        mfr_data[9:12], is_meter_binded
+    )
+
+    water_level = HumidifierWaterLevel(mfr_data[11] & 0b00000011).name.lower()
+    filter_run_time = timedelta(
+        hours=int.from_bytes(mfr_data[12:14], byteorder="big") & 0xFFF
+    )
+    target_humidity = mfr_data[16] & 0b01111111
+
     return {
+        "seq_number": seq_number,
         "isOn": is_on,
-        "mode": mode if is_on else None,
-        "target_humidity": (mfr_data[16] & 0b01111111)
-        if is_on and mode in TARGET_HUMIDITY_MODES
-        else None,
-        "child_lock": bool(mfr_data[8] & 0b00100000),
-        "over_humidify_protection": bool(mfr_data[8] & 0b10000000)
-        if is_on and mode in OVER_HUMIDIFY_PROTECTION_MODES
-        else None,
-        "tank_removed": is_tank_removed,
-        "tilted_alert": bool(mfr_data[8] & 0b00000010),
-        "filter_missing": bool(mfr_data[8] & 0b00000001),
-        "humidity": (mfr_data[9] & 0b01111111) if has_humidity else None,
-        "temperature": float(mfr_data[10] & 0b01111111) + float(mfr_data[11] >> 4) / 10
-        if has_temperature
-        else None,
+        "mode": mode,
+        "over_humidify_protection": over_humidify_protection,
+        "child_lock": child_lock,
+        "tank_removed": tank_removed,
+        "tilted_alert": tilted_alert,
+        "filter_missing": filter_missing,
+        "is_meter_binded": is_meter_binded,
+        "humidity": humidity,
+        "temperature": _temp_c,
+        "temp": {"c": _temp_c, "f": _temp_f},
+        "water_level": water_level,
         "filter_run_time": filter_run_time,
         "filter_alert": filter_run_time.days >= 10,
-        "water_level": HumidifierWaterLevel(mfr_data[11] & 0b00000011)
-        if not is_tank_removed
-        else None,
+        "target_humidity": target_humidity,
     }

+ 3 - 1
switchbot/adv_parsers/meter.py

@@ -5,6 +5,8 @@ from __future__ import annotations
 import struct
 from typing import Any
 
+from ..helpers import celsius_to_fahrenheit
+
 CO2_UNPACK = struct.Struct(">H").unpack_from
 
 
@@ -28,7 +30,7 @@ def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str,
     _temp_c = _temp_sign * (
         (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
     )
-    _temp_f = (_temp_c * 9 / 5) + 32
+    _temp_f = celsius_to_fahrenheit(_temp_c)
     _temp_f = (_temp_f * 10) / 10
     humidity = temp_data[2] & 0b01111111
 

+ 8 - 0
switchbot/const/__init__.py

@@ -4,6 +4,11 @@ from __future__ import annotations
 
 from ..enum import StrEnum
 from .air_purifier import AirPurifierMode
+from .evaporative_humidifier import (
+    HumidifierAction,
+    HumidifierMode,
+    HumidifierWaterLevel,
+)
 from .fan import FanMode
 
 # Preserve old LockStatus export for backwards compatibility
@@ -86,6 +91,9 @@ __all__ = [
     "DEFAULT_SCAN_TIMEOUT",
     "AirPurifierMode",
     "FanMode",
+    "HumidifierAction",
+    "HumidifierMode",
+    "HumidifierWaterLevel",
     "LockStatus",
     "SwitchbotAccountConnectionError",
     "SwitchbotApiError",

+ 14 - 0
switchbot/const/evaporative_humidifier.py

@@ -13,6 +13,10 @@ class HumidifierMode(Enum):
     AUTO = 7
     DRYING_FILTER = 8
 
+    @classmethod
+    def get_modes(cls) -> list[str]:
+        return [mode.name.lower() for mode in cls]
+
 
 class HumidifierWaterLevel(Enum):
     EMPTY = 0
@@ -20,6 +24,16 @@ class HumidifierWaterLevel(Enum):
     MEDIUM = 2
     HIGH = 3
 
+    @classmethod
+    def get_levels(cls) -> list[str]:
+        return [level.name.lower() for level in cls]
+
+
+class HumidifierAction(Enum):
+    OFF = 0
+    HUMIDIFYING = 1
+    DRYING = 2
+
 
 OVER_HUMIDIFY_PROTECTION_MODES = {
     HumidifierMode.QUIET,

+ 4 - 4
switchbot/devices/device.py

@@ -583,11 +583,11 @@ class SwitchbotBaseDevice:
 
         return self._sb_adv_data
 
-    async def _get_basic_info(self) -> bytes | None:
+    async def _get_basic_info(
+        self, cmd: str = DEVICE_GET_BASIC_SETTINGS_KEY
+    ) -> bytes | None:
         """Return basic info of device."""
-        _data = await self._send_command(
-            key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count
-        )
+        _data = await self._send_command(key=cmd, retry=self._retry_count)
 
         if _data in (b"\x07", b"\x00"):
             _LOGGER.error("Unsuccessful, please try again")

+ 114 - 70
switchbot/devices/evaporative_humidifier.py

@@ -3,14 +3,20 @@ from typing import Any
 
 from bleak.backends.device import BLEDevice
 
+from ..adv_parsers.humidifier import calculate_temperature_and_humidity
 from ..const import SwitchbotModel
 from ..const.evaporative_humidifier import (
     TARGET_HUMIDITY_MODES,
+    HumidifierAction,
     HumidifierMode,
     HumidifierWaterLevel,
 )
-from ..models import SwitchBotAdvertisement
-from .device import SwitchbotEncryptedDevice
+from .device import (
+    SwitchbotEncryptedDevice,
+    SwitchbotOperationError,
+    SwitchbotSequenceDevice,
+    update_after_operation,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -24,6 +30,7 @@ COMMAND_AUTO_DRY_ON = f"{COMMAND_HEADER}0f430a01"
 COMMAND_AUTO_DRY_OFF = f"{COMMAND_HEADER}0f430a02"
 COMMAND_SET_MODE = f"{COMMAND_HEADER}0f4302"
 COMMAND_GET_BASIC_INFO = f"{COMMAND_HEADER}000300"
+COMMAND_SET_DRYING_FILTER = f"{COMMAND_TURN_ON}08"
 
 MODES_COMMANDS = {
     HumidifierMode.HIGH: "010100",
@@ -35,8 +42,10 @@ MODES_COMMANDS = {
     HumidifierMode.AUTO: "040000",
 }
 
+DEVICE_GET_BASIC_SETTINGS_KEY = "570f4481"
+
 
-class SwitchbotEvaporativeHumidifier(SwitchbotEncryptedDevice):
+class SwitchbotEvaporativeHumidifier(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
     """Representation of a Switchbot Evaporative Humidifier"""
 
     def __init__(
@@ -64,100 +73,123 @@ class SwitchbotEvaporativeHumidifier(SwitchbotEncryptedDevice):
             device, key_id, encryption_key, model, **kwargs
         )
 
-    def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
-        """Update device data from advertisement."""
-        super().update_from_advertisement(advertisement)
-        _LOGGER.debug(
-            "%s: update advertisement: %s",
-            self.name,
-            advertisement,
-        )
-
-    async def _get_basic_info(self) -> bytes | None:
-        """Return basic info of device."""
-        _data = await self._send_command(
-            key=COMMAND_GET_BASIC_INFO, retry=self._retry_count
-        )
-
-        if _data in (b"\x07", b"\x00"):
-            _LOGGER.error("Unsuccessful, please try again")
-            return None
-
-        return _data
-
     async def get_basic_info(self) -> dict[str, Any] | None:
         """Get device basic settings."""
-        if not (_data := await self._get_basic_info()):
+        if not (_data := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
             return None
 
-        # Not 100% sure about this data, will verify once a firmware update is available
+        _LOGGER.debug("basic info data: %s", _data.hex())
+        isOn = bool(_data[1] & 0b10000000)
+        mode = HumidifierMode(_data[1] & 0b00001111)
+        over_humidify_protection = bool(_data[2] & 0b10000000)
+        child_lock = bool(_data[2] & 0b00100000)
+        tank_removed = bool(_data[2] & 0b00000100)
+        tilted_alert = bool(_data[2] & 0b00000010)
+        filter_missing = bool(_data[2] & 0b00000001)
+        is_meter_binded = bool(_data[3] & 0b10000000)
+
+        _temp_c, _temp_f, humidity = calculate_temperature_and_humidity(
+            _data[3:6], is_meter_binded
+        )
+
+        water_level = HumidifierWaterLevel(_data[5] & 0b00000011).name.lower()
+        filter_run_time = int.from_bytes(_data[6:8], byteorder="big") & 0xFFF
+        target_humidity = _data[10] & 0b01111111
+
         return {
-            "firmware": _data[2] / 10.0,
+            "isOn": isOn,
+            "mode": mode,
+            "over_humidify_protection": over_humidify_protection,
+            "child_lock": child_lock,
+            "tank_removed": tank_removed,
+            "tilted_alert": tilted_alert,
+            "filter_missing": filter_missing,
+            "is_meter_binded": is_meter_binded,
+            "humidity": humidity,
+            "temperature": _temp_c,
+            "temp": {"c": _temp_c, "f": _temp_f},
+            "water_level": water_level,
+            "filter_run_time": filter_run_time,
+            "target_humidity": target_humidity,
         }
 
+    @update_after_operation
     async def turn_on(self) -> bool:
         """Turn device on."""
         result = await self._send_command(COMMAND_TURN_ON)
-        if ok := self._check_command_result(result, 0, {1}):
-            self._override_state({"isOn": True})
-            self._fire_callbacks()
-        return ok
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def turn_off(self) -> bool:
         """Turn device off."""
         result = await self._send_command(COMMAND_TURN_OFF)
-        if ok := self._check_command_result(result, 0, {1}):
-            self._override_state({"isOn": False})
-            self._fire_callbacks()
-        return ok
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_target_humidity(self, target_humidity: int) -> bool:
+        """Set target humidity."""
+        self._validate_water_level()
+        self._validate_mode_for_target_humidity()
+        command = (
+            COMMAND_SET_MODE
+            + MODES_COMMANDS[self.get_mode()]
+            + f"{target_humidity:02x}"
+        )
+        result = await self._send_command(command)
+        return self._check_command_result(result, 0, {1})
 
-    async def set_mode(
-        self, mode: HumidifierMode, target_humidity: int | None = None
-    ) -> bool:
+    @update_after_operation
+    async def set_mode(self, mode: HumidifierMode) -> bool:
         """Set device mode."""
+        self._validate_water_level()
+        self._validate_meter_binding(mode)
+
         if mode == HumidifierMode.DRYING_FILTER:
-            return await self.start_drying_filter()
-        if mode not in MODES_COMMANDS:
-            raise ValueError("Invalid mode")
+            command = COMMAND_SET_DRYING_FILTER
+        else:
+            command = COMMAND_SET_MODE + MODES_COMMANDS[mode]
 
-        command = COMMAND_SET_MODE + MODES_COMMANDS[mode]
         if mode in TARGET_HUMIDITY_MODES:
+            target_humidity = self.get_target_humidity()
             if target_humidity is None:
-                raise TypeError("target_humidity is required")
+                raise SwitchbotOperationError(
+                    "Target humidity must be set before switching to target humidity mode or sleep mode"
+                )
             command += f"{target_humidity:02x}"
         result = await self._send_command(command)
-        if ok := self._check_command_result(result, 0, {1}):
-            self._override_state({"mode": mode})
-            if mode == HumidifierMode.TARGET_HUMIDITY and target_humidity is not None:
-                self._override_state({"target_humidity": target_humidity})
-            self._fire_callbacks()
-        return ok
-
+        return self._check_command_result(result, 0, {1})
+
+    def _validate_water_level(self) -> None:
+        """Validate that the water level is not empty."""
+        if self.get_water_level() == HumidifierWaterLevel.EMPTY.name.lower():
+            raise SwitchbotOperationError(
+                "Cannot perform operation when water tank is empty"
+            )
+
+    def _validate_mode_for_target_humidity(self) -> None:
+        """Validate that the current mode supports target humidity."""
+        if self.get_mode() not in TARGET_HUMIDITY_MODES:
+            raise SwitchbotOperationError(
+                "Target humidity can only be set in target humidity mode or sleep mode"
+            )
+
+    def _validate_meter_binding(self, mode: HumidifierMode) -> None:
+        """Validate that the meter is binded for specific modes."""
+        if not self.is_meter_binded() and mode in [
+            HumidifierMode.TARGET_HUMIDITY,
+            HumidifierMode.AUTO,
+        ]:
+            raise SwitchbotOperationError(
+                "Cannot set target humidity or auto mode when meter is not binded"
+            )
+
+    @update_after_operation
     async def set_child_lock(self, enabled: bool) -> bool:
         """Set child lock."""
         result = await self._send_command(
             COMMAND_CHILD_LOCK_ON if enabled else COMMAND_CHILD_LOCK_OFF
         )
-        if ok := self._check_command_result(result, 0, {1}):
-            self._override_state({"child_lock": enabled})
-            self._fire_callbacks()
-        return ok
-
-    async def start_drying_filter(self):
-        """Start drying filter."""
-        result = await self._send_command(COMMAND_TURN_ON + "08")
-        if ok := self._check_command_result(result, 0, {1}):
-            self._override_state({"mode": HumidifierMode.DRYING_FILTER})
-            self._fire_callbacks()
-        return ok
-
-    async def stop_drying_filter(self):
-        """Stop drying filter."""
-        result = await self._send_command(COMMAND_TURN_OFF)
-        if ok := self._check_command_result(result, 0, {0}):
-            self._override_state({"isOn": False, "mode": None})
-            self._fire_callbacks()
-        return ok
+        return self._check_command_result(result, 0, {1})
 
     def is_on(self) -> bool | None:
         """Return state from cache."""
@@ -210,3 +242,15 @@ class SwitchbotEvaporativeHumidifier(SwitchbotEncryptedDevice):
     def get_temperature(self) -> float | None:
         """Return state from cache."""
         return self._get_adv_value("temperature")
+
+    def get_action(self) -> int:
+        """Return current action from cache."""
+        if not self.is_on():
+            return HumidifierAction.OFF
+        if self.get_mode() != HumidifierMode.DRYING_FILTER:
+            return HumidifierAction.HUMIDIFYING
+        return HumidifierAction.DRYING
+
+    def is_meter_binded(self) -> bool | None:
+        """Return meter bind state from cache."""
+        return self._get_adv_value("is_meter_binded")

+ 5 - 0
switchbot/helpers.py

@@ -15,3 +15,8 @@ def create_background_task(target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]:
     _BACKGROUND_TASKS.add(task)
     task.add_done_callback(_BACKGROUND_TASKS.remove)
     return task
+
+
+def celsius_to_fahrenheit(celsius: float) -> float:
+    """Convert temperature from Celsius to Fahrenheit."""
+    return (celsius * 9 / 5) + 32

+ 208 - 1
tests/test_adv_parser.py

@@ -1,12 +1,13 @@
 from __future__ import annotations
 
+import datetime
 from typing import Any
 
 import pytest
 from bleak.backends.device import BLEDevice
 from bleak.backends.scanner import AdvertisementData
 
-from switchbot import SwitchbotModel
+from switchbot import HumidifierMode, SwitchbotModel
 from switchbot.adv_parser import parse_advertisement_data
 from switchbot.const.lock import LockStatus
 from switchbot.models import SwitchBotAdvertisement
@@ -3087,3 +3088,209 @@ def test_blind_tilt_with_empty_data() -> None:
         rssi=-97,
         active=True,
     )
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [
+        AdvTestCase(
+            b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]",
+            b"#\x00\x00\x15\x1c\x00",
+            {
+                "seq_number": 56,
+                "isOn": True,
+                "mode": HumidifierMode(6),
+                "over_humidify_protection": True,
+                "child_lock": False,
+                "tank_removed": False,
+                "tilted_alert": False,
+                "filter_missing": False,
+                "is_meter_binded": True,
+                "humidity": 53,
+                "temperature": 25.1,
+                "temp": {"c": 25.1, "f": 77.18},
+                "water_level": "medium",
+                "filter_run_time": datetime.timedelta(days=1, seconds=10800),
+                "filter_alert": False,
+                "target_humidity": 93,
+            },
+            "#",
+            "Evaporative Humidifier",
+            SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
+        ),
+        AdvTestCase(
+            b"\xa0\xa3\xb3,\x9c\xe6H\x86\x80\x7f\xff\xf2\x10\x1d\x00\x874",
+            b"#\x00\x00\x15\x1c\x00",
+            {
+                "seq_number": 72,
+                "isOn": True,
+                "mode": HumidifierMode(6),
+                "over_humidify_protection": True,
+                "child_lock": False,
+                "tank_removed": False,
+                "tilted_alert": False,
+                "filter_missing": False,
+                "is_meter_binded": False,
+                "humidity": None,
+                "temperature": None,
+                "temp": {"c": None, "f": None},
+                "water_level": "medium",
+                "filter_run_time": datetime.timedelta(days=1, seconds=18000),
+                "filter_alert": False,
+                "target_humidity": 52,
+            },
+            "#",
+            "Evaporative Humidifier",
+            SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
+        ),
+        AdvTestCase(
+            b"\xa0\xa3\xb3,\x9c\xe6H\x86\x80\xff\xff\xf2\x10\x1d\x00\x874",
+            b"#\x00\x00\x15\x1c\x00",
+            {
+                "seq_number": 72,
+                "isOn": True,
+                "mode": HumidifierMode(6),
+                "over_humidify_protection": True,
+                "child_lock": False,
+                "tank_removed": False,
+                "tilted_alert": False,
+                "filter_missing": False,
+                "is_meter_binded": True,
+                "humidity": None,
+                "temperature": None,
+                "temp": {"c": None, "f": None},
+                "water_level": "medium",
+                "filter_run_time": datetime.timedelta(days=1, seconds=18000),
+                "filter_alert": False,
+                "target_humidity": 52,
+            },
+            "#",
+            "Evaporative Humidifier",
+            SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
+        ),
+        AdvTestCase(
+            b"\xacg\xb2\xcd\xfa\xbe",
+            b"e\x80\x00\xf9\x80Bc\x00",
+            {
+                "isOn": True,
+                "level": 128,
+                "switchMode": True,
+            },
+            "e",
+            "Humidifier",
+            SwitchbotModel.HUMIDIFIER,
+        ),
+    ],
+)
+def test_humidifer_active(test_case: AdvTestCase) -> None:
+    """Test humidifier with active data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: test_case.manufacturer_data},
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": test_case.service_data},
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": test_case.service_data,
+            "data": test_case.data,
+            "isEncrypted": False,
+            "model": test_case.model,
+            "modelFriendlyName": test_case.modelFriendlyName,
+            "modelName": test_case.modelName,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [
+        AdvTestCase(
+            b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]",
+            b"#\x00\x00\x15\x1c\x00",
+            {
+                "seq_number": 56,
+                "isOn": True,
+                "mode": HumidifierMode(6),
+                "over_humidify_protection": True,
+                "child_lock": False,
+                "tank_removed": False,
+                "tilted_alert": False,
+                "filter_missing": False,
+                "is_meter_binded": True,
+                "humidity": 53,
+                "temperature": 25.1,
+                "temp": {"c": 25.1, "f": 77.18},
+                "water_level": "medium",
+                "filter_run_time": datetime.timedelta(days=1, seconds=10800),
+                "filter_alert": False,
+                "target_humidity": 93,
+            },
+            "#",
+            "Evaporative Humidifier",
+            SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
+        ),
+        AdvTestCase(
+            b"\xacg\xb2\xcd\xfa\xbe",
+            b"e\x80\x00\xf9\x80Bc\x00",
+            {
+                "isOn": None,
+                "level": None,
+                "switchMode": True,
+            },
+            "e",
+            "Humidifier",
+            SwitchbotModel.HUMIDIFIER,
+        ),
+    ],
+)
+def test_humidifer_passive(test_case: AdvTestCase) -> None:
+    """Test humidifier with passive data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: test_case.manufacturer_data},
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data, test_case.modelName)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": None,
+            "data": test_case.data,
+            "isEncrypted": False,
+            "model": test_case.model,
+            "modelFriendlyName": test_case.modelFriendlyName,
+            "modelName": test_case.modelName,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=False,
+    )
+
+
+def test_humidifer_with_empty_data() -> None:
+    """Test humidifier with empty data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: None},
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"#\x00\x00\x15\x1c\x00"},
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b"#\x00\x00\x15\x1c\x00",
+            "data": {},
+            "isEncrypted": False,
+            "model": "#",
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )

+ 262 - 107
tests/test_evaporative_humidifier.py

@@ -1,13 +1,18 @@
 import datetime
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 from bleak.backends.device import BLEDevice
 
-from switchbot import SwitchBotAdvertisement, SwitchbotModel
-from switchbot.adv_parsers.humidifier import process_evaporative_humidifier
-from switchbot.const.evaporative_humidifier import HumidifierMode, HumidifierWaterLevel
+from switchbot import (
+    HumidifierAction,
+    HumidifierMode,
+    HumidifierWaterLevel,
+    SwitchBotAdvertisement,
+    SwitchbotModel,
+)
 from switchbot.devices import evaporative_humidifier
+from switchbot.devices.device import SwitchbotEncryptedDevice, SwitchbotOperationError
 
 from .test_adv_parser import generate_ble_device
 
@@ -22,6 +27,9 @@ def create_device_for_command_testing(init_data: dict | None = None):
     evaporative_humidifier_device.update_from_advertisement(
         make_advertisement_data(ble_device, init_data)
     )
+    evaporative_humidifier_device._send_command = AsyncMock()
+    evaporative_humidifier_device._check_command_result = MagicMock()
+    evaporative_humidifier_device.update = AsyncMock()
     return evaporative_humidifier_device
 
 
@@ -36,17 +44,19 @@ def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None
             "data": {
                 "isOn": False,
                 "mode": None,
-                "target_humidity": None,
+                "target_humidity": 52,
                 "child_lock": False,
                 "over_humidify_protection": True,
                 "tank_removed": False,
                 "tilted_alert": False,
                 "filter_missing": False,
+                "is_meter_binded": True,
                 "humidity": 51,
                 "temperature": 16.8,
+                "temp": {"c": 16.8, "f": 62.24},
                 "filter_run_time": datetime.timedelta(days=3, seconds=57600),
                 "filter_alert": False,
-                "water_level": HumidifierWaterLevel.LOW,
+                "water_level": "medium",
             }
             | init_data,
             "isEncrypted": False,
@@ -60,143 +70,288 @@ def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None
     )
 
 
-@pytest.mark.asyncio
-async def test_process_advertisement():
-    data = process_evaporative_humidifier(
-        b"#\x00\x00\x15\x1c\x00",
-        b"\xd4\x8cIU\x95\xb2\x08\x06\x88\xb3\x90\x81\x00X\x00X2",
-    )
-
-    assert data == {
-        "isOn": False,
-        "mode": None,
-        "target_humidity": None,
-        "child_lock": False,
-        "over_humidify_protection": None,
-        "tank_removed": False,
-        "tilted_alert": False,
-        "filter_missing": False,
-        "humidity": 51,
-        "temperature": 16.8,
-        "filter_run_time": datetime.timedelta(days=3, seconds=57600),
-        "filter_alert": False,
-        "water_level": HumidifierWaterLevel.LOW,
-    }
-
-
-@pytest.mark.asyncio
-async def test_process_advertisement_empty():
-    data = process_evaporative_humidifier(None, None)
-
-    assert data == {
-        "isOn": None,
-        "mode": None,
-        "target_humidity": None,
-        "child_lock": None,
-        "over_humidify_protection": None,
-        "tank_removed": None,
-        "tilted_alert": None,
-        "filter_missing": None,
-        "humidity": None,
-        "temperature": None,
-        "filter_run_time": None,
-        "filter_alert": None,
-        "water_level": None,
-    }
-
-
 @pytest.mark.asyncio
 async def test_turn_on():
-    device = create_device_for_command_testing({"isOn": False})
-    device._send_command = AsyncMock(return_value=b"\x01")
-
-    assert device.is_on() is False
+    """Test the turn_on method."""
+    device = create_device_for_command_testing({"isOn": True})
     await device.turn_on()
     assert device.is_on() is True
 
 
 @pytest.mark.asyncio
 async def test_turn_off():
-    device = create_device_for_command_testing({"isOn": True})
-    device._send_command = AsyncMock(return_value=b"\x01")
-
-    assert device.is_on() is True
+    """Test the turn_off method."""
+    device = create_device_for_command_testing({"isOn": False})
     await device.turn_off()
     assert device.is_on() is False
 
 
 @pytest.mark.asyncio
-async def test_set_mode():
-    device = create_device_for_command_testing(
-        {"isOn": True, "mode": HumidifierMode.LOW}
-    )
-    device._send_command = AsyncMock(return_value=b"\x01")
-
-    assert device.get_mode() is HumidifierMode.LOW
-    await device.set_mode(HumidifierMode.AUTO)
-    assert device.get_mode() is HumidifierMode.AUTO
+async def test_get_basic_is_none():
+    """Test the get_basic_info when it returns None."""
+    device = create_device_for_command_testing()
+    device._get_basic_info = AsyncMock(return_value=None)
+    assert await device.get_basic_info() is None
 
-    await device.set_mode(HumidifierMode.TARGET_HUMIDITY, 60)
-    assert device.get_mode() is HumidifierMode.TARGET_HUMIDITY
-    assert device.get_target_humidity() == 60
 
-    await device.set_mode(HumidifierMode.DRYING_FILTER)
-    assert device.get_mode() is HumidifierMode.DRYING_FILTER
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("basic_info", "result"),
+    [
+        (
+            bytearray(b"\x01\x86\x88\xb1\x98\x82\x00\x1e\x00\x88-\xc4\xff\xff \n\x07"),
+            [
+                True,
+                HumidifierMode(6),
+                True,
+                False,
+                False,
+                False,
+                False,
+                True,
+                49,
+                24.8,
+                24.8,
+                76.64,
+                "medium",
+                30,
+                45,
+            ],
+        ),
+        (
+            bytearray(b"\x01\x08 \xb1\x98r\x00\x1e\x00\x89-\xc4\xff\xff\x00\x00\x00"),
+            [
+                False,
+                HumidifierMode(8),
+                False,
+                True,
+                False,
+                False,
+                False,
+                True,
+                49,
+                24.7,
+                24.7,
+                76.46,
+                "medium",
+                30,
+                45,
+            ],
+        ),
+    ],
+)
+async def test_get_basic_info(basic_info, result):
+    """Test the get_basic_info method."""
+    device = create_device_for_command_testing()
+    device._get_basic_info = AsyncMock(return_value=basic_info)
+
+    info = await device.get_basic_info()
+    assert info["isOn"] is result[0]
+    assert info["mode"] == result[1]
+    assert info["over_humidify_protection"] is result[2]
+    assert info["child_lock"] is result[3]
+    assert info["tank_removed"] is result[4]
+    assert info["tilted_alert"] is result[5]
+    assert info["filter_missing"] is result[6]
+    assert info["is_meter_binded"] is result[7]
+    assert info["humidity"] == result[8]
+    assert info["temperature"] == result[9]
+    assert info["temp"]["c"] == result[10]
+    assert info["temp"]["f"] == result[11]
+    assert info["water_level"] == result[12]
+    assert info["filter_run_time"] == result[13]
+    assert info["target_humidity"] == result[14]
 
-    with pytest.raises(ValueError):  # noqa: PT011
-        await device.set_mode(0)
 
-    with pytest.raises(TypeError):
-        await device.set_mode(HumidifierMode.TARGET_HUMIDITY)
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("err_msg", "mode", "water_level"),
+    [
+        (
+            "Target humidity can only be set in target humidity mode or sleep mode",
+            HumidifierMode.AUTO,
+            "low",
+        ),
+        (
+            "Cannot perform operation when water tank is empty",
+            HumidifierMode.TARGET_HUMIDITY,
+            "empty",
+        ),
+    ],
+)
+async def test_set_target_humidity_with_invalid_conditions(err_msg, mode, water_level):
+    """Test setting target humidity with invalid mode."""
+    device = create_device_for_command_testing()
+    device.get_mode = MagicMock(return_value=mode)
+    device.get_water_level = MagicMock(return_value=water_level)
+    with pytest.raises(SwitchbotOperationError, match=err_msg):
+        await device.set_target_humidity(45)
 
 
 @pytest.mark.asyncio
-async def test_set_child_lock():
-    device = create_device_for_command_testing({"child_lock": False})
-    device._send_command = AsyncMock(return_value=b"\x01")
-
-    assert device.is_child_lock_enabled() is False
-    await device.set_child_lock(True)
-    assert device.is_child_lock_enabled() is True
+@pytest.mark.parametrize(
+    ("err_msg", "mode", "water_level", "is_meter_binded", "target_humidity"),
+    [
+        (
+            "Cannot perform operation when water tank is empty",
+            HumidifierMode.TARGET_HUMIDITY,
+            "empty",
+            True,
+            45,
+        ),
+        (
+            "Cannot set target humidity or auto mode when meter is not binded",
+            HumidifierMode.TARGET_HUMIDITY,
+            "medium",
+            False,
+            45,
+        ),
+        (
+            "Target humidity must be set before switching to target humidity mode or sleep mode",
+            HumidifierMode.TARGET_HUMIDITY,
+            "medium",
+            True,
+            None,
+        ),
+    ],
+)
+async def test_set_mode_with_invalid_conditions(
+    err_msg, mode, water_level, is_meter_binded, target_humidity
+):
+    """Test setting target humidity with invalid mode."""
+    device = create_device_for_command_testing()
+    device.get_water_level = MagicMock(return_value=water_level)
+    device.is_meter_binded = MagicMock(return_value=is_meter_binded)
+    device.get_target_humidity = MagicMock(return_value=target_humidity)
+    with pytest.raises(SwitchbotOperationError, match=err_msg):
+        await device.set_mode(mode)
 
 
 @pytest.mark.asyncio
-async def test_start_drying_filter():
-    device = create_device_for_command_testing(
-        {"isOn": True, "mode": HumidifierMode.AUTO}
-    )
-    device._send_command = AsyncMock(return_value=b"\x01")
+async def test_set_target_humidity():
+    """Test setting target humidity."""
+    device = create_device_for_command_testing()
+    device.get_mode = MagicMock(return_value=HumidifierMode.TARGET_HUMIDITY)
 
-    assert device.get_mode() is HumidifierMode.AUTO
-    await device.start_drying_filter()
-    assert device.get_mode() is HumidifierMode.DRYING_FILTER
+    await device.set_target_humidity(45)
+    device._send_command.assert_awaited_once_with("570f430202002d")
 
 
 @pytest.mark.asyncio
-async def test_stop_drying_filter():
-    device = create_device_for_command_testing(
-        {"isOn": True, "mode": HumidifierMode.DRYING_FILTER}
-    )
-    device._send_command = AsyncMock(return_value=b"\x00")
+@pytest.mark.parametrize(
+    ("mode", "command"),
+    [
+        (HumidifierMode.TARGET_HUMIDITY, "570f430202002d"),
+        (HumidifierMode.AUTO, "570f4302040000"),
+        (HumidifierMode.SLEEP, "570f430203002d"),
+        (HumidifierMode.DRYING_FILTER, "570f43010108"),
+    ],
+)
+async def test_set_mode(mode, command):
+    """Test setting mode."""
+    device = create_device_for_command_testing()
+    device.get_target_humidity = MagicMock(return_value=45)
 
-    assert device.is_on() is True
-    assert device.get_mode() is HumidifierMode.DRYING_FILTER
-    await device.stop_drying_filter()
-    assert device.is_on() is False
-    assert device.get_mode() is None
+    await device.set_mode(mode)
+    device._send_command.assert_awaited_once_with(command)
 
 
 @pytest.mark.asyncio
-async def test_attributes():
-    device = create_device_for_command_testing()
-    device._send_command = AsyncMock(return_value=b"\x01")
-
+@pytest.mark.parametrize(
+    ("init_data", "result"),
+    [
+        (
+            {"isOn": False, "mode": HumidifierMode.AUTO},
+            [False, HumidifierMode.AUTO, HumidifierAction.OFF],
+        ),
+        (
+            {"isOn": True, "mode": HumidifierMode.TARGET_HUMIDITY},
+            [True, HumidifierMode.TARGET_HUMIDITY, HumidifierAction.HUMIDIFYING],
+        ),
+        (
+            {"isOn": True, "mode": HumidifierMode.DRYING_FILTER},
+            [True, HumidifierMode.DRYING_FILTER, HumidifierAction.DRYING],
+        ),
+    ],
+)
+async def test_status_from_process_adv(init_data, result):
+    """Test status from process advertisement."""
+    device = create_device_for_command_testing(init_data)
+
+    assert device.is_on() is result[0]
+    assert device.get_mode() is result[1]
+    assert device.is_child_lock_enabled() is False
     assert device.is_over_humidify_protection_enabled() is True
     assert device.is_tank_removed() is False
     assert device.is_filter_missing() is False
     assert device.is_filter_alert_on() is False
     assert device.is_tilted_alert_on() is False
-    assert device.get_water_level() is HumidifierWaterLevel.LOW
+    assert device.get_water_level() == "medium"
     assert device.get_filter_run_time() == datetime.timedelta(days=3, seconds=57600)
+    assert device.get_target_humidity() == 52
     assert device.get_humidity() == 51
     assert device.get_temperature() == 16.8
+    assert device.get_action() == result[2]
+    assert device.is_meter_binded() is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("enabled", "command"),
+    [
+        (True, "570f430501"),
+        (False, "570f430500"),
+    ],
+)
+async def test_set_child_lock(enabled, command):
+    """Test setting child lock."""
+    device = create_device_for_command_testing()
+    await device.set_child_lock(enabled)
+    device._send_command.assert_awaited_once_with(command)
+
+
+@pytest.mark.asyncio
+@patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
+async def test_verify_encryption_key(mock_parent_verify):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    key_id = "ff"
+    encryption_key = "ffffffffffffffffffffffffffffffff"
+
+    mock_parent_verify.return_value = True
+
+    result = await evaporative_humidifier.SwitchbotEvaporativeHumidifier.verify_encryption_key(
+        device=ble_device,
+        key_id=key_id,
+        encryption_key=encryption_key,
+    )
+
+    mock_parent_verify.assert_awaited_once_with(
+        ble_device,
+        key_id,
+        encryption_key,
+        SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
+    )
+
+    assert result is True
+
+
+def test_evaporative_humidifier_modes():
+    assert HumidifierMode.get_modes() == [
+        "high",
+        "medium",
+        "low",
+        "quiet",
+        "target_humidity",
+        "sleep",
+        "auto",
+        "drying_filter",
+    ]
+
+
+def test_evaporative_humidifier_water_levels():
+    assert HumidifierWaterLevel.get_levels() == [
+        "empty",
+        "low",
+        "medium",
+        "high",
+    ]