Browse Source

feat(lock): add Quick Key support for Lock Ultra (#515)

* 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>

* fix(lock): guard Quick Key parse + export QuickKeyFunction

Address PR review feedback:

- get_quick_key() no longer raises on an undefined function value. The
  2-bit function field has 4 possible values but only 3 are defined, so a
  0b11 reading (firmware variant or corrupted BLE byte) now logs and
  returns None, matching the return-None-on-malformed contract that the
  other parse paths already honor.
- Export QuickKeyFunction from switchbot/__init__.py and the const package
  alongside LockStatus, so callers of set_quick_key(function=...) can
  import it from the package root.
- Add a test for the undefined-function-value path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lock): return the raw Quick Key byte, parse partially, log write failures

Three changes, all driven by a field report on a second Lock Ultra
(mayerwin/ha-switchbot-lock-quick-key-support#1).

Return the raw config byte. Diagnosing that report took several rounds because
nothing in the stack could show the byte the lock actually sent; it only became
visible once the reporter patched in a log line of their own. Callers can now
surface it, which makes the next such report a one-round one.

Parse partially instead of discarding the whole byte. An undefined value in the
2-bit function field previously returned None for everything, including the
enable and trigger bits, which are unambiguous. Those are now kept and only
`function` is None. This follows the existing _parse_basic_data convention of
degrading rather than discarding.

Split the silent echo guard in set_quick_key. A truncated echo and a lock that
refused a requested bit both returned a bare False with nothing logged, so from
a Home Assistant log they were indistinguishable. Each now logs what happened,
and both branches are covered by tests. Raised in review by @bluetoothbot.

Also corrects a comment: bits 7-6 of the config byte are not constant status
flags. One Lock Ultra reports both set, another reports only bit 7, with the
same low nibble and identical behaviour. A new test pins that both parse the
same, so the high bits stay out of any validity check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(lock): document Quick Key behaviour when the lock does not answer

get_quick_key and set_quick_key return None/False when the lock answers
with a failure, but raise SwitchbotOperationError (via
_check_command_result) when it does not answer at all. Say so in both
docstrings so callers handle both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lock): document Quick Key transport errors and test false/zero writes

A lock that never answers raises TimeoutError (or a BleakError) from the
transport once the retries are used up; SwitchbotOperationError only
covers the case where no response is produced at all. Correct both
docstrings accordingly.

Add set_quick_key cases for enabled=False, double_press=False and
QuickKeyFunction.LOCK_ONLY, checking their mask bits are still sent with
zero values, plus a lock that keeps a bit set after being asked to clear
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(lock): extend Quick Key settings with protocol-aligned writes

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Erwin Mayer <2272127+mayerwin@users.noreply.github.com>
Co-authored-by: zerzhang <916250497@qq.com>
mayerwin 2 days ago
parent
commit
91354a189a
5 changed files with 697 additions and 3 deletions
  1. 2 0
      switchbot/__init__.py
  2. 2 1
      switchbot/const/__init__.py
  3. 12 0
      switchbot/const/lock.py
  4. 186 1
      switchbot/devices/lock.py
  5. 495 1
      tests/test_lock.py

+ 2 - 0
switchbot/__init__.py

