Переглянути джерело

Add support for switchbot air purifier (#329)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Retha Runolfsson 2 днів тому
батько
коміт
b510273ebb

+ 4 - 0
switchbot/__init__.py

@@ -10,6 +10,7 @@ from bleak_retry_connector import (
 
 from .adv_parser import SwitchbotSupportedType, parse_advertisement_data
 from .const import (
+    AirPurifierMode,
     FanMode,
     LockStatus,
     SwitchbotAccountConnectionError,
@@ -17,6 +18,7 @@ from .const import (
     SwitchbotAuthenticationError,
     SwitchbotModel,
 )
+from .devices.air_purifier import SwitchbotAirPurifier
 from .devices.base_light import SwitchbotBaseLight
 from .devices.blind_tilt import SwitchbotBlindTilt
 from .devices.bot import Switchbot
@@ -37,6 +39,7 @@ from .discovery import GetSwitchbotDevices
 from .models import SwitchBotAdvertisement
 
 __all__ = [
+    "AirPurifierMode",
     "ColorMode",
     "FanMode",
     "GetSwitchbotDevices",
@@ -45,6 +48,7 @@ __all__ = [
     "Switchbot",
     "Switchbot",
     "SwitchbotAccountConnectionError",
+    "SwitchbotAirPurifier",
     "SwitchbotApiError",
     "SwitchbotAuthenticationError",
     "SwitchbotBaseLight",

+ 25 - 0
switchbot/adv_parser.py

@@ -10,6 +10,7 @@ from typing import Any, TypedDict
 from bleak.backends.device import BLEDevice
 from bleak.backends.scanner import AdvertisementData
 
+from .adv_parsers.air_purifier import process_air_purifier
 from .adv_parsers.blind_tilt import process_woblindtilt
 from .adv_parsers.bot import process_wohand
 from .adv_parsers.bulb import process_color_bulb
@@ -268,6 +269,30 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
         "func": process_vacuum_k,
         "manufacturer_id": 2409,
     },
+    "*": {
+        "modelName": SwitchbotModel.AIR_PURIFIER,
+        "modelFriendlyName": "Air Purifier",
+        "func": process_air_purifier,
+        "manufacturer_id": 2409,
+    },
+    "+": {
+        "modelName": SwitchbotModel.AIR_PURIFIER,
+        "modelFriendlyName": "Air Purifier",
+        "func": process_air_purifier,
+        "manufacturer_id": 2409,
+    },
+    "7": {
+        "modelName": SwitchbotModel.AIR_PURIFIER,
+        "modelFriendlyName": "Air Purifier",
+        "func": process_air_purifier,
+        "manufacturer_id": 2409,
+    },
+    "8": {
+        "modelName": SwitchbotModel.AIR_PURIFIER,
+        "modelFriendlyName": "Air Purifier",
+        "func": process_air_purifier,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {

+ 52 - 0
switchbot/adv_parsers/air_purifier.py

@@ -0,0 +1,52 @@
+"""Air Purifier adv parser."""
+
+from __future__ import annotations
+
+import struct
+
+from ..const.air_purifier import AirPurifierMode, AirQualityLevel
+
+
+def process_air_purifier(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int]:
+    """Process air purifier services data."""
+    if mfr_data is None:
+        return {}
+    device_data = mfr_data[6:]
+
+    _seq_num = device_data[0]
+    _isOn = bool(device_data[1] & 0b10000000)
+    _mode = device_data[1] & 0b00000111
+    _is_aqi_valid = bool(device_data[2] & 0b00000100)
+    _child_lock = bool(device_data[2] & 0b00000010)
+    _speed = device_data[3] & 0b01111111
+    _aqi_level = (device_data[4] & 0b00000110) >> 1
+    _aqi_level = AirQualityLevel(_aqi_level).name.lower()
+    _work_time = struct.unpack(">H", device_data[5:7])[0]
+    _err_code = device_data[7]
+
+    return {
+        "isOn": _isOn,
+        "mode": get_air_purifier_mode(_mode, _speed),
+        "isAqiValid": _is_aqi_valid,
+        "child_lock": _child_lock,
+        "speed": _speed,
+        "aqi_level": _aqi_level,
+        "filter element working time": _work_time,
+        "err_code": _err_code,
+        "sequence_number": _seq_num,
+    }
+
+
+def get_air_purifier_mode(mode: int, speed: int) -> str | None:
+    if mode == 1:
+        if 0 <= speed <= 33:
+            return "level_1"
+        if 34 <= speed <= 66:
+            return "level_2"
+        return "level_3"
+    if 1 < mode <= 4:
+        mode += 2
+        return AirPurifierMode(mode).name.lower()
+    return None

+ 3 - 0
switchbot/const/__init__.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 from ..enum import StrEnum
+from .air_purifier import AirPurifierMode
 from .fan import FanMode
 
 # Preserve old LockStatus export for backwards compatibility
@@ -72,12 +73,14 @@ class SwitchbotModel(StrEnum):
     K10_VACUUM = "K10+ Vacuum"
     K10_PRO_VACUUM = "K10+ Pro Vacuum"
     K10_PRO_COMBO_VACUUM = "K10+ Pro Combo Vacuum"
+    AIR_PURIFIER = "Air Purifier"
 
 
 __all__ = [
     "DEFAULT_RETRY_COUNT",
     "DEFAULT_RETRY_TIMEOUT",
     "DEFAULT_SCAN_TIMEOUT",
+    "AirPurifierMode",
     "FanMode",
     "LockStatus",
     "SwitchbotAccountConnectionError",

+ 23 - 0
switchbot/const/air_purifier.py

@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from enum import Enum
+
+
+class AirPurifierMode(Enum):
+    LEVEL_1 = 1
+    LEVEL_2 = 2
+    LEVEL_3 = 3
+    AUTO = 4
+    PET = 5
+    SLEEP = 6
+
+    @classmethod
+    def get_modes(cls) -> list[str]:
+        return [mode.name.lower() for mode in cls]
+
+
+class AirQualityLevel(Enum):
+    EXCELLENT = 0
+    GOOD = 1
+    MODERATE = 2
+    UNHEALTHY = 3

+ 142 - 0
switchbot/devices/air_purifier.py

@@ -0,0 +1,142 @@
+"""Library to handle connection with Switchbot."""
+
+from __future__ import annotations
+
+import logging
+import struct
+from typing import Any
+
+from bleak.backends.device import BLEDevice
+
+from ..adv_parsers.air_purifier import get_air_purifier_mode
+from ..const import SwitchbotModel
+from ..const.air_purifier import AirPurifierMode, AirQualityLevel
+from .device import (
+    SwitchbotEncryptedDevice,
+    SwitchbotSequenceDevice,
+    update_after_operation,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+COMMAND_HEAD = "570f4c"
+COMMAND_TURN_OFF = f"{COMMAND_HEAD}010000"
+COMMAND_TURN_ON = f"{COMMAND_HEAD}010100"
+COMMAND_SET_MODE = {
+    AirPurifierMode.LEVEL_1.name.lower(): f"{COMMAND_HEAD}01010100",
+    AirPurifierMode.LEVEL_2.name.lower(): f"{COMMAND_HEAD}01010132",
+    AirPurifierMode.LEVEL_3.name.lower(): f"{COMMAND_HEAD}01010164",
+    AirPurifierMode.AUTO.name.lower(): f"{COMMAND_HEAD}01010200",
+    AirPurifierMode.PET.name.lower(): f"{COMMAND_HEAD}01010300",
+    AirPurifierMode.SLEEP.name.lower(): f"{COMMAND_HEAD}01010400",
+}
+DEVICE_GET_BASIC_SETTINGS_KEY = "570f4d81"
+
+
+class SwitchbotAirPurifier(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
+    """Representation of a Switchbot Air Purifier."""
+
+    def __init__(
+        self,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        interface: int = 0,
+        model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER,
+        **kwargs: Any,
+    ) -> None:
+        super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
+
+    @classmethod
+    async def verify_encryption_key(
+        cls,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER,
+        **kwargs: Any,
+    ) -> bool:
+        return await super().verify_encryption_key(
+            device, key_id, encryption_key, model, **kwargs
+        )
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info()):
+            return None
+
+        _LOGGER.debug("data: %s", _data)
+        isOn = bool(_data[2] & 0b10000000)
+        version_info = (_data[2] & 0b00110000) >> 4
+        _mode = _data[2] & 0b00000111
+        isAqiValid = bool(_data[3] & 0b00000100)
+        child_lock = bool(_data[3] & 0b00000010)
+        _aqi_level = (_data[4] & 0b00000110) >> 1
+        aqi_level = AirQualityLevel(_aqi_level).name.lower()
+        speed = _data[6] & 0b01111111
+        pm25 = struct.unpack("<H", _data[12:14])[0] & 0xFFF
+        firmware = _data[15] / 10.0
+        mode = get_air_purifier_mode(_mode, speed)
+
+        return {
+            "isOn": isOn,
+            "version_info": version_info,
+            "mode": mode,
+            "isAqiValid": isAqiValid,
+            "child_lock": child_lock,
+            "aqi_level": aqi_level,
+            "speed": speed,
+            "pm25": pm25,
+            "firmware": firmware,
+        }
+
+    async def _get_basic_info(self) -> bytes | None:
+        """Return basic info of device."""
+        _data = await self._send_command(
+            key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count
+        )
+
+        if _data in (b"\x07", b"\x00"):
+            _LOGGER.error("Unsuccessful, please try again")
+            return None
+
+        return _data
+
+    @update_after_operation
+    async def set_preset_mode(self, preset_mode: str) -> bool:
+        """Send command to set air purifier preset_mode."""
+        result = await self._send_command(COMMAND_SET_MODE[preset_mode])
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def turn_on(self) -> bool:
+        """Turn on the air purifier."""
+        result = await self._send_command(COMMAND_TURN_ON)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def turn_off(self) -> bool:
+        """Turn off the air purifier."""
+        result = await self._send_command(COMMAND_TURN_OFF)
+        return self._check_command_result(result, 0, {1})
+
+    def get_current_percentage(self) -> Any:
+        """Return cached percentage."""
+        return self._get_adv_value("speed")
+
+    def is_on(self) -> bool | None:
+        """Return air purifier state from cache."""
+        return self._get_adv_value("isOn")
+
+    def get_current_aqi_level(self) -> Any:
+        """Return cached aqi level."""
+        return self._get_adv_value("aqi_level")
+
+    def get_current_pm25(self) -> Any:
+        """Return cached pm25."""
+        return self._get_adv_value("pm25")
+
+    def get_current_mode(self) -> Any:
+        """Return cached mode."""
+        return self._get_adv_value("mode")

+ 185 - 0
tests/test_adv_parser.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import Any
 
+import pytest
 from bleak.backends.device import BLEDevice
 from bleak.backends.scanner import AdvertisementData
 
@@ -2643,3 +2644,187 @@ def test_s10_with_empty_data() -> None:
         rssi=-97,
         active=True,
     )
+
+
+@pytest.mark.parametrize(
+    ("manufacturer_data", "service_data", "data", "model"),
+    [
+        (
+            b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00",
+            b"7\x00\x00\x95-\x00",
+            {
+                "isOn": True,
+                "mode": "level_3",
+                "isAqiValid": False,
+                "child_lock": False,
+                "speed": 100,
+                "aqi_level": "excellent",
+                "filter element working time": 405,
+                "err_code": 0,
+                "sequence_number": 161,
+            },
+            "7",
+        ),
+        (
+            b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00',
+            b"*\x00\x00\x15\x04\x00",
+            {
+                "isOn": False,
+                "mode": "auto",
+                "isAqiValid": False,
+                "child_lock": False,
+                "speed": 0,
+                "aqi_level": "excellent",
+                "filter element working time": 15,
+                "err_code": 0,
+                "sequence_number": 9,
+            },
+            "*",
+        ),
+        (
+            b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00",
+            b"+\x00\x00\x15\x04\x00",
+            {
+                "isOn": True,
+                "mode": "pet",
+                "isAqiValid": False,
+                "child_lock": False,
+                "speed": 100,
+                "aqi_level": "excellent",
+                "filter element working time": 60000,
+                "err_code": 0,
+                "sequence_number": 11,
+            },
+            "+",
+        ),
+        (
+            b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00",
+            b"8\x00\x00\x95-\x00",
+            {
+                "isOn": True,
+                "mode": "level_2",
+                "isAqiValid": True,
+                "child_lock": False,
+                "speed": 50,
+                "aqi_level": "excellent",
+                "filter element working time": 404,
+                "err_code": 0,
+                "sequence_number": 155,
+            },
+            "8",
+        ),
+        (
+            b"\xcc\x8d\xa2\xa7\xc1\xae\x9e\xa1\x8c\x800\x01\x95\x00\x00",
+            b"8\x00\x00\x95-\x00",
+            {
+                "isOn": True,
+                "mode": "level_1",
+                "isAqiValid": True,
+                "child_lock": False,
+                "speed": 0,
+                "aqi_level": "excellent",
+                "filter element working time": 405,
+                "err_code": 0,
+                "sequence_number": 158,
+            },
+            "8",
+        ),
+        (
+            b"\xcc\x8d\xa2\xa7\xc1\xae\x9e\x05\x8c\x800\x01\x95\x00\x00",
+            b"8\x00\x00\x95-\x00",
+            {
+                "isOn": False,
+                "mode": None,
+                "isAqiValid": True,
+                "child_lock": False,
+                "speed": 0,
+                "aqi_level": "excellent",
+                "filter element working time": 405,
+                "err_code": 0,
+                "sequence_number": 158,
+            },
+            "8",
+        ),
+    ],
+)
+def test_air_purifier_active(manufacturer_data, service_data, data, model) -> None:
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: manufacturer_data},
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": service_data},
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": service_data,
+            "data": data,
+            "isEncrypted": False,
+            "model": model,
+            "modelFriendlyName": "Air Purifier",
+            "modelName": SwitchbotModel.AIR_PURIFIER,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )
+
+
+def test_air_purifier_passive() -> None:
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00"
+        },
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.AIR_PURIFIER)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": None,
+            "data": {
+                "isOn": True,
+                "mode": "level_3",
+                "isAqiValid": False,
+                "child_lock": False,
+                "speed": 100,
+                "aqi_level": "excellent",
+                "filter element working time": 405,
+                "err_code": 0,
+                "sequence_number": 161,
+            },
+            "isEncrypted": False,
+            "model": "8",
+            "modelFriendlyName": "Air Purifier",
+            "modelName": SwitchbotModel.AIR_PURIFIER,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=False,
+    )
+
+
+def test_air_purifier_with_empty_data() -> None:
+    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\x04\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\x04\x00",
+            "data": {},
+            "isEncrypted": False,
+            "model": "+",
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )

+ 231 - 0
tests/test_air_purifier.py

@@ -0,0 +1,231 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import SwitchBotAdvertisement, SwitchbotEncryptedDevice, SwitchbotModel
+from switchbot.const.air_purifier import AirPurifierMode
+from switchbot.devices import air_purifier
+
+from .test_adv_parser import generate_ble_device
+
+common_params = [
+    (b"7\x00\x00\x95-\x00", "7"),
+    (b"*\x00\x00\x15\x04\x00", "*"),
+    (b"+\x00\x00\x15\x04\x00", "+"),
+    (b"8\x00\x00\x95-\x00", "8"),
+]
+
+
+def create_device_for_command_testing(
+    rawAdvData: bytes, model: str, init_data: dict | None = None
+):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = air_purifier.SwitchbotAirPurifier(
+        ble_device, "ff", "ffffffffffffffffffffffffffffffff"
+    )
+    device.update_from_advertisement(
+        make_advertisement_data(ble_device, rawAdvData, model, init_data)
+    )
+    device._send_command = AsyncMock()
+    device._check_command_result = MagicMock()
+    device.update = AsyncMock()
+    return device
+
+
+def make_advertisement_data(
+    ble_device: BLEDevice, rawAdvData: bytes, model: str, init_data: dict | None = None
+):
+    """Set advertisement data with defaults."""
+    if init_data is None:
+        init_data = {}
+
+    return SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": rawAdvData,
+            "data": {
+                "isOn": True,
+                "mode": "level_3",
+                "isAqiValid": False,
+                "child_lock": False,
+                "speed": 100,
+                "aqi_level": "excellent",
+                "filter element working time": 405,
+                "err_code": 0,
+                "sequence_number": 161,
+            }
+            | init_data,
+            "isEncrypted": False,
+            "model": model,
+            "modelFriendlyName": "Air Purifier",
+            "modelName": SwitchbotModel.AIR_PURIFIER,
+        },
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("rawAdvData", "model"),
+    common_params,
+)
+@pytest.mark.parametrize(
+    "pm25",
+    [150],
+)
+async def test_status_from_proceess_adv(rawAdvData, model, pm25):
+    device = create_device_for_command_testing(rawAdvData, model, {"pm25": pm25})
+    assert device.get_current_percentage() == 100
+    assert device.is_on() is True
+    assert device.get_current_aqi_level() == "excellent"
+    assert device.get_current_mode() == "level_3"
+    assert device.get_current_pm25() == 150
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("rawAdvData", "model"),
+    common_params,
+)
+async def test_get_basic_info_returns_none_when_no_data(rawAdvData, model):
+    device = create_device_for_command_testing(rawAdvData, model)
+    device._get_basic_info = AsyncMock(return_value=None)
+
+    assert await device.get_basic_info() is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("rawAdvData", "model"),
+    common_params,
+)
+@pytest.mark.parametrize(
+    "mode", ["level_1", "level_2", "level_3", "auto", "pet", "sleep"]
+)
+async def test_set_preset_mode(rawAdvData, model, mode):
+    device = create_device_for_command_testing(rawAdvData, model, {"mode": mode})
+    await device.set_preset_mode(mode)
+    assert device.get_current_mode() == mode
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("rawAdvData", "model"),
+    common_params,
+)
+async def test_turn_on(rawAdvData, model):
+    device = create_device_for_command_testing(rawAdvData, model, {"isOn": True})
+    await device.turn_on()
+    assert device.is_on() is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("rawAdvData", "model"),
+    common_params,
+)
+async def test_turn_off(rawAdvData, model):
+    device = create_device_for_command_testing(rawAdvData, model, {"isOn": False})
+    await device.turn_off()
+    assert device.is_on() is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("rawAdvData", "model"),
+    common_params,
+)
+@pytest.mark.parametrize(
+    ("response", "expected"),
+    [
+        (b"\x00", None),
+        (b"\x07", None),
+        (b"\x01\x02\x03", b"\x01\x02\x03"),
+    ],
+)
+async def test__get_basic_info(rawAdvData, model, response, expected):
+    device = create_device_for_command_testing(rawAdvData, model)
+    device._send_command = AsyncMock(return_value=response)
+    result = await device._get_basic_info()
+    assert result == expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("rawAdvData", "model"),
+    common_params,
+)
+@pytest.mark.parametrize(
+    ("basic_info", "result"),
+    [
+        (
+            bytearray(
+                b"\x01\xa7\xe9\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\xf0\x00\x00\x17"
+            ),
+            [True, 2, "level_2", True, False, "excellent", 50, 240, 2.3],
+        ),
+        (
+            bytearray(
+                b"\x01\xa8\xec\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\xf0\x00\x00\x17"
+            ),
+            [True, 2, "sleep", True, False, "excellent", 50, 240, 2.3],
+        ),
+    ],
+)
+async def test_get_basic_info(rawAdvData, model, basic_info, result):
+    device = create_device_for_command_testing(rawAdvData, model)
+
+    async def mock_get_basic_info():
+        return basic_info
+
+    device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+
+    info = await device.get_basic_info()
+    assert info["isOn"] == result[0]
+    assert info["version_info"] == result[1]
+    assert info["mode"] == result[2]
+    assert info["isAqiValid"] == result[3]
+    assert info["child_lock"] == result[4]
+    assert info["aqi_level"] == result[5]
+    assert info["speed"] == result[6]
+    assert info["pm25"] == result[7]
+    assert info["firmware"] == result[8]
+
+
+@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 air_purifier.SwitchbotAirPurifier.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.AIR_PURIFIER,
+    )
+
+    assert result is True
+
+
+def test_get_modes():
+    assert AirPurifierMode.get_modes() == [
+        "level_1",
+        "level_2",
+        "level_3",
+        "auto",
+        "pet",
+        "sleep",
+    ]