Переглянути джерело

feat: add Lock Ultra Max support (#558)

Co-authored-by: Fan Kai <fankai@onero.com>
Onero-testdev 8 годин тому
батько
коміт
eefa4a7014

+ 12 - 0
switchbot/adv_parser.py

@@ -898,6 +898,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_wolock_pro,
         "manufacturer_id": 2409,
     },
+    b"\x00\x11\x9f\xb8": {
+        "modelName": SwitchbotModel.LOCK_ULTRA_MAX,
+        "modelFriendlyName": "Lock Ultra Max",
+        "func": process_lock2,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x11\x9f\xb8": {
+        "modelName": SwitchbotModel.LOCK_ULTRA_MAX,
+        "modelFriendlyName": "Lock Ultra Max",
+        "func": process_lock2,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x11\x07\x60": {
         "modelName": SwitchbotModel.STANDING_FAN,
         "modelFriendlyName": "Standing Fan",

+ 1 - 0
switchbot/const/__init__.py

@@ -126,6 +126,7 @@ class SwitchbotModel(StrEnum):
     LOCK_VISION_PRO = "Lock Vision Pro"
     LOCK_VISION = "Lock Vision"
     LOCK_PRO_WIFI = "Lock Pro Wifi"
+    LOCK_ULTRA_MAX = "Lock Ultra Max"
     WEATHER_STATION = "Weather Station"
 
 

+ 11 - 0
switchbot/devices/lock.py

@@ -22,6 +22,7 @@ COMMAND_LOCK_INFO = {
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4f8101",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8104",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8107",
+    SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4f8107",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4f8102",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4f8102",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4f810a",
@@ -31,6 +32,7 @@ COMMAND_UNLOCK = {
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011080",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080",
+    SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000080",
@@ -40,6 +42,7 @@ COMMAND_UNLOCK_WITHOUT_UNLATCH = {
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e010110a0",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0",
+    SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e01010000a0",
@@ -49,6 +52,7 @@ COMMAND_LOCK = {
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011000",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000",
+    SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000000",
@@ -62,6 +66,7 @@ COMMAND_ENABLE_NOTIFICATIONS = {
     SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0e01001e00008104",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0e01001e00008107",
+    SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0e01001e00008107",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0e01001e00008102",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0e01001e00008102",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0e01001e0000810a",
@@ -102,6 +107,7 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
             SwitchbotModel.LOCK_PRO,
             SwitchbotModel.LOCK_LITE,
             SwitchbotModel.LOCK_ULTRA,
+            SwitchbotModel.LOCK_ULTRA_MAX,
             SwitchbotModel.LOCK_VISION_PRO,
             SwitchbotModel.LOCK_VISION,
             SwitchbotModel.LOCK_PRO_WIFI,
@@ -143,6 +149,11 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
 
     def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
         """Parse basic data from lock."""
+        if self._model is SwitchbotModel.LOCK_ULTRA_MAX:
+            # Lock Ultra Max's basic-info response format is not documented.
+            # Its advertisement already carries the reliable battery and state
+            # data, so never overwrite that data with an unverified response.
+            return {}
         return {
             "battery": basic_data[1],
             "firmware": basic_data[2] / 10.0,

+ 83 - 0
tests/test_adv_parser.py

@@ -3014,6 +3014,56 @@ def test_hub3_with_empty_data() -> None:
             "Lock Pro Wifi",
             SwitchbotModel.LOCK_PRO_WIFI,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
+            b"\x00\x80\x64\x00\x11\x9f\xb8",
+            {
+                "sequence_number": 42,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": True,
+                "door_open_from_secondary_lock": False,
+                "double_lock_mode": False,
+                "is_secondary_lock": False,
+                "manual_unlock_linkage": False,
+                "unclosed_alarm": True,
+                "unlocked_alarm": False,
+                "auto_lock_paused": True,
+                "night_latch": False,
+                "power_alarm": True,
+                "battery_status": 1,
+            },
+            b"\x00\x11\x9f\xb8",
+            "Lock Ultra Max",
+            SwitchbotModel.LOCK_ULTRA_MAX,
+        ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x44\x55\x66\x2b\x10\x00\x50\x00\x00",
+            b"\x00\x80\x50\x01\x11\x9f\xb8",
+            {
+                "sequence_number": 43,
+                "battery": 80,
+                "calibration": False,
+                "status": LockStatus.LOCKING,
+                "update_from_secondary_lock": False,
+                "door_open": False,
+                "door_open_from_secondary_lock": False,
+                "double_lock_mode": False,
+                "is_secondary_lock": False,
+                "manual_unlock_linkage": False,
+                "unclosed_alarm": False,
+                "unlocked_alarm": False,
+                "auto_lock_paused": False,
+                "night_latch": False,
+                "power_alarm": False,
+                "battery_status": 0,
+            },
+            b"\x01\x11\x9f\xb8",
+            "Lock Ultra Max",
+            SwitchbotModel.LOCK_ULTRA_MAX,
+        ),
     ],
 )
 def test_lock_active(test_case: AdvTestCase) -> None:
@@ -3200,6 +3250,31 @@ def test_lock_active(test_case: AdvTestCase) -> None:
             "Lock Pro Wifi",
             SwitchbotModel.LOCK_PRO_WIFI,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
+            b"\x00\x80\x64\x00\x11\x9f\xb8",
+            {
+                "sequence_number": 42,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "update_from_secondary_lock": False,
+                "door_open": True,
+                "door_open_from_secondary_lock": False,
+                "double_lock_mode": False,
+                "is_secondary_lock": False,
+                "manual_unlock_linkage": False,
+                "unclosed_alarm": True,
+                "unlocked_alarm": False,
+                "auto_lock_paused": True,
+                "night_latch": False,
+                "power_alarm": True,
+                "battery_status": 1,
+            },
+            b"\x00\x11\x9f\xb8",
+            "Lock Ultra Max",
+            SwitchbotModel.LOCK_ULTRA_MAX,
+        ),
     ],
 )
 def test_lock_passive(test_case: AdvTestCase) -> None:
@@ -3285,6 +3360,14 @@ def test_lock_passive(test_case: AdvTestCase) -> None:
             "Lock Pro Wifi",
             SwitchbotModel.LOCK_PRO_WIFI,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x80\x64\x00\x11\x9f\xb8",
+            {},
+            b"\x00\x11\x9f\xb8",
+            "Lock Ultra Max",
+            SwitchbotModel.LOCK_ULTRA_MAX,
+        ),
     ],
 )
 def test_lock_with_empty_data(test_case: AdvTestCase) -> None:

+ 29 - 0
tests/test_lock.py

@@ -25,6 +25,7 @@ def create_device_for_command_testing(model: str):
         SwitchbotModel.LOCK_LITE,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA,
+        SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,
@@ -65,6 +66,7 @@ def test_default_model_classvar():
         (SwitchbotModel.LOCK_LITE, b"W\x0fN\x01\x01\x10\x81"),
         (SwitchbotModel.LOCK_PRO, b"W\x0fN\x01\x01\x10\x85"),
         (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x86"),
+        (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x00"),
         (SwitchbotModel.LOCK_VISION, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x82"),
@@ -94,6 +96,7 @@ async def test_lock(model: str, command: bytes):
         (SwitchbotModel.LOCK_LITE, b"W\x0fN\x01\x01\x10\x81"),
         (SwitchbotModel.LOCK_PRO, b"W\x0fN\x01\x01\x10\x84"),
         (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x83"),
+        (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_VISION, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x81"),
@@ -123,6 +126,7 @@ async def test_unlock(model: str, command: bytes):
         SwitchbotModel.LOCK_LITE,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA,
+        SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,
@@ -226,6 +230,30 @@ def test_parse_basic_data():
     assert result["firmware"] == 0.1
 
 
+def test_lock_ultra_max_uses_advertisement_for_basic_data():
+    """Lock Ultra Max does not parse its undocumented basic-info response."""
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA_MAX)
+    assert device._parse_basic_data(b"\x00\xf3\x01") == {}
+
+
+@pytest.mark.asyncio
+async def test_lock_ultra_max_verifies_encryption_key():
+    """Lock Ultra Max verifies a valid encryption key."""
+    lock_data = b"\x00\x80\x00\x00\x00\x00\x00\x00"
+    basic_data = b"\x00\x64\x01"
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    with (
+        patch.object(lock.SwitchbotLock, "_get_lock_info", return_value=lock_data),
+        patch.object(lock.SwitchbotLock, "_get_basic_info", return_value=basic_data),
+    ):
+        assert await lock.SwitchbotLock.verify_encryption_key(
+            ble_device,
+            "ff",
+            "ffffffffffffffffffffffffffffffff",
+            SwitchbotModel.LOCK_ULTRA_MAX,
+        )
+
+
 @pytest.mark.parametrize(
     "model",
     [
@@ -786,6 +814,7 @@ async def test_half_lock_not_calibrated():
         SwitchbotModel.LOCK,
         SwitchbotModel.LOCK_LITE,
         SwitchbotModel.LOCK_PRO,
+        SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,