diff --git a/switchbot/adv_parser.py b/switchbot/adv_parser.py index d8db70c..2dbbce0 100644 --- a/switchbot/adv_parser.py +++ b/switchbot/adv_parser.py @@ -150,6 +150,12 @@ class SwitchbotSupportedType(TypedDict): "func": process_wolock, "manufacturer_id": 2409, }, + "$": { + "modelName": SwitchbotModel.LOCK_PRO, + "modelFriendlyName": "Lock Pro", + "func": process_wolock, + "manufacturer_id": 2409, + }, "x": { "modelName": SwitchbotModel.BLIND_TILT, "modelFriendlyName": "Blind Tilt", diff --git a/switchbot/const.py b/switchbot/const.py index e4438c3..ad7d1c3 100644 --- a/switchbot/const.py +++ b/switchbot/const.py @@ -47,6 +47,7 @@ class SwitchbotModel(StrEnum): COLOR_BULB = "WoBulb" CEILING_LIGHT = "WoCeiling" LOCK = "WoLock" + LOCK_PRO = "WoLockPro" BLIND_TILT = "WoBlindTilt" HUB2 = "WoHub2" diff --git a/switchbot/devices/lock.py b/switchbot/devices/lock.py index a45fb14..6742123 100644 --- a/switchbot/devices/lock.py +++ b/switchbot/devices/lock.py @@ -16,15 +16,28 @@ SwitchbotAccountConnectionError, SwitchbotApiError, SwitchbotAuthenticationError, + SwitchbotModel, ) from .device import SwitchbotDevice, SwitchbotOperationError COMMAND_HEADER = "57" COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103" -COMMAND_LOCK_INFO = f"{COMMAND_HEADER}0f4f8101" -COMMAND_UNLOCK = f"{COMMAND_HEADER}0f4e01011080" -COMMAND_UNLOCK_WITHOUT_UNLATCH = f"{COMMAND_HEADER}0f4e010110a0" -COMMAND_LOCK = f"{COMMAND_HEADER}0f4e01011000" +COMMAND_LOCK_INFO = { + SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4f8101", + SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8102", +} +COMMAND_UNLOCK = { + SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011080", + SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080", +} +COMMAND_UNLOCK_WITHOUT_UNLATCH = { + SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e010110a0", + SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0", +} +COMMAND_LOCK = { + SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011000", + SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000", +} COMMAND_ENABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e01001e00008101" COMMAND_DISABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e00" @@ -49,6 +62,7 @@ def __init__( key_id: str, encryption_key: str, interface: int = 0, + model: SwitchbotModel = SwitchbotModel.LOCK, **kwargs: Any, ) -> None: if len(key_id) == 0: @@ -59,20 +73,27 @@ def __init__( raise ValueError("encryption_key is missing") elif len(encryption_key) != 32: raise ValueError("encryption_key is invalid") + if model not in (SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO): + raise ValueError("initializing SwitchbotLock with a non-lock model") self._iv = None self._cipher = None self._key_id = key_id self._encryption_key = bytearray.fromhex(encryption_key) self._notifications_enabled: bool = False + self._model: SwitchbotModel = model super().__init__(device, None, interface, **kwargs) @staticmethod async def verify_encryption_key( - device: BLEDevice, key_id: str, encryption_key: str + device: BLEDevice, + key_id: str, + encryption_key: str, + model: SwitchbotModel = SwitchbotModel.LOCK, + **kwargs: Any, ) -> bool: try: lock = SwitchbotLock( - device=device, key_id=key_id, encryption_key=encryption_key + device, key_id=key_id, encryption_key=encryption_key, model=model ) except ValueError: return False @@ -183,19 +204,19 @@ async def async_retrieve_encryption_key( async def lock(self) -> bool: """Send lock command.""" return await self._lock_unlock( - COMMAND_LOCK, {LockStatus.LOCKED, LockStatus.LOCKING} + COMMAND_LOCK[self._model], {LockStatus.LOCKED, LockStatus.LOCKING} ) async def unlock(self) -> bool: """Send unlock command. If unlatch feature is enabled in EU firmware, also unlatches door""" return await self._lock_unlock( - COMMAND_UNLOCK, {LockStatus.UNLOCKED, LockStatus.UNLOCKING} + COMMAND_UNLOCK[self._model], {LockStatus.UNLOCKED, LockStatus.UNLOCKING} ) async def unlock_without_unlatch(self) -> bool: """Send unlock command. This command will not unlatch the door.""" return await self._lock_unlock( - COMMAND_UNLOCK_WITHOUT_UNLATCH, + COMMAND_UNLOCK_WITHOUT_UNLATCH[self._model], {LockStatus.UNLOCKED, LockStatus.UNLOCKING, LockStatus.NOT_FULLY_LOCKED}, ) @@ -275,7 +296,9 @@ def is_night_latch_enabled(self) -> bool: async def _get_lock_info(self) -> bytes | None: """Return lock info of device.""" - _data = await self._send_command(key=COMMAND_LOCK_INFO, retry=self._retry_count) + _data = await self._send_command( + key=COMMAND_LOCK_INFO[self._model], retry=self._retry_count + ) if not self._check_command_result(_data, 0, COMMAND_RESULT_EXPECTED_VALUES): _LOGGER.error("Unsuccessful, please try again") diff --git a/switchbot/discovery.py b/switchbot/discovery.py index 5c74aba..546faf0 100644 --- a/switchbot/discovery.py +++ b/switchbot/discovery.py @@ -120,7 +120,9 @@ async def get_contactsensors(self) -> dict[str, SwitchBotAdvertisement]: async def get_locks(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoLock/Locks devices with services data.""" - return await self._get_devices_by_model("o") + locks = await self._get_devices_by_model("o") + lock_pros = await self._get_devices_by_model("$") + return {**locks, **lock_pros} async def get_device_data( self, address: str diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index 2c79dea..684aa43 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -1390,6 +1390,75 @@ def test_parsing_lock_passive(): ) +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_active_old_firmware(): """Test parsing lock with active data. Old firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")