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 @@ -24,6 +24,7 @@
HumidifierWaterLevel,
LockStatus,
NightLightState,
QuickKeyFunction,
SmartThermostatRadiatorMode,
StandingFanMode,
StripLightColorMode,
Expand Down Expand Up @@ -89,6 +90,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 @@ -25,7 +25,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 @@ -142,6 +142,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
97 changes: 96 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 @@ -57,6 +57,24 @@
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 (the high bits 0xC0 are constant status flags).
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 @@ -141,6 +159,83 @@ 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
-------
``{"enabled": bool, "double_press": bool, "function": QuickKeyFunction}``,
or ``None`` if it can't be read.

"""
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")
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] | None:
"""Parse the Quick Key config byte, or None if it can't be parsed."""
func_bits = cfg & QUICK_KEY_FUNCTION_MASK
if func_bits not in QUICK_KEY_FUNCTION_VALUES:
_LOGGER.error("Unknown Quick Key function value: %#04x", func_bits)
return None
return {
"enabled": bool(cfg & QUICK_KEY_ENABLED_BIT),
"double_press": bool(cfg & QUICK_KEY_DOUBLE_PRESS_BIT),
"function": QuickKeyFunction(func_bits),
}

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")
return False
# The lock echoes the resulting config byte; confirm our bits stuck.
return len(result) >= 2 and (result[1] & mask) == (value & mask)

def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
"""Parse basic data from lock."""
return {
Expand Down
104 changes: 103 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 @@ -862,3 +862,105 @@ 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 == {
"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 (0b11) returns None instead of raising."""
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() is None


@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