Skip to content
Open
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
2 changes: 2 additions & 0 deletions switchbot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
HumidifierWaterLevel,
LockStatus,
NightLightState,
QuickKeyFunction,
SmartThermostatRadiatorMode,
StandingFanMode,
StripLightColorMode,
Expand Down Expand Up @@ -108,6 +109,7 @@
"HumidifierWaterLevel",
"LockStatus",
"NightLightState",
"QuickKeyFunction",
"SmartThermostatRadiatorMode",
"StandingFanMode",
"StripLightColorMode",
Expand Down
3 changes: 2 additions & 1 deletion switchbot/const/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
)

# Preserve old LockStatus export for backwards compatibility
from .lock import LockStatus
from .lock import LockStatus, QuickKeyFunction

DEFAULT_RETRY_COUNT = 3
DEFAULT_RETRY_TIMEOUT = 1
Expand Down Expand Up @@ -149,6 +149,7 @@ class SwitchbotModel(StrEnum):
"HumidifierWaterLevel",
"LockStatus",
"NightLightState",
"QuickKeyFunction",
"SmartThermostatRadiatorMode",
"StandingFanMode",
"StripLightColorMode",
Expand Down
12 changes: 12 additions & 0 deletions switchbot/const/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,15 @@ class LockStatus(Enum):
UNLOCKING_STOP = 5 # UNLOCKING_BLOCKED
NOT_FULLY_LOCKED = 6 # LATCH_LOCKED - Only EU lock type
HALF_LOCKED = 7 # Only Lock2 EU lock type


class QuickKeyFunction(Enum):
"""
Action of the Lock Ultra Quick Key.

Value is the 2-bit function field of the Quick Key config byte. Lock Ultra.
"""

LOCK_AND_UNLOCK = 0b10
UNLOCK_ONLY = 0b01
LOCK_ONLY = 0b00
126 changes: 125 additions & 1 deletion switchbot/devices/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from bleak.backends.device import BLEDevice

from ..const import SwitchbotModel
from ..const.lock import LockStatus
from ..const.lock import LockStatus, QuickKeyFunction
from .device import (
SwitchbotEncryptedDevice,
SwitchbotOperationError,
Expand Down Expand Up @@ -61,6 +61,28 @@
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000008",
}

# Quick Key, a Lock Ultra setting. All three of
# its settings (enabled / single-vs-double press / function) live in a single config
# byte: read it with 0x4f, masked-write it with 0x4e. Lock Ultra only (untested on
# other lock models).
COMMAND_GET_QUICK_KEY = {
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f0401",
}
# Append "<mask><value>ff" (each one hex byte) for a masked write.
COMMAND_SET_QUICK_KEY_PREFIX = {
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e040100",
}
# Quick Key config-byte layout. Only the low nibble carries Quick Key settings.
# Bits 7-6 are undecoded status flags and are NOT constant across locks: one Lock
# Ultra reports both set (0xCx), another reports only bit 7 (0x8x) with the same low
# nibble and identical behaviour. Nothing here validates them, and the raw byte is
# returned so a caller can surface it when a lock behaves unexpectedly.
QUICK_KEY_ENABLED_BIT = 0x08
QUICK_KEY_DOUBLE_PRESS_BIT = 0x04
QUICK_KEY_FUNCTION_MASK = 0x03
# The 2-bit function field has 4 possible values but only 3 are defined.
QUICK_KEY_FUNCTION_VALUES = frozenset(f.value for f in QuickKeyFunction)

COMMAND_ENABLE_NOTIFICATIONS = {
SwitchbotModel.LOCK: f"{COMMAND_HEADER}0e01001e00008101",
SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
Expand Down Expand Up @@ -147,6 +169,108 @@ async def half_lock(self) -> bool:
{LockStatus.HALF_LOCKED, LockStatus.LOCKING},
)

async def get_quick_key(self) -> dict[str, Any] | None:
"""
Return the Quick Key settings (Lock Ultra only).

Returns
-------
``{"raw": int, "enabled": bool, "double_press": bool,
"function": QuickKeyFunction | None}``, or ``None`` if it can't be read.

``raw`` is the config byte exactly as the lock reported it. ``function`` is
``None`` if the lock reports the one undefined value of the 2-bit function
field; the other fields are still valid in that case.

"""
if self._model not in COMMAND_GET_QUICK_KEY:
raise SwitchbotOperationError(
f"Quick Key is not supported on {self._model}"
)
result = await self._send_command(
key=COMMAND_GET_QUICK_KEY[self._model], retry=self._retry_count
)
if not self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES):
_LOGGER.error("Failed to read Quick Key settings: %s", result)
return None
if len(result) < 2:
_LOGGER.error("Invalid Quick Key response: %s", result)
return None
return self._parse_quick_key(result[1])

