Skip to content

Commit d73bace

Browse files
mayerwinclaude
andcommitted
feat(lock): add Quick Key support for Lock Ultra
Expose the Lock Ultra "Quick Key" setting. All three of its settings live in a single config byte: get_quick_key() reads and parses it, set_quick_key() does a masked write of any subset of enabled / single-vs-double press / function (Lock & Unlock, Unlock Only, Lock Only). Adds a QuickKeyFunction enum and unit tests. Scoped to LOCK_ULTRA (other lock models are untested). Tested on a SwitchBot Lock Ultra (firmware V2.6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6029aae commit d73bace

3 files changed

Lines changed: 169 additions & 2 deletions

File tree

switchbot/const/lock.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,14 @@ class LockStatus(Enum):
1212
UNLOCKING_STOP = 5 # UNLOCKING_BLOCKED
1313
NOT_FULLY_LOCKED = 6 # LATCH_LOCKED - Only EU lock type
1414
HALF_LOCKED = 7 # Only Lock2 EU lock type
15+
16+
17+
class QuickKeyFunction(Enum):
18+
"""Action of the Lock Ultra Quick Key.
19+
20+
Value is the 2-bit function field of the Quick Key config byte. Lock Ultra.
21+
"""
22+
23+
LOCK_AND_UNLOCK = 0b10
24+
UNLOCK_ONLY = 0b01
25+
LOCK_ONLY = 0b00

switchbot/devices/lock.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from bleak.backends.device import BLEDevice
1010

1111
from ..const import SwitchbotModel
12-
from ..const.lock import LockStatus
12+
from ..const.lock import LockStatus, QuickKeyFunction
1313
from .device import (
1414
SwitchbotEncryptedDevice,
1515
SwitchbotOperationError,
@@ -57,6 +57,22 @@
5757
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000008",
5858
}
5959

60+
# Quick Key — a Lock Ultra setting. All three of
61+
# its settings (enabled / single-vs-double press / function) live in a single config
62+
# byte: read it with 0x4f, masked-write it with 0x4e. Lock Ultra only (untested on
63+
# other lock models).
64+
COMMAND_GET_QUICK_KEY = {
65+
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f0401",
66+
}
67+
# Append "<mask><value>ff" (each one hex byte) for a masked write.
68+
COMMAND_SET_QUICK_KEY_PREFIX = {
69+
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e040100",
70+
}
71+
# Quick Key config-byte layout (the high bits 0xC0 are constant status flags).
72+
QUICK_KEY_ENABLED_BIT = 0x08
73+
QUICK_KEY_DOUBLE_PRESS_BIT = 0x04
74+
QUICK_KEY_FUNCTION_MASK = 0x03
75+
6076
COMMAND_ENABLE_NOTIFICATIONS = {
6177
SwitchbotModel.LOCK: f"{COMMAND_HEADER}0e01001e00008101",
6278
SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
@@ -141,6 +157,71 @@ async def half_lock(self) -> bool:
141157
{LockStatus.HALF_LOCKED, LockStatus.LOCKING},
142158
)
143159

160+
async def get_quick_key(self) -> dict[str, Any] | None:
161+
"""Return the Quick Key settings (Lock Ultra only).
162+
163+
Returns
164+
``{"enabled": bool, "double_press": bool, "function": QuickKeyFunction}``,
165+
or ``None`` if it can't be read.
166+
"""
167+
if self._model not in COMMAND_GET_QUICK_KEY:
168+
raise SwitchbotOperationError(f"Quick Key is not supported on {self._model}")
169+
result = await self._send_command(
170+
key=COMMAND_GET_QUICK_KEY[self._model], retry=self._retry_count
171+
)
172+
if not self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES):
173+
_LOGGER.error("Failed to read Quick Key settings")
174+
return None
175+
if len(result) < 2:
176+
_LOGGER.error("Invalid Quick Key response: %s", result)
177+
return None
178+
return self._parse_quick_key(result[1])
179+
180+
@staticmethod
181+
def _parse_quick_key(cfg: int) -> dict[str, Any]:
182+
"""Parse the Quick Key config byte."""
183+
return {
184+
"enabled": bool(cfg & QUICK_KEY_ENABLED_BIT),
185+
"double_press": bool(cfg & QUICK_KEY_DOUBLE_PRESS_BIT),
186+
"function": QuickKeyFunction(cfg & QUICK_KEY_FUNCTION_MASK),
187+
}
188+
189+
async def set_quick_key(
190+
self,
191+
*,
192+
enabled: bool | None = None,
193+
double_press: bool | None = None,
194+
function: QuickKeyFunction | None = None,
195+
) -> bool:
196+
"""Update one or more Quick Key settings (Lock Ultra only).
197+
198+
Only the fields you pass are changed (a masked write); the others keep their
199+
current value. Returns ``True`` if the lock acknowledges with the requested
200+
bits set.
201+
"""
202+
if self._model not in COMMAND_SET_QUICK_KEY_PREFIX:
203+
raise SwitchbotOperationError(f"Quick Key is not supported on {self._model}")
204+
mask = 0
205+
value = 0
206+
if enabled is not None:
207+
mask |= QUICK_KEY_ENABLED_BIT
208+
value |= QUICK_KEY_ENABLED_BIT if enabled else 0
209+
if double_press is not None:
210+
mask |= QUICK_KEY_DOUBLE_PRESS_BIT
211+
value |= QUICK_KEY_DOUBLE_PRESS_BIT if double_press else 0
212+
if function is not None:
213+
mask |= QUICK_KEY_FUNCTION_MASK
214+
value |= function.value
215+
if not mask:
216+
raise ValueError("set_quick_key requires at least one setting to change")
217+
command = f"{COMMAND_SET_QUICK_KEY_PREFIX[self._model]}{mask:02x}{value:02x}ff"
218+
result = await self._send_command(key=command, retry=self._retry_count)
219+
if not self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES):
220+
_LOGGER.error("Failed to set Quick Key settings")
221+
return False
222+
# The lock echoes the resulting config byte; confirm our bits stuck.
223+
return len(result) >= 2 and (result[1] & mask) == (value & mask)
224+
144225
def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
145226
"""Parse basic data from lock."""
146227
return {

tests/test_lock.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pytest
55

66
from switchbot import SwitchbotModel
7-
from switchbot.const.lock import LockStatus
7+
from switchbot.const.lock import LockStatus, QuickKeyFunction
88
from switchbot.devices import lock
99
from switchbot.devices.device import SwitchbotOperationError
1010

@@ -862,3 +862,78 @@ async def test_lock_with_invalid_basic_data(model: str):
862862
):
863863
result = await device.lock()
864864
assert result is True
865+
866+
867+
@pytest.mark.asyncio
868+
async def test_get_quick_key():
869+
"""Reading the Quick Key parses the config byte (Lock Ultra)."""
870+
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
871+
# 0xca = enabled, single press, Lock & Unlock
872+
with patch.object(
873+
device, "_send_command", return_value=b"\x01\xca\x00\x00\x00\x80"
874+
) as mock_send_command:
875+
result = await device.get_quick_key()
876+
mock_send_command.assert_any_call(
877+
key=lock.COMMAND_GET_QUICK_KEY[SwitchbotModel.LOCK_ULTRA],
878+
retry=device._retry_count,
879+
)
880+
assert result == {
881+
"enabled": True,
882+
"double_press": False,
883+
"function": QuickKeyFunction.LOCK_AND_UNLOCK,
884+
}
885+
886+
887+
@pytest.mark.asyncio
888+
async def test_get_quick_key_failure():
889+
"""A non-success status returns None."""
890+
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
891+
with patch.object(device, "_send_command", return_value=b"\x00"):
892+
assert await device.get_quick_key() is None
893+
894+
895+
@pytest.mark.asyncio
896+
async def test_set_quick_key_masked_write():
897+
"""Setting only the function sends a masked write of just those bits."""
898+
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
899+
with patch.object(
900+
device, "_send_command", return_value=b"\x01\xc9" # echo 0xc9 = Unlock Only
901+
) as mock_send_command:
902+
result = await device.set_quick_key(function=QuickKeyFunction.UNLOCK_ONLY)
903+
prefix = lock.COMMAND_SET_QUICK_KEY_PREFIX[SwitchbotModel.LOCK_ULTRA]
904+
mock_send_command.assert_any_call(key=f"{prefix}0301ff", retry=device._retry_count)
905+
assert result is True
906+
907+
908+
@pytest.mark.asyncio
909+
async def test_set_quick_key_multiple_fields():
910+
"""Multiple fields combine into one mask/value pair."""
911+
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
912+
with patch.object(
913+
device, "_send_command", return_value=b"\x01\xce"
914+
) as mock_send_command:
915+
result = await device.set_quick_key(
916+
enabled=True, double_press=True, function=QuickKeyFunction.LOCK_AND_UNLOCK
917+
)
918+
prefix = lock.COMMAND_SET_QUICK_KEY_PREFIX[SwitchbotModel.LOCK_ULTRA]
919+
# mask = 0x08|0x04|0x03 = 0x0f ; value = 0x08|0x04|0x02 = 0x0e
920+
mock_send_command.assert_any_call(key=f"{prefix}0f0eff", retry=device._retry_count)
921+
assert result is True
922+
923+
924+
@pytest.mark.asyncio
925+
async def test_set_quick_key_requires_a_field():
926+
"""Calling with nothing to change raises ValueError."""
927+
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
928+
with pytest.raises(ValueError, match="at least one setting"):
929+
await device.set_quick_key()
930+
931+
932+
@pytest.mark.asyncio
933+
async def test_quick_key_unsupported_model():
934+
"""Quick Key is Lock Ultra only; other models raise."""
935+
device = create_device_for_command_testing(SwitchbotModel.LOCK)
936+
with pytest.raises(SwitchbotOperationError):
937+
await device.get_quick_key()
938+
with pytest.raises(SwitchbotOperationError):
939+
await device.set_quick_key(enabled=True)

0 commit comments

Comments
 (0)