浏览代码

Add lock series products (#340)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Retha Runolfsson 1 周之前
父节点
当前提交
ab7dad7942

+ 13 - 1
switchbot/adv_parser.py

@@ -25,7 +25,7 @@ from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohu
 from .adv_parsers.keypad import process_wokeypad
 from .adv_parsers.leak import process_leak
 from .adv_parsers.light_strip import process_wostrip
-from .adv_parsers.lock import process_wolock, process_wolock_pro
+from .adv_parsers.lock import process_lock2, process_wolock, process_wolock_pro
 from .adv_parsers.meter import process_wosensorth, process_wosensorth_c
 from .adv_parsers.motion import process_wopresence
 from .adv_parsers.plug import process_woplugmini
@@ -300,6 +300,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_hub3,
         "manufacturer_id": 2409,
     },
+    "-": {
+        "modelName": SwitchbotModel.LOCK_LITE,
+        "modelFriendlyName": "Lock Lite",
+        "func": process_wolock,
+        "manufacturer_id": 2409,
+    },
+    b"\x00\x10\xa5\xb8": {
+        "modelName": SwitchbotModel.LOCK_ULTRA,
+        "modelFriendlyName": "Lock Ultra",
+        "func": process_lock2,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {

+ 1 - 1
switchbot/adv_parsers/hub3.py

@@ -1,4 +1,4 @@
-"""Air Purifier adv parser."""
+"""Hub3 adv parser."""
 
 from __future__ import annotations
 

+ 46 - 18
switchbot/adv_parsers/lock.py

@@ -10,7 +10,7 @@ _LOGGER = logging.getLogger(__name__)
 
 
 def process_wolock(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
-    """Process woLock services data."""
+    """Support for lock and lock lite process data."""
     if mfr_data is None:
         return {}
 
@@ -19,6 +19,7 @@ def process_wolock(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool
         _LOGGER.debug("data: %s", data.hex())
 
     return {
+        "sequence_number": mfr_data[6],
         "battery": data[2] & 0b01111111 if data else None,
         "calibration": bool(mfr_data[7] & 0b10000000),
         "status": LockStatus((mfr_data[7] & 0b01110000) >> 4),
@@ -32,26 +33,53 @@ def process_wolock(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool
     }
 
 
-def process_wolock_pro(
-    data: bytes | None, mfr_data: bytes | None
-) -> dict[str, bool | int]:
-    _LOGGER.debug("mfr_data: %s", mfr_data.hex())
-    if data:
-        _LOGGER.debug("data: %s", data.hex())
+def parse_common_data(mfr_data: bytes | None) -> dict[str, bool | int]:
+    if mfr_data is None:
+        return {}
 
-    res = {
-        "battery": data[2] & 0b01111111 if data else None,
+    return {
+        "sequence_number": mfr_data[6],
         "calibration": bool(mfr_data[7] & 0b10000000),
-        "status": LockStatus((mfr_data[7] & 0b00111000) >> 3),
-        "door_open": bool(mfr_data[8] & 0b01100000),
-        # Double lock mode is not supported on Lock Pro
-        "update_from_secondary_lock": False,
-        "double_lock_mode": False,
+        "status": LockStatus((mfr_data[7] & 0b01111000) >> 4),
+        "update_from_secondary_lock": bool(mfr_data[8] & 0b11000000),
+        "door_open_from_secondary_lock": bool(mfr_data[8] & 0b00100000),
+        "door_open": bool(mfr_data[8] & 0b00010000),
+        "auto_lock_paused": bool(mfr_data[8] & 0b00001000),
+        "battery": mfr_data[9] & 0b01111111,
+        "double_lock_mode": bool(mfr_data[10] & 0b10000000),
+        "is_secondary_lock": bool(mfr_data[10] & 0b01000000),
+        "manual_unlock_linkage": bool(mfr_data[10] & 0b00100000),
         "unclosed_alarm": bool(mfr_data[11] & 0b10000000),
         "unlocked_alarm": bool(mfr_data[11] & 0b01000000),
-        "auto_lock_paused": bool(mfr_data[8] & 0b100000),
-        # Looks like night latch bit is not anymore in ADV
         "night_latch": False,
     }
-    _LOGGER.debug(res)
-    return res
+
+
+def process_wolock_pro(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int]:
+    """Support for lock pro process data."""
+    common_data = parse_common_data(mfr_data)
+    if not common_data:
+        return {}
+
+    lock_pro_data = {
+        "low_temperature_alarm": bool(mfr_data[11] & 0b00100000),
+        "left_battery_compartment_alarm": mfr_data[11] & 0b000000100,
+        "right_battery_compartment_alarm": mfr_data[11] & 0b000000010,
+    }
+    return common_data | lock_pro_data
+
+
+def process_lock2(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
+    """Support for lock2 process data."""
+    common_data = parse_common_data(mfr_data)
+    if not common_data:
+        return {}
+
+    lock2_data = {
+        "power_alarm": bool(mfr_data[11] & 0b00010000),
+        "battery_status": mfr_data[11] & 0b00000111,
+    }
+
+    return common_data | lock2_data

+ 2 - 0
switchbot/const/__init__.py

@@ -76,6 +76,8 @@ class SwitchbotModel(StrEnum):
     AIR_PURIFIER = "Air Purifier"
     AIR_PURIFIER_TABLE = "Air Purifier Table"
     HUB3 = "Hub3"
+    LOCK_ULTRA = "Lock Ultra"
+    LOCK_LITE = "Lock Lite"
 
 
 __all__ = [

+ 1 - 0
switchbot/const/lock.py

@@ -11,3 +11,4 @@ class LockStatus(Enum):
     LOCKING_STOP = 4  # LOCKING_BLOCKED
     UNLOCKING_STOP = 5  # UNLOCKING_BLOCKED
     NOT_FULLY_LOCKED = 6  # LATCH_LOCKED - Only EU lock type
+    HALF_LOCKED = 7  # Only Lock2 EU lock type

+ 16 - 3
switchbot/devices/lock.py

@@ -10,24 +10,32 @@ from bleak.backends.device import BLEDevice
 
 from ..const import SwitchbotModel
 from ..const.lock import LockStatus
-from .device import SwitchbotEncryptedDevice
+from .device import SwitchbotEncryptedDevice, SwitchbotSequenceDevice
 
 COMMAND_HEADER = "57"
 COMMAND_LOCK_INFO = {
     SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4f8101",
+    SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4f8101",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8102",
+    SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8102",
 }
 COMMAND_UNLOCK = {
     SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011080",
+    SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011080",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080",
+    SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080",
 }
 COMMAND_UNLOCK_WITHOUT_UNLATCH = {
     SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e010110a0",
+    SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e010110a0",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
+    SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0",
 }
 COMMAND_LOCK = {
     SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011000",
+    SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011000",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000",
+    SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000",
 }
 COMMAND_ENABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e01001e00008101"
 COMMAND_DISABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e00"
@@ -44,7 +52,7 @@ COMMAND_RESULT_EXPECTED_VALUES = {1, 6}
 # The return value of the command is 6 when the command is successful but the battery is low.
 
 
-class SwitchbotLock(SwitchbotEncryptedDevice):
+class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
     """Representation of a Switchbot Lock."""
 
     def __init__(
@@ -56,7 +64,12 @@ class SwitchbotLock(SwitchbotEncryptedDevice):
         model: SwitchbotModel = SwitchbotModel.LOCK,
         **kwargs: Any,
     ) -> None:
-        if model not in (SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO):
+        if model not in (
+            SwitchbotModel.LOCK,
+            SwitchbotModel.LOCK_PRO,
+            SwitchbotModel.LOCK_LITE,
+            SwitchbotModel.LOCK_ULTRA,
+        ):
             raise ValueError("initializing SwitchbotLock with a non-lock model")
         self._notifications_enabled: bool = False
         super().__init__(device, key_id, encryption_key, model, interface, **kwargs)

+ 3 - 3
tests/__init__.py

@@ -4,10 +4,10 @@ from switchbot import SwitchbotModel
 
 
 @dataclass
-class AirPurifierTestCase:
-    manufacturer_data: bytes
+class AdvTestCase:
+    manufacturer_data: bytes | None
     service_data: bytes
     data: dict
-    model: str
+    model: str | bytes
     modelFriendlyName: str
     modelName: SwitchbotModel

+ 309 - 250
tests/test_adv_parser.py

@@ -11,7 +11,7 @@ from switchbot.adv_parser import parse_advertisement_data
 from switchbot.const.lock import LockStatus
 from switchbot.models import SwitchBotAdvertisement
 
-from . import AirPurifierTestCase
+from . import AdvTestCase
 
 ADVERTISEMENT_DATA_DEFAULTS = {
     "local_name": "",
@@ -1288,248 +1288,6 @@ def test_motion_with_light_detected():
     )
 
 
-def test_parsing_lock_active():
-    """Test parsing lock with active data."""
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    adv_data = generate_advertisement_data(
-        manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00 "},
-        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"},
-        rssi=-67,
-    )
-    result = parse_advertisement_data(ble_device, adv_data)
-    assert result == SwitchBotAdvertisement(
-        address="aa:bb:cc:dd:ee:ff",
-        data={
-            "data": {
-                "auto_lock_paused": False,
-                "battery": 100,
-                "calibration": True,
-                "door_open": False,
-                "double_lock_mode": False,
-                "night_latch": False,
-                "status": LockStatus.LOCKED,
-                "unclosed_alarm": False,
-                "unlocked_alarm": False,
-                "update_from_secondary_lock": False,
-            },
-            "isEncrypted": False,
-            "model": "o",
-            "modelFriendlyName": "Lock",
-            "modelName": SwitchbotModel.LOCK,
-            "rawAdvData": b"o\x80d",
-        },
-        device=ble_device,
-        rssi=-67,
-        active=True,
-    )
-
-
-def test_parsing_lock_passive():
-    """Test parsing lock with active data."""
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    adv_data = generate_advertisement_data(
-        manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00 "}, rssi=-67
-    )
-    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK)
-    assert result == SwitchBotAdvertisement(
-        address="aa:bb:cc:dd:ee:ff",
-        data={
-            "data": {
-                "auto_lock_paused": False,
-                "battery": None,
-                "calibration": True,
-                "door_open": False,
-                "double_lock_mode": False,
-                "night_latch": False,
-                "status": LockStatus.LOCKED,
-                "unclosed_alarm": False,
-                "unlocked_alarm": False,
-                "update_from_secondary_lock": False,
-            },
-            "isEncrypted": False,
-            "model": "o",
-            "modelFriendlyName": "Lock",
-            "modelName": SwitchbotModel.LOCK,
-            "rawAdvData": None,
-        },
-        device=ble_device,
-        rssi=-67,
-        active=False,
-    )
-
-
-def test_parsing_lock_pro_active():
-    """Test parsing lock pro with active data."""
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    adv_data = generate_advertisement_data(
-        manufacturer_data={2409: b"\xc8\xf5,\xd9-V\x07\x82\x00d\x00\x00"},
-        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"$\x80d"},
-        rssi=-80,
-    )
-    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK_PRO)
-    assert result == SwitchBotAdvertisement(
-        address="aa:bb:cc:dd:ee:ff",
-        data={
-            "data": {
-                "battery": 100,
-                "calibration": True,
-                "status": LockStatus.LOCKED,
-                "update_from_secondary_lock": False,
-                "door_open": False,
-                "double_lock_mode": False,
-                "unclosed_alarm": False,
-                "unlocked_alarm": False,
-                "auto_lock_paused": False,
-                "night_latch": False,
-            },
-            "model": "$",
-            "isEncrypted": False,
-            "modelFriendlyName": "Lock Pro",
-            "modelName": SwitchbotModel.LOCK_PRO,
-            "rawAdvData": b"$\x80d",
-        },
-        device=ble_device,
-        rssi=-80,
-        active=True,
-    )
-
-
-def test_parsing_lock_pro_passive():
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    adv_data = generate_advertisement_data(
-        manufacturer_data={2409: bytes.fromhex("aabbccddeeff208200640000")}, rssi=-67
-    )
-    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK_PRO)
-    assert result == SwitchBotAdvertisement(
-        address="aa:bb:cc:dd:ee:ff",
-        data={
-            "data": {
-                "battery": None,
-                "calibration": True,
-                "status": LockStatus.LOCKED,
-                "update_from_secondary_lock": False,
-                "door_open": False,
-                "double_lock_mode": False,
-                "unclosed_alarm": False,
-                "unlocked_alarm": False,
-                "auto_lock_paused": False,
-                "night_latch": False,
-            },
-            "model": "$",
-            "isEncrypted": False,
-            "modelFriendlyName": "Lock Pro",
-            "modelName": SwitchbotModel.LOCK_PRO,
-            "rawAdvData": None,
-        },
-        device=ble_device,
-        rssi=-67,
-        active=False,
-    )
-
-
-def test_parsing_lock_pro_passive_nightlatch_disabled():
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    adv_data = generate_advertisement_data(
-        manufacturer_data={2409: bytes.fromhex("aabbccddeeff0a8200630000")}, rssi=-67
-    )
-    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK_PRO)
-    assert result == SwitchBotAdvertisement(
-        address="aa:bb:cc:dd:ee:ff",
-        data={
-            "data": {
-                "battery": None,
-                "calibration": True,
-                "status": LockStatus.LOCKED,
-                "update_from_secondary_lock": False,
-                "door_open": False,
-                "double_lock_mode": False,
-                "unclosed_alarm": False,
-                "unlocked_alarm": False,
-                "auto_lock_paused": False,
-                "night_latch": False,
-            },
-            "model": "$",
-            "isEncrypted": False,
-            "modelFriendlyName": "Lock Pro",
-            "modelName": SwitchbotModel.LOCK_PRO,
-            "rawAdvData": None,
-        },
-        device=ble_device,
-        rssi=-67,
-        active=False,
-    )
-
-
-def test_parsing_lock_active_old_firmware():
-    """Test parsing lock with active data. Old firmware."""
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    adv_data = generate_advertisement_data(
-        manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00"},
-        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"},
-        rssi=-67,
-    )
-    result = parse_advertisement_data(ble_device, adv_data)
-    assert result == SwitchBotAdvertisement(
-        address="aa:bb:cc:dd:ee:ff",
-        data={
-            "data": {
-                "auto_lock_paused": False,
-                "battery": 100,
-                "calibration": True,
-                "door_open": False,
-                "double_lock_mode": False,
-                "night_latch": False,
-                "status": LockStatus.LOCKED,
-                "unclosed_alarm": False,
-                "unlocked_alarm": False,
-                "update_from_secondary_lock": False,
-            },
-            "isEncrypted": False,
-            "model": "o",
-            "modelFriendlyName": "Lock",
-            "modelName": SwitchbotModel.LOCK,
-            "rawAdvData": b"o\x80d",
-        },
-        device=ble_device,
-        rssi=-67,
-        active=True,
-    )
-
-
-def test_parsing_lock_passive_old_firmware():
-    """Test parsing lock with active data. Old firmware."""
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    adv_data = generate_advertisement_data(
-        manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00"}, rssi=-67
-    )
-    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK)
-    assert result == SwitchBotAdvertisement(
-        address="aa:bb:cc:dd:ee:ff",
-        data={
-            "data": {
-                "auto_lock_paused": False,
-                "battery": None,
-                "calibration": True,
-                "door_open": False,
-                "double_lock_mode": False,
-                "night_latch": False,
-                "status": LockStatus.LOCKED,
-                "unclosed_alarm": False,
-                "unlocked_alarm": False,
-                "update_from_secondary_lock": False,
-            },
-            "isEncrypted": False,
-            "model": "o",
-            "modelFriendlyName": "Lock",
-            "modelName": SwitchbotModel.LOCK,
-            "rawAdvData": None,
-        },
-        device=ble_device,
-        rssi=-67,
-        active=False,
-    )
-
-
 def test_meter_pro_active() -> None:
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
     adv_data = generate_advertisement_data(
@@ -2651,7 +2409,7 @@ def test_s10_with_empty_data() -> None:
 @pytest.mark.parametrize(
     "test_case",
     [
-        AirPurifierTestCase(
+        AdvTestCase(
             b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00",
             b"7\x00\x00\x95-\x00",
             {
@@ -2669,7 +2427,7 @@ def test_s10_with_empty_data() -> None:
             "Air Purifier Table",
             SwitchbotModel.AIR_PURIFIER_TABLE,
         ),
-        AirPurifierTestCase(
+        AdvTestCase(
             b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00',
             b"*\x00\x00\x15\x04\x00",
             {
@@ -2687,7 +2445,7 @@ def test_s10_with_empty_data() -> None:
             "Air Purifier",
             SwitchbotModel.AIR_PURIFIER,
         ),
-        AirPurifierTestCase(
+        AdvTestCase(
             b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00",
             b"+\x00\x00\x15\x04\x00",
             {
@@ -2705,7 +2463,7 @@ def test_s10_with_empty_data() -> None:
             "Air Purifier",
             SwitchbotModel.AIR_PURIFIER,
         ),
-        AirPurifierTestCase(
+        AdvTestCase(
             b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00",
             b"8\x00\x00\x95-\x00",
             {
@@ -2723,7 +2481,7 @@ def test_s10_with_empty_data() -> None:
             "Air Purifier Table",
             SwitchbotModel.AIR_PURIFIER_TABLE,
         ),
-        AirPurifierTestCase(
+        AdvTestCase(
             b"\xcc\x8d\xa2\xa7\xc1\xae\x9e\xa1\x8c\x800\x01\x95\x00\x00",
             b"8\x00\x00\x95-\x00",
             {
@@ -2741,7 +2499,7 @@ def test_s10_with_empty_data() -> None:
             "Air Purifier Table",
             SwitchbotModel.AIR_PURIFIER_TABLE,
         ),
-        AirPurifierTestCase(
+        AdvTestCase(
             b"\xcc\x8d\xa2\xa7\xc1\xae\x9e\x05\x8c\x800\x01\x95\x00\x00",
             b"8\x00\x00\x95-\x00",
             {
@@ -2761,7 +2519,7 @@ def test_s10_with_empty_data() -> None:
         ),
     ],
 )
-def test_air_purifier_active(test_case: AirPurifierTestCase) -> None:
+def test_air_purifier_active(test_case: AdvTestCase) -> None:
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
     adv_data = generate_advertisement_data(
         manufacturer_data={2409: test_case.manufacturer_data},
@@ -2942,3 +2700,304 @@ def test_hub3_with_empty_data() -> None:
         rssi=-97,
         active=True,
     )
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [
+        AdvTestCase(
+            b"\xe9\xd5\x11\xb2kS\x17\x93\x08 ",
+            b"-\x80d",
+            {
+                "sequence_number": 23,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": False,
+                "double_lock_mode": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+            },
+            "-",
+            "Lock Lite",
+            SwitchbotModel.LOCK_LITE,
+        ),
+        AdvTestCase(
+            b"\xee\xf5\xe6\t\x8f\xe8\x11\x97\x08 ",
+            b"o\x80d",
+            {
+                "sequence_number": 17,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": True,
+                "double_lock_mode": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+            },
+            "o",
+            "Lock",
+            SwitchbotModel.LOCK,
+        ),
+        AdvTestCase(
+            b"\xf7a\x07H\xe6\xe8:\x8a\x00d\x00\x00",
+            b"$\x80d",
+            {
+                "sequence_number": 58,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.LOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": False,
+                "door_open_from_secondary_lock": False,
+                "double_lock_mode": False,
+                "is_secondary_lock": False,
+                "left_battery_compartment_alarm": 0,
+                "right_battery_compartment_alarm": 0,
+                "low_temperature_alarm": False,
+                "manual_unlock_linkage": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+            },
+            "$",
+            "Lock Pro",
+            SwitchbotModel.LOCK_PRO,
+        ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xb6j=%\x8204\x00\x04",
+            b"\x00\x804\x00\x10\xa5\xb8",
+            {
+                "sequence_number": 37,
+                "battery": 52,
+                "calibration": True,
+                "status": LockStatus.LOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": True,
+                "door_open_from_secondary_lock": True,
+                "double_lock_mode": False,
+                "is_secondary_lock": False,
+                "manual_unlock_linkage": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+                "power_alarm": False,
+                "battery_status": 4,
+            },
+            b"\x00\x10\xa5\xb8",
+            "Lock Ultra",
+            SwitchbotModel.LOCK_ULTRA,
+        ),
+    ],
+)
+def test_lock_active(test_case: AdvTestCase) -> None:
+    """Test lokc series 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"\xe9\xd5\x11\xb2kS\x17\x93\x08 ",
+            b"-\x80d",
+            {
+                "sequence_number": 23,
+                "battery": None,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": False,
+                "double_lock_mode": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+            },
+            "-",
+            "Lock Lite",
+            SwitchbotModel.LOCK_LITE,
+        ),
+        AdvTestCase(
+            b"\xee\xf5\xe6\t\x8f\xe8\x11\x97\x08 ",
+            b"o\x80d",
+            {
+                "sequence_number": 17,
+                "battery": None,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": True,
+                "double_lock_mode": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+            },
+            "o",
+            "Lock",
+            SwitchbotModel.LOCK,
+        ),
+        AdvTestCase(
+            b"\xf7a\x07H\xe6\xe8:\x8a\x00d\x00\x00",
+            b"$\x80d",
+            {
+                "sequence_number": 58,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.LOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": False,
+                "door_open_from_secondary_lock": False,
+                "double_lock_mode": False,
+                "is_secondary_lock": False,
+                "left_battery_compartment_alarm": 0,
+                "right_battery_compartment_alarm": 0,
+                "low_temperature_alarm": False,
+                "manual_unlock_linkage": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+            },
+            "$",
+            "Lock Pro",
+            SwitchbotModel.LOCK_PRO,
+        ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xb6j=%\x8204\x00\x04",
+            b"\x00\x804\x00\x10\xa5\xb8",
+            {
+                "sequence_number": 37,
+                "battery": 52,
+                "calibration": True,
+                "status": LockStatus.LOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": True,
+                "door_open_from_secondary_lock": True,
+                "double_lock_mode": False,
+                "is_secondary_lock": False,
+                "manual_unlock_linkage": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+                "power_alarm": False,
+                "battery_status": 4,
+            },
+            b"\x00\x10\xa5\xb8",
+            "Lock Ultra",
+            SwitchbotModel.LOCK_ULTRA,
+        ),
+    ],
+)
+def test_lock_passive(test_case: AdvTestCase) -> None:
+    """Test lokc series 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,
+    )
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [
+        AdvTestCase(
+            None,
+            b"-\x80d",
+            {},
+            "-",
+            "Lock Lite",
+            SwitchbotModel.LOCK_LITE,
+        ),
+        AdvTestCase(
+            None,
+            b"o\x80d",
+            {},
+            "o",
+            "Lock",
+            SwitchbotModel.LOCK,
+        ),
+        AdvTestCase(
+            None,
+            b"$\x80d",
+            {},
+            "$",
+            "Lock Pro",
+            SwitchbotModel.LOCK_PRO,
+        ),
+        AdvTestCase(
+            None,
+            b"\x00\x804\x00\x10\xa5\xb8",
+            {},
+            b"\x00\x10\xa5\xb8",
+            "Lock Ultra",
+            SwitchbotModel.LOCK_ULTRA,
+        ),
+    ],
+)
+def test_lock_with_empty_data(test_case: AdvTestCase) -> None:
+    """Test lokc series with empty 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, test_case.modelName)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": test_case.service_data,
+            "data": {},
+            "isEncrypted": False,
+            "model": test_case.model,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )

+ 42 - 0
tests/test_lock.py

@@ -0,0 +1,42 @@
+import pytest
+
+from switchbot import SwitchbotModel
+from switchbot.devices import lock
+
+from .test_adv_parser import generate_ble_device
+
+
+def create_device_for_command_testing(model: str):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    return lock.SwitchbotLock(
+        ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model
+    )
+
+
+@pytest.mark.parametrize(
+    "model",
+    [
+        SwitchbotModel.LOCK,
+        SwitchbotModel.LOCK_LITE,
+        SwitchbotModel.LOCK_PRO,
+        SwitchbotModel.LOCK_ULTRA,
+    ],
+)
+def test_lock_init(model: str):
+    """Test the initialization of the lock device."""
+    device = create_device_for_command_testing(model)
+    assert device._model == model
+
+
+@pytest.mark.parametrize(
+    "model",
+    [
+        SwitchbotModel.AIR_PURIFIER,
+    ],
+)
+def test_lock_init_with_invalid_model(model: str):
+    """Test that initializing with an invalid model raises ValueError."""
+    with pytest.raises(
+        ValueError, match="initializing SwitchbotLock with a non-lock model"
+    ):
+        create_device_for_command_testing(model)