@staticmethod
def _parse_quick_key(cfg: int) -> dict[str, Any]:
"""Parse the Quick Key config byte."""
func_bits = cfg & QUICK_KEY_FUNCTION_MASK
if func_bits in QUICK_KEY_FUNCTION_VALUES:
function = QuickKeyFunction(func_bits)
else:
# The 2-bit field has one undefined value. Keep the enable and trigger
# bits, which are unambiguous, rather than discarding a byte that is
# mostly readable.
_LOGGER.warning(
"Unknown Quick Key function value: %#04x (config byte %#04x)",
func_bits,
cfg,
)
function = None
return {
"raw": cfg,
"enabled": bool(cfg & QUICK_KEY_ENABLED_BIT),
"double_press": bool(cfg & QUICK_KEY_DOUBLE_PRESS_BIT),
"function": function,
}

async def set_quick_key(
self,
*,
enabled: bool | None = None,
double_press: bool | None = None,
function: QuickKeyFunction | None = None,
) -> bool:
"""
Update one or more Quick Key settings (Lock Ultra only).

Only the fields you pass are changed (a masked write); the others keep their
current value. Returns ``True`` if the lock acknowledges with the requested
bits set.
"""
if self._model not in COMMAND_SET_QUICK_KEY_PREFIX:
raise SwitchbotOperationError(
f"Quick Key is not supported on {self._model}"
)
mask = 0
value = 0
if enabled is not None:
mask |= QUICK_KEY_ENABLED_BIT
value |= QUICK_KEY_ENABLED_BIT if enabled else 0
if double_press is not None:
mask |= QUICK_KEY_DOUBLE_PRESS_BIT
value |= QUICK_KEY_DOUBLE_PRESS_BIT if double_press else 0
if function is not None:
mask |= QUICK_KEY_FUNCTION_MASK
value |= function.value
if not mask:
raise ValueError("set_quick_key requires at least one setting to change")
command = f"{COMMAND_SET_QUICK_KEY_PREFIX[self._model]}{mask:02x}{value:02x}ff"
result = await self._send_command(key=command, retry=self._retry_count)
if not self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES):
_LOGGER.error("Failed to set Quick Key settings: %s", result)
return False
# The lock echoes the resulting config byte; confirm our bits stuck. A
# truncated echo and a refused bit are different problems, so say which.
if len(result) < 2:
_LOGGER.error("Invalid Quick Key write response: %s", result)
return False
if (result[1] & mask) != (value & mask):
_LOGGER.error(
"Quick Key write not applied: requested %#04x, lock reported %#04x",
value & mask,
result[1] & mask,
)
return False
return True

def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
"""Parse basic data from lock."""
if self._model is SwitchbotModel.LOCK_ULTRA_MAX:
Expand Down
144 changes: 143 additions & 1 deletion tests/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest

from switchbot import SwitchbotModel
from switchbot.const.lock import LockStatus
from switchbot.const.lock import LockStatus, QuickKeyFunction
from switchbot.devices import lock
from switchbot.devices.device import SwitchbotOperationError

Expand Down Expand Up @@ -891,3 +891,145 @@ async def test_lock_with_invalid_basic_data(model: str):
):
result = await device.lock()
assert result is True


@pytest.mark.asyncio
async def test_get_quick_key():
"""Reading the Quick Key parses the config byte (Lock Ultra)."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
# 0xca = enabled, single press, Lock & Unlock
with patch.object(
device, "_send_command", return_value=b"\x01\xca\x00\x00\x00\x80"
) as mock_send_command:
result = await device.get_quick_key()
mock_send_command.assert_any_call(
key=lock.COMMAND_GET_QUICK_KEY[SwitchbotModel.LOCK_ULTRA],
retry=device._retry_count,
)
assert result == {
"raw": 0xCA,
"enabled": True,
"double_press": False,
"function": QuickKeyFunction.LOCK_AND_UNLOCK,
}


@pytest.mark.asyncio
async def test_get_quick_key_failure():
"""A non-success status returns None."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
with patch.object(device, "_send_command", return_value=b"\x00"):
assert await device.get_quick_key() is None