@@ -25,6 +25,7 @@ from .const import (
     HumidifierWaterLevel,
     LockStatus,
     NightLightState,
+    QuickKeyFunction,
     SmartThermostatRadiatorMode,
     StandingFanMode,
     StripLightColorMode,
@@ -108,6 +109,7 @@ __all__ = [
     "HumidifierWaterLevel",
     "LockStatus",
     "NightLightState",
+    "QuickKeyFunction",
     "SmartThermostatRadiatorMode",
     "StandingFanMode",
     "StripLightColorMode",

+ 2 - 1
switchbot/const/__init__.py

@@ -26,7 +26,7 @@ from .light import (
 )
 
 # Preserve old LockStatus export for backwards compatibility
-from .lock import LockStatus
+from .lock import LockStatus, QuickKeyFunction
 
 DEFAULT_RETRY_COUNT = 3
 DEFAULT_RETRY_TIMEOUT = 1
@@ -160,6 +160,7 @@ __all__ = [
     "HumidifierWaterLevel",
     "LockStatus",
     "NightLightState",
+    "QuickKeyFunction",
     "SmartThermostatRadiatorMode",
     "StandingFanMode",
     "StripLightColorMode",

+ 12 - 0
switchbot/const/lock.py

@@ -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

+ 186 - 1
switchbot/devices/lock.py

@@ -9,7 +9,7 @@ from typing import Any
 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,
@@ -61,6 +61,13 @@ COMMAND_HALF_LOCK = {
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000008",
 }
 
+COMMAND_GET_QUICK_KEY = {
+    SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f0401",
+}
+COMMAND_SET_QUICK_KEY_PREFIX = {
+    SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e040100",
+}
+
 COMMAND_ENABLE_NOTIFICATIONS = {
     SwitchbotModel.LOCK: f"{COMMAND_HEADER}0e01001e00008101",
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
@@ -147,6 +154,184 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
             {LockStatus.HALF_LOCKED, LockStatus.LOCKING},
         )
 
+    async def get_quick_key(self) -> dict[str, Any] | None:
+        """
+        Read Quick Key and basic property settings from Lock Ultra.
+
+        Request: ``57 0f 4f 04 01``.
+        Response: status, config, secondary config, motor speed, secondary speed,
+        followed by model-specific data. Only the primary settings are returned.
+
+        Config byte (also returned unchanged as ``raw``):
+          bit7: ``light`` (0 disabled, 1 enabled).
+          bit6: ``sound`` (0 disabled, 1 enabled).
+          bit5: reserved, available only through ``raw``.
+          bit4: ``manual_unlock_linkage`` (0 disabled, 1 enabled).
+          bit3: ``enabled`` (0 Quick Key disabled, 1 enabled).
+          bit2: ``double_press`` (0 single press, 1 double press).
+          bits1-0: ``function`` (00 lock only, 01 unlock only, 10 toggle).
+
+        ``motor_speed`` is 0 (high), 1 (normal), or 2 (quiet); it is ``None``
+        if absent from the response. An unknown ``function`` is ``None``.
+        Rejected responses or a missing config byte return ``None``.
+        Unsupported models or absent responses raise ``SwitchbotOperationError``;
+        transport errors, including ``TimeoutError`` and ``BleakError``, propagate.
+        """
+        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
+        settings = self._parse_quick_key(result[1])
+        settings["motor_speed"] = result[3] if len(result) > 3 else None
+        return settings
+
+    @staticmethod
+    def _parse_quick_key(config: int) -> dict[str, Any]:
+        """Decode the Quick Key fields of the config byte."""
+        function_bits = config & 0b11
+        try:
+            function = QuickKeyFunction(function_bits)
+        except ValueError:
+            _LOGGER.warning(
+                "Unknown Quick Key function value: %#04x (config byte %#04x)",
+                function_bits,
+                config,
+            )
+            function = None
+        return {
+            "raw": config,
+            "light": bool(config & (1 << 7)),
+            "sound": bool(config & (1 << 6)),
+            "manual_unlock_linkage": bool(config & (1 << 4)),
+            "enabled": bool(config & (1 << 3)),
+            "double_press": bool(config & (1 << 2)),
+            "function": function,
+        }
+
+    async def set_quick_key(  # noqa: PLR0913
+        self,
+        *,
+        light: bool | None = None,
+        sound: bool | None = None,
+        manual_unlock_linkage: bool | None = None,
+        enabled: bool | None = None,
+        double_press: bool | None = None,
+        function: QuickKeyFunction | None = None,
+        motor_speed: int = 0xFF,
+    ) -> bool:
+        """
+        Update any combination of Quick Key and basic properties on Lock Ultra.
+
+        Command: ``57 0f 4e 04 01 00 <update> <value> <speed>``.
+        Target 00 uses the default behavior and can affect both grouped locks.
+        Property arguments default to ``None`` and motor speed to ``0xFF``,
+        leaving the corresponding settings unchanged.
+
+        Update byte (1 selects the corresponding property for writing):
+          bit7: ``light``.
+          bit6: ``sound``.
+          bit5: reserved, always 0.
+          bit4: ``manual_unlock_linkage``.
+          bit3: ``enabled`` (Quick Key).
+          bit2: ``double_press`` (Quick Key trigger).
+          bit1: ``function`` (selects the complete two-bit function field).
+          bit0: unused, always 0.
+
+        The value byte follows the config layout documented in ``get_quick_key``.
+        ``False`` clears a boolean property; ``True`` sets it. Function values
+        occupy bits1-0: 00 lock only, 01 unlock only, 10 toggle.
+        Speed is 00 high, 01 normal, 02 quiet, or ff to preserve it (the default).
+
+        Return whether the status reports success (01 or low-battery 06) and the
+        config echo matches the requested properties. Failed status, missing
+        config, and each mismatching property are logged separately and return
+        ``False``. Speed is acknowledged only by status; no extra read is made.
+        No properties or invalid speed raises ``ValueError``. Communication
+        errors propagate.
+
+        Example: ``await device.set_quick_key(light=True, sound=False, enabled=True)``.
+        """
+        if self._model not in COMMAND_SET_QUICK_KEY_PREFIX:
+            raise SwitchbotOperationError(
+                f"Quick Key is not supported on {self._model}"
+            )
+        if motor_speed not in (0, 1, 2, 0xFF):
+            raise ValueError(
+                "motor_speed must be 0 (high), 1 (normal), 2 (quiet), or 0xFF (keep)"
+                f" (got {motor_speed!r})"
+            )
+        update_fields = 0
+        value = 0
+        for setting, bit in (
+            (light, 7),
+            (sound, 6),
+            (manual_unlock_linkage, 4),
+            (enabled, 3),
+            (double_press, 2),
+        ):
+            if setting is not None:
+                update_fields |= 1 << bit
+                value |= int(setting) << bit
+        if function is not None:
+            update_fields |= 1 << 1
+            value |= function.value
+        if not update_fields and motor_speed == 0xFF:
+            raise ValueError("set_quick_key requires at least one setting to change")
+        command = (
+            f"{COMMAND_SET_QUICK_KEY_PREFIX[self._model]}"
+            f"{update_fields:02x}{value:02x}{motor_speed:02x}"
+        )
+        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(
+                "%s: Quick Key command failed (status=%#04x, response=%s)",
+                self.name,
+                result[0],
+                result.hex(),
+            )
+            return False
+        if len(result) < 2:
+            _LOGGER.error(
+                "%s: Quick Key write response is missing config (status=%#04x, response=%s)",
+                self.name,
+                result[0],
+                result.hex(),
+            )
+            return False
+        confirmed = self._parse_quick_key(result[1])
+        requested = {
+            "light": light,
+            "sound": sound,
+            "manual_unlock_linkage": manual_unlock_linkage,
+            "enabled": enabled,
+            "double_press": double_press,
+            "function": function,
+        }
+        mismatches = [
+            (setting, expected, confirmed[setting])
+            for setting, expected in requested.items()
+            if expected is not None and confirmed[setting] != expected
+        ]
+        for setting, expected, reported in mismatches:
+            _LOGGER.error(
+                "%s: Quick Key %s was not applied (requested=%s, reported=%s, config=%#04x)",
+                self.name,
+                setting,
+                expected,
+                reported,
+                result[1],
+            )
+        return not mismatches
+
     def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
         """Parse basic data from lock."""
         if len(basic_data) < 3:

+ 495 - 1
tests/test_lock.py

@@ -1,10 +1,11 @@
 import logging
+from typing import Any
 from unittest.mock import AsyncMock, Mock, patch
 
 import pytest
 
 from switchbot import SwitchbotLockUltra, 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
 from switchbot.models import SwitchBotAdvertisement
@@ -957,3 +958,496 @@ 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="570f4f0401",
+        retry=device._retry_count,
+    )
+    assert result == {
+        "raw": 0xCA,
+        "light": True,
+        "sound": True,
+        "manual_unlock_linkage": False,
+        "enabled": True,
+        "double_press": False,
+        "function": QuickKeyFunction.LOCK_AND_UNLOCK,
+        "motor_speed": 0,
+    }
+
+
+@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_get_quick_key_zero_properties():
+    """A zero property byte means disabled, single press, and lock only."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(device, "_send_command", return_value=b"\x01\x00"):
+        assert await device.get_quick_key() == {
+            "raw": 0,
+            "light": False,
+            "sound": False,
+            "manual_unlock_linkage": False,
+            "enabled": False,
+            "double_press": False,
+            "function": QuickKeyFunction.LOCK_ONLY,
+            "motor_speed": None,
+        }
+
+
+@pytest.mark.asyncio
+async def test_set_quick_key_function_update_flag():
+    """Bit 1 selects the entire two-bit function field for writing."""
+    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)
+    mock_send_command.assert_awaited_once_with(
+        key="570f4e0401000201ff", retry=device._retry_count
+    )
+    assert result is True
+
+
+@pytest.mark.asyncio
+async def test_set_quick_key_multiple_fields():
+    """Update all Quick Key fields while keeping the default target and speed."""
+    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
+        )
+    mock_send_command.assert_awaited_once_with(
+        key="570f4e0401000e0eff", retry=device._retry_count
+    )
+    assert result is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("kwargs", "flags_and_value"),
+    [
+        ({"enabled": False}, "0800"),
+        ({"double_press": False}, "0400"),
+        ({"function": QuickKeyFunction.LOCK_ONLY}, "0200"),
+        (
+            {
+                "enabled": False,
+                "double_press": False,
+                "function": QuickKeyFunction.LOCK_ONLY,
+            },
+            "0e00",
+        ),
+    ],
+)
+async def test_set_quick_key_false_and_zero_values(
+    kwargs: dict[str, Any], flags_and_value: str
+):
+    """Explicit false and zero values still select their fields for writing."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=b"\x01\xc0"
+    ) as mock_send_command:
+        result = await device.set_quick_key(**kwargs)
+    mock_send_command.assert_awaited_once_with(
+        key=f"570f4e040100{flags_and_value}ff", 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,
+            "light": True,
+            "sound": True,
+            "manual_unlock_linkage": False,
+            "enabled": True,
+            "double_press": False,
+            "function": None,
+            "motor_speed": None,
+        }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("properties", "light", "sound"),
+    [
+        (0x0A, False, False),
+        (0x4A, False, True),
+        (0x8A, True, False),
+        (0xCA, True, True),
+    ],
+)
+async def test_get_quick_key_light_and_sound_settings(
+    properties: int, light: bool, sound: bool
+):
+    """Read light and sound independently of the original Quick Key fields."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=bytes([1, properties, 0, 0, 0, 0])
+    ):
+        assert await device.get_quick_key() == {
+            "raw": properties,
+            "light": light,
+            "sound": sound,
+            "manual_unlock_linkage": False,
+            "enabled": True,
+            "double_press": False,
+            "function": QuickKeyFunction.LOCK_AND_UNLOCK,
+            "motor_speed": 0,
+        }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("response", "expected"),
+    [
+        (b"\x01", False),
+        (b"\x06", False),
+        (b"\x01\x08", True),
+        (b"\x06\x08", True),
+        (b"\x01\x00", False),
+        (b"\x06\x00", False),
+        (b"\x00", False),
+        (b"\x02", False),
+        (b"\x03", False),
+        (b"\x05", False),
+    ],
+)
+async def test_set_quick_key_command_status(response: bytes, expected: bool):
+    """Require a successful status and matching config without another read."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=response
+    ) as mock_send_command:
+        assert await device.set_quick_key(enabled=True) is expected
+
+    mock_send_command.assert_awaited_once_with(
+        key="570f4e0401000808ff", retry=device._retry_count
+    )
+
+
+@pytest.mark.asyncio
+async def test_get_quick_key_manual_unlock_linkage_and_motor_speed():
+    """Read primary properties and speed without using the secondary values."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=bytes([1, 0xD9, 0xFF, 2, 0, 0])
+    ):
+        assert await device.get_quick_key() == {
+            "raw": 0xD9,
+            "light": True,
+            "sound": True,
+            "manual_unlock_linkage": True,
+            "enabled": True,
+            "double_press": False,
+            "function": QuickKeyFunction.UNLOCK_ONLY,
+            "motor_speed": 2,
+        }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("kwargs", "command", "config"),
+    [
+        ({"light": True}, "570f4e0401008080ff", 0xCA),
+        ({"light": False}, "570f4e0401008000ff", 0x4A),
+        ({"sound": True}, "570f4e0401004040ff", 0xCA),
+        ({"sound": False}, "570f4e0401004000ff", 0x8A),
+        ({"manual_unlock_linkage": True}, "570f4e0401001010ff", 0xDA),
+        ({"manual_unlock_linkage": False}, "570f4e0401001000ff", 0xCA),
+    ],
+)
+async def test_set_quick_key_independent_properties(
+    kwargs: dict[str, bool], command: str, config: int
+):
+    """Each basic property can be changed while retaining the Quick Key fields."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=bytes([1, config])
+    ) as mock_send_command:
+        assert await device.set_quick_key(**kwargs) is True
+
+    mock_send_command.assert_awaited_once_with(key=command, retry=device._retry_count)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("kwargs", "command", "config"),
+    [
+        (
+            {
+                "light": True,
+                "sound": False,
+                "manual_unlock_linkage": True,
+                "enabled": False,
+                "double_press": True,
+                "function": QuickKeyFunction.UNLOCK_ONLY,
+            },
+            "570f4e040100de95ff",
+            0x95,
+        ),
+        (
+            {
+                "light": False,
+                "sound": False,
+                "manual_unlock_linkage": False,
+                "enabled": False,
+                "double_press": False,
+                "function": QuickKeyFunction.LOCK_ONLY,
+            },
+            "570f4e040100de00ff",
+            0x20,
+        ),
+    ],
+)
+async def test_set_quick_key_combines_all_properties(
+    kwargs: dict[str, Any], command: str, config: int
+):
+    """Combine all writable config fields without selecting reserved bits."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=bytes([1, config])
+    ) as mock_send_command:
+        assert await device.set_quick_key(**kwargs) is True
+
+    mock_send_command.assert_awaited_once_with(key=command, retry=device._retry_count)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("motor_speed", [0, 1, 2])
+async def test_set_quick_key_motor_speed(motor_speed: int):
+    """Change speed alone with a single write command."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device,
+        "_send_command",
+        return_value=b"\x01\xca",
+    ) as mock_send_command:
+        assert await device.set_quick_key(motor_speed=motor_speed) is True
+
+    mock_send_command.assert_awaited_once_with(
+        key=f"570f4e0401000000{motor_speed:02x}", retry=device._retry_count
+    )
+
+
+@pytest.mark.asyncio
+async def test_set_quick_key_motor_speed_with_other_properties():
+    """Write speed and properties together without an additional read."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device,
+        "_send_command",
+        return_value=b"\x01\x5d",
+    ) as mock_send_command:
+        assert (
+            await device.set_quick_key(
+                light=False,
+                sound=True,
+                manual_unlock_linkage=True,
+                enabled=True,
+                double_press=True,
+                function=QuickKeyFunction.UNLOCK_ONLY,
+                motor_speed=2,
+            )
+            is True
+        )
+
+    mock_send_command.assert_awaited_once_with(
+        key="570f4e040100de5d02", retry=device._retry_count
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("motor_speed", [-1, 3, 0x100])
+async def test_set_quick_key_invalid_motor_speed(motor_speed: int):
+    """Invalid speeds fail before Bluetooth commands are sent."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with (
+        patch.object(device, "_send_command") as mock_send_command,
+        pytest.raises(ValueError, match="motor_speed must be"),
+    ):
+        await device.set_quick_key(motor_speed=motor_speed)
+
+    mock_send_command.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_set_quick_key_motor_speed_keep():
+    """Explicit 0xFF preserves speed without an additional read."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=b"\x01\xca"
+    ) as mock_send_command:
+        assert await device.set_quick_key(light=True, motor_speed=0xFF) is True
+
+    mock_send_command.assert_awaited_once_with(
+        key="570f4e0401008080ff", retry=device._retry_count
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("kwargs", "config", "reported"),
+    [
+        ({"light": True}, 0x00, False),
+        ({"light": False}, 0x80, True),
+        ({"sound": True}, 0x00, False),
+        ({"manual_unlock_linkage": True}, 0x00, False),
+        ({"enabled": False}, 0x08, True),
+        ({"double_press": True}, 0x00, False),
+        ({"function": QuickKeyFunction.LOCK_ONLY}, 0x01, QuickKeyFunction.UNLOCK_ONLY),
+        ({"function": QuickKeyFunction.UNLOCK_ONLY}, 0x00, QuickKeyFunction.LOCK_ONLY),
+        ({"function": QuickKeyFunction.LOCK_AND_UNLOCK}, 0x03, None),
+    ],
+)
+async def test_set_quick_key_config_mismatch(
+    kwargs: dict[str, Any],
+    config: int,
+    reported: bool | QuickKeyFunction | None,
+    caplog: pytest.LogCaptureFixture,
+):
+    """Identify the rejected field, requested value, and reported value."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(
+        device, "_send_command", return_value=bytes([1, config])
+    ) as mock_send_command:
+        assert await device.set_quick_key(**kwargs) is False
+
+    mock_send_command.assert_awaited_once()
+    setting, expected = next(iter(kwargs.items()))
+    assert (
+        f"Quick Key {setting} was not applied "
+        f"(requested={expected}, reported={reported}, config={config:#04x})"
+    ) in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_set_quick_key_multiple_config_mismatches(
+    caplog: pytest.LogCaptureFixture,
+):
+    """Report every mismatching field in a combined write."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(device, "_send_command", return_value=b"\x01\x00"):
+        assert (
+            await device.set_quick_key(
+                light=True,
+                sound=True,
+                manual_unlock_linkage=True,
+                enabled=True,
+                double_press=True,
+                function=QuickKeyFunction.UNLOCK_ONLY,
+            )
+            is False
+        )
+
+    messages = [
+        record.message
+        for record in caplog.records
+        if "was not applied" in record.message
+    ]
+    assert len(messages) == 6
+    for setting in (
+        "light",
+        "sound",
+        "manual_unlock_linkage",
+        "enabled",
+        "double_press",
+        "function",
+    ):
+        assert any(
+            f"Quick Key {setting} was not applied" in message for message in messages
+        )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("status", [0x00, 0x02, 0x03, 0x04, 0x05, 0x09, 0x0E])
+async def test_set_quick_key_failed_status_logs_code(
+    status: int, caplog: pytest.LogCaptureFixture
+):
+    """Keep the device status code in diagnostics for rejected commands."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(device, "_send_command", return_value=bytes([status])):
+        assert await device.set_quick_key(enabled=True) is False
+
+    assert (
+        f"Quick Key command failed (status={status:#04x}, response={status:02x})"
+        in caplog.text
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("status", [0x01, 0x06])
+async def test_set_quick_key_missing_config_logs_status(
+    status: int, caplog: pytest.LogCaptureFixture
+):
+    """Distinguish a missing config from a rejected command."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with patch.object(device, "_send_command", return_value=bytes([status])):
+        assert await device.set_quick_key(enabled=True) is False
+
+    assert (
+        f"Quick Key write response is missing config (status={status:#04x}, response={status:02x})"
+    ) in caplog.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("response", [None, b""])
+async def test_set_quick_key_absent_response_raises(response: bytes | None):
+    """Preserve the existing operation error for an absent response."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    with (
+        patch.object(device, "_send_command", return_value=response),
+        pytest.raises(SwitchbotOperationError, match="Sending command failed"),
+    ):
+        await device.set_quick_key(enabled=True)