Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic support for lock pro #241

Merged
merged 16 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions switchbot/adv_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions switchbot/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class SwitchbotModel(StrEnum):
COLOR_BULB = "WoBulb"
CEILING_LIGHT = "WoCeiling"
LOCK = "WoLock"
LOCK_PRO = "WoLockPro"
BLIND_TILT = "WoBlindTilt"
HUB2 = "WoHub2"

Expand Down
43 changes: 33 additions & 10 deletions switchbot/devices/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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:
Expand All @@ -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,
bdraco marked this conversation as resolved.
Show resolved Hide resolved
) -> 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
Expand Down Expand Up @@ -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},
)

Expand Down Expand Up @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion switchbot/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions tests/test_adv_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading