Browse Source

Add a half lock to Lock Ultra (#472)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Retha Runolfsson 2 days ago
parent
commit
0edda6b29d
2 changed files with 102 additions and 2 deletions
  1. 30 2
      switchbot/devices/lock.py
  2. 72 0
      tests/test_lock.py

+ 30 - 2
switchbot/devices/lock.py

@@ -10,7 +10,11 @@ from bleak.backends.device import BLEDevice
 
 
 from ..const import SwitchbotModel
 from ..const import SwitchbotModel
 from ..const.lock import LockStatus
 from ..const.lock import LockStatus
-from .device import SwitchbotEncryptedDevice, SwitchbotSequenceDevice
+from .device import (
+    SwitchbotEncryptedDevice,
+    SwitchbotOperationError,
+    SwitchbotSequenceDevice,
+)
 
 
 COMMAND_HEADER = "57"
 COMMAND_HEADER = "57"
 COMMAND_LOCK_INFO = {
 COMMAND_LOCK_INFO = {
@@ -49,6 +53,10 @@ COMMAND_LOCK = {
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000000",
 }
 }
+COMMAND_HALF_LOCK = {
+    SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000008",
+}
+
 COMMAND_ENABLE_NOTIFICATIONS = {
 COMMAND_ENABLE_NOTIFICATIONS = {
     SwitchbotModel.LOCK: f"{COMMAND_HEADER}0e01001e00008101",
     SwitchbotModel.LOCK: f"{COMMAND_HEADER}0e01001e00008101",
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
@@ -129,6 +137,19 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
             {LockStatus.UNLOCKED, LockStatus.UNLOCKING, LockStatus.NOT_FULLY_LOCKED},
             {LockStatus.UNLOCKED, LockStatus.UNLOCKING, LockStatus.NOT_FULLY_LOCKED},
         )
         )
 
 
+    async def half_lock(self) -> bool:
+        """Send half lock command (Lock Ultra EU type only)."""
+        if self._model not in COMMAND_HALF_LOCK:
+            raise SwitchbotOperationError(
+                f"Half lock is not supported on {self._model}"
+            )
+        if not self.is_half_lock_calibrated():
+            raise SwitchbotOperationError("Half lock is not calibrated")
+        return await self._lock_unlock(
+            COMMAND_HALF_LOCK[self._model],
+            {LockStatus.HALF_LOCKED, LockStatus.LOCKING},
+        )
+
     def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
     def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
         """Parse basic data from lock."""
         """Parse basic data from lock."""
         return {
         return {
@@ -207,6 +228,10 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
         """Return True if Night Latch is enabled on EU firmware."""
         """Return True if Night Latch is enabled on EU firmware."""
         return self._get_adv_value("night_latch")
         return self._get_adv_value("night_latch")
 
 
+    def is_half_lock_calibrated(self) -> bool | None:
+        """Return True if half lock position is calibrated (Lock Ultra only)."""
+        return self._get_adv_value("half_lock_calibration")
+
     async def _get_lock_info(self) -> bytes | None:
     async def _get_lock_info(self) -> bytes | None:
         """Return lock info of device."""
         """Return lock info of device."""
         _data = await self._send_command(
         _data = await self._send_command(
@@ -268,10 +293,13 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
                 "status": LockStatus((data[0] & 0b01110000) >> 4),
                 "status": LockStatus((data[0] & 0b01110000) >> 4),
                 "unlocked_alarm": bool(data[1] & 0b00010000),
                 "unlocked_alarm": bool(data[1] & 0b00010000),
             }
             }
-        return {
+        result = {
             "calibration": bool(data[0] & 0b10000000),
             "calibration": bool(data[0] & 0b10000000),
             "status": LockStatus((data[0] & 0b01111000) >> 3),
             "status": LockStatus((data[0] & 0b01111000) >> 3),
             "door_open": bool(data[1] & 0b00010000),
             "door_open": bool(data[1] & 0b00010000),
             "unclosed_alarm": bool(data[5] & 0b10000000),
             "unclosed_alarm": bool(data[5] & 0b10000000),
             "unlocked_alarm": bool(data[5] & 0b01000000),
             "unlocked_alarm": bool(data[5] & 0b01000000),
         }
         }
+        if model is SwitchbotModel.LOCK_ULTRA:
+            result["half_lock_calibration"] = bool(data[1] & 0b00000001)
+        return result

+ 72 - 0
tests/test_lock.py

@@ -6,6 +6,7 @@ import pytest
 from switchbot import SwitchbotModel
 from switchbot import SwitchbotModel
 from switchbot.const.lock import LockStatus
 from switchbot.const.lock import LockStatus
 from switchbot.devices import lock
 from switchbot.devices import lock
+from switchbot.devices.device import SwitchbotOperationError
 
 
 from .test_adv_parser import generate_ble_device
 from .test_adv_parser import generate_ble_device
 
 
@@ -624,6 +625,7 @@ def test_update_lock_status(model: str):
                 "door_open": True,
                 "door_open": True,
                 "unclosed_alarm": True,
                 "unclosed_alarm": True,
                 "unlocked_alarm": True,
                 "unlocked_alarm": True,
+                "half_lock_calibration": False,
             },
             },
         ),
         ),
         (
         (
@@ -712,6 +714,7 @@ def test_parse_lock_data(model: str, data: bytes, expected: dict):
                 "door_open": False,
                 "door_open": False,
                 "unclosed_alarm": False,
                 "unclosed_alarm": False,
                 "unlocked_alarm": True,  # bit 6 of byte 5
                 "unlocked_alarm": True,  # bit 6 of byte 5
+                "half_lock_calibration": False,
             },
             },
         ),
         ),
         (
         (
@@ -760,6 +763,75 @@ async def test_lock_with_update(model: str):
         assert result is True
         assert result is True
 
 
 
 
+def test_is_half_lock_calibrated():
+    """Test is_half_lock_calibrated method."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    device._get_adv_value = Mock(return_value=True)
+    assert device.is_half_lock_calibrated() is True
+
+    device._get_adv_value = Mock(return_value=False)
+    assert device.is_half_lock_calibrated() is False
+
+
+@pytest.mark.asyncio
+async def test_half_lock_calibrated():
+    """Test half_lock succeeds when calibrated."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    device._get_adv_value = Mock(side_effect=[True, LockStatus.LOCKED])
+    with (
+        patch.object(device, "_send_command", return_value=b"\x01\x00"),
+        patch.object(device, "_enable_notifications", return_value=True),
+        patch.object(device, "_get_basic_info", return_value=b"\x01\x64\x01"),
+    ):
+        result = await device.half_lock()
+        assert result is True
+
+
+@pytest.mark.asyncio
+async def test_half_lock_not_calibrated():
+    """Test half_lock raises SwitchbotOperationError when not calibrated."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    device._get_adv_value = Mock(return_value=False)
+    with pytest.raises(SwitchbotOperationError, match="not calibrated"):
+        await device.half_lock()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "model",
+    [
+        SwitchbotModel.LOCK,
+        SwitchbotModel.LOCK_LITE,
+        SwitchbotModel.LOCK_PRO,
+        SwitchbotModel.LOCK_VISION,
+        SwitchbotModel.LOCK_VISION_PRO,
+        SwitchbotModel.LOCK_PRO_WIFI,
+    ],
+)
+async def test_half_lock_unsupported_model(model: str):
+    """Test half_lock raises SwitchbotOperationError on unsupported models."""
+    device = create_device_for_command_testing(model)
+    with pytest.raises(SwitchbotOperationError, match="not supported"):
+        await device.half_lock()
+
+
+@pytest.mark.asyncio
+async def test_half_lock():
+    """Test half_lock method."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
+    device._get_adv_value = Mock(side_effect=[True, LockStatus.LOCKED])
+    with (
+        patch.object(device, "_send_command", return_value=b"\x01\x00") as mock_send,
+        patch.object(device, "_enable_notifications", return_value=True),
+        patch.object(device, "_get_basic_info", return_value=b"\x01\x64\x01"),
+    ):
+        result = await device.half_lock()
+        assert result is True
+        mock_send.assert_awaited_once_with(
+            lock.COMMAND_HALF_LOCK[SwitchbotModel.LOCK_ULTRA]
+        )
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
 @pytest.mark.parametrize(
     ("model", "status"),
     ("model", "status"),