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 12 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
2 changes: 2 additions & 0 deletions switchbot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .adv_parser import SwitchbotSupportedType, parse_advertisement_data
from .const import (
LockModel,
LockStatus,
SwitchbotAccountConnectionError,
SwitchbotApiError,
Expand Down Expand Up @@ -40,6 +41,7 @@
"SwitchbotApiError",
"SwitchbotAuthenticationError",
"ColorMode",
"LockModel",
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"LockStatus",
"SwitchbotBaseLight",
"SwitchbotBulb",
Expand Down
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
6 changes: 6 additions & 0 deletions switchbot/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,16 @@ class SwitchbotModel(StrEnum):
COLOR_BULB = "WoBulb"
CEILING_LIGHT = "WoCeiling"
LOCK = "WoLock"
LOCK_PRO = "WoLockPro"
BLIND_TILT = "WoBlindTilt"
HUB2 = "WoHub2"


class LockModel(StrEnum):
LOCK = "Lock"
LOCK_PRO = "LockPro"


class LockStatus(Enum):
LOCKED = 0
UNLOCKED = 1
Expand Down
41 changes: 31 additions & 10 deletions switchbot/devices/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import logging
import time
from enum import StrEnum
from typing import Any

import aiohttp
Expand All @@ -12,6 +13,7 @@

from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID
from ..const import (
LockModel,
LockStatus,
SwitchbotAccountConnectionError,
SwitchbotApiError,
Expand All @@ -21,10 +23,22 @@

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 = {
LockModel.LOCK: f"{COMMAND_HEADER}0f4f8101",
LockModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8102",
}
COMMAND_UNLOCK = {
LockModel.LOCK: f"{COMMAND_HEADER}0f4e01011080",
LockModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080",
}
COMMAND_UNLOCK_WITHOUT_UNLATCH = {
LockModel.LOCK: f"{COMMAND_HEADER}0f4e010110a0",
LockModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
}
COMMAND_LOCK = {
LockModel.LOCK: f"{COMMAND_HEADER}0f4e01011000",
LockModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000",
}
COMMAND_ENABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e01001e00008101"
COMMAND_DISABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e00"

Expand Down Expand Up @@ -64,15 +78,20 @@ def __init__(
self._key_id = key_id
self._encryption_key = bytearray.fromhex(encryption_key)
self._notifications_enabled: bool = False
self._model: LockModel = kwargs.get("model") or LockModel.LOCK
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,
**kwargs: Any,
bdraco marked this conversation as resolved.
Show resolved Hide resolved
) -> bool:
model = kwargs.get("model") or LockModel.LOCK
try:
lock = SwitchbotLock(
device=device, key_id=key_id, encryption_key=encryption_key
device, model, key_id=key_id, encryption_key=encryption_key
)
except ValueError:
return False
Expand Down Expand Up @@ -183,19 +202,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 +294,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