@pytest.mark.asyncio
async def test_set_quick_key_masked_write():
"""Setting only the function sends a masked write of just those bits."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
with patch.object(
device,
"_send_command",
return_value=b"\x01\xc9", # echo 0xc9 = Unlock Only
) as mock_send_command:
result = await device.set_quick_key(function=QuickKeyFunction.UNLOCK_ONLY)
prefix = lock.COMMAND_SET_QUICK_KEY_PREFIX[SwitchbotModel.LOCK_ULTRA]
mock_send_command.assert_any_call(key=f"{prefix}0301ff", retry=device._retry_count)
assert result is True


@pytest.mark.asyncio
async def test_set_quick_key_multiple_fields():
"""Multiple fields combine into one mask/value pair."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
with patch.object(
device, "_send_command", return_value=b"\x01\xce"
) as mock_send_command:
result = await device.set_quick_key(
enabled=True, double_press=True, function=QuickKeyFunction.LOCK_AND_UNLOCK
)
prefix = lock.COMMAND_SET_QUICK_KEY_PREFIX[SwitchbotModel.LOCK_ULTRA]
# mask = 0x08|0x04|0x03 = 0x0f ; value = 0x08|0x04|0x02 = 0x0e
mock_send_command.assert_any_call(key=f"{prefix}0f0eff", retry=device._retry_count)
assert result is True


@pytest.mark.asyncio
async def test_set_quick_key_requires_a_field():
"""Calling with nothing to change raises ValueError."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
with pytest.raises(ValueError, match="at least one setting"):
await device.set_quick_key()


@pytest.mark.asyncio
async def test_quick_key_unsupported_model():
"""Quick Key is Lock Ultra only; other models raise."""
device = create_device_for_command_testing(SwitchbotModel.LOCK)
with pytest.raises(SwitchbotOperationError):
await device.get_quick_key()
with pytest.raises(SwitchbotOperationError):
await device.set_quick_key(enabled=True)


@pytest.mark.asyncio
async def test_get_quick_key_short_response():
"""A success status but a truncated payload returns None."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
with patch.object(device, "_send_command", return_value=b"\x01"):
assert await device.get_quick_key() is None


@pytest.mark.asyncio
async def test_get_quick_key_unknown_function():
"""An undefined 2-bit function value keeps the bits that are unambiguous."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
# 0xcb = enabled, single press, function bits 0b11 (undefined)
with patch.object(device, "_send_command", return_value=b"\x01\xcb"):
assert await device.get_quick_key() == {
"raw": 0xCB,
"enabled": True,
"double_press": False,
"function": None,
}


@pytest.mark.asyncio
async def test_get_quick_key_high_bits_are_not_validated():
"""A lock reporting only bit 7 parses the same as one reporting bits 7-6."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
# 0x8a has the same low nibble as the 0xca above, with bit 6 clear. Both were
# reported by real Lock Ultras; the high bits are status flags, not settings.
with patch.object(
device, "_send_command", return_value=b"\x01\x8a\x00\x00\x00\x00"
):
assert await device.get_quick_key() == {
"raw": 0x8A,
"enabled": True,
"double_press": False,
"function": QuickKeyFunction.LOCK_AND_UNLOCK,
}


@pytest.mark.asyncio
async def test_set_quick_key_short_echo():
"""A success status with a truncated echo returns False."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
with patch.object(device, "_send_command", return_value=b"\x01"):
assert await device.set_quick_key(enabled=True) is False


@pytest.mark.asyncio
async def test_set_quick_key_echo_mismatch():
"""The lock acknowledging but echoing different bits returns False."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
# asked for enabled (0x08 set), lock echoes it clear
with patch.object(device, "_send_command", return_value=b"\x01\xc2"):
assert await device.set_quick_key(enabled=True) is False


@pytest.mark.asyncio
async def test_set_quick_key_rejected():
"""A non-success status from the lock makes set_quick_key return False."""
device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
with patch.object(device, "_send_command", return_value=b"\x00"):
assert await device.set_quick_key(enabled=True) is False
Loading