Просмотр исходного кода

feat: add UWB Lock support (#599)

* feat: add UWB Lock support

* fix: use the Ultra basic-info path for UWB Lock

* fix: map the UWB Lock cloud product ID
Onero-testdev 19 часов назад
Родитель
Сommit
980f72658d

+ 12 - 0
switchbot/adv_parser.py

@@ -911,6 +911,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_wolock_pro,
         "manufacturer_id": 2409,
     },
+    b"\x00\x12\x11\x00": {
+        "modelName": SwitchbotModel.UWB_LOCK,
+        "modelFriendlyName": "UWB Lock",
+        "func": process_lock2,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x12\x11\x00": {
+        "modelName": SwitchbotModel.UWB_LOCK,
+        "modelFriendlyName": "UWB Lock",
+        "func": process_lock2,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x11\x9f\xb8": {
         "modelName": SwitchbotModel.LOCK_ULTRA_MAX,
         "modelFriendlyName": "Lock Ultra Max",

+ 1 - 0
switchbot/const/__init__.py

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

+ 1 - 0
switchbot/devices/device.py

@@ -108,6 +108,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
     "W1079001": SwitchbotModel.METER_PRO_CO2,
     "W1101000": SwitchbotModel.PRESENCE_SENSOR,
     "W1091000": SwitchbotModel.LOCK_ULTRA,
+    "W1184000": SwitchbotModel.UWB_LOCK,
     "W1096000": SwitchbotModel.HUB3,
     "W1083003": SwitchbotModel.GARAGE_DOOR_OPENER,
     "W1102000": SwitchbotModel.FLOOR_LAMP,

+ 7 - 1
switchbot/devices/lock.py

@@ -23,6 +23,7 @@ COMMAND_LOCK_INFO = {
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8104",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8107",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4f8107",
+    SwitchbotModel.UWB_LOCK: 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",
@@ -33,6 +34,7 @@ COMMAND_UNLOCK = {
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000080",
+    SwitchbotModel.UWB_LOCK: 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",
@@ -43,6 +45,7 @@ COMMAND_UNLOCK_WITHOUT_UNLATCH = {
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e01010000a0",
+    SwitchbotModel.UWB_LOCK: 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",
@@ -53,6 +56,7 @@ COMMAND_LOCK = {
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000000",
+    SwitchbotModel.UWB_LOCK: 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",
@@ -74,6 +78,7 @@ COMMAND_ENABLE_NOTIFICATIONS = {
     SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0e01001e00008104",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0e01001e00008107",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0e01001e00008107",
+    SwitchbotModel.UWB_LOCK: 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",
@@ -115,6 +120,7 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
             SwitchbotModel.LOCK_LITE,
             SwitchbotModel.LOCK_ULTRA,
             SwitchbotModel.LOCK_ULTRA_MAX,
+            SwitchbotModel.UWB_LOCK,
             SwitchbotModel.LOCK_VISION_PRO,
             SwitchbotModel.LOCK_VISION,
             SwitchbotModel.LOCK_PRO_WIFI,
@@ -490,7 +496,7 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
 
 
 class SwitchbotLockUltra(SwitchbotLock):
-    """SwitchBot Lock Ultra and Ultra Max, which share the Lock 2 basic-info format."""
+    """SwitchBot locks that use the Lock 2 basic-info format."""
 
     _model = SwitchbotModel.LOCK_ULTRA
 

+ 83 - 0
tests/test_adv_parser.py

@@ -3151,6 +3151,31 @@ def test_hub3_with_empty_data() -> None:
             "Lock Ultra Max",
             SwitchbotModel.LOCK_ULTRA_MAX,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
+            b"\x00\x80\x64\x00\x12\x11\x00",
+            {
+                "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\x12\x11\x00",
+            "UWB Lock",
+            SwitchbotModel.UWB_LOCK,
+        ),
         AdvTestCase(
             b"\xb0\xe9\xfe\x44\x55\x66\x2b\x10\x00\x50\x00\x00",
             b"\x00\x80\x50\x01\x11\x9f\xb8",
@@ -3176,6 +3201,31 @@ def test_hub3_with_empty_data() -> None:
             "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\x12\x11\x00",
+            {
+                "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\x12\x11\x00",
+            "UWB Lock",
+            SwitchbotModel.UWB_LOCK,
+        ),
     ],
 )
 def test_lock_active(test_case: AdvTestCase) -> None:
@@ -3387,6 +3437,31 @@ def test_lock_active(test_case: AdvTestCase) -> None:
             "Lock Ultra Max",
             SwitchbotModel.LOCK_ULTRA_MAX,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
+            b"\x00\x80\x64\x00\x12\x11\x00",
+            {
+                "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\x12\x11\x00",
+            "UWB Lock",
+            SwitchbotModel.UWB_LOCK,
+        ),
     ],
 )
 def test_lock_passive(test_case: AdvTestCase) -> None:
@@ -3480,6 +3555,14 @@ def test_lock_passive(test_case: AdvTestCase) -> None:
             "Lock Ultra Max",
             SwitchbotModel.LOCK_ULTRA_MAX,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x80\x64\x00\x12\x11\x00",
+            {},
+            b"\x00\x12\x11\x00",
+            "UWB Lock",
+            SwitchbotModel.UWB_LOCK,
+        ),
     ],
 )
 def test_lock_with_empty_data(test_case: AdvTestCase) -> None:

+ 22 - 0
tests/test_device.py

@@ -56,6 +56,28 @@ def test_api_model_mapping(api_model: str, expected_model: SwitchbotModel) -> No
     assert API_MODEL_TO_ENUM[api_model] is expected_model
 
 
+@pytest.mark.asyncio
+async def test_cloud_maps_uwb_lock(mock_user_info: dict[str, Any]) -> None:
+    """Recognize the UWB Lock product ID in the account device list."""
+    response = {
+        "Items": [
+            {"device_mac": "aabbccddeeff", "device_detail": {"device_type": "W1184000"}}
+        ]
+    }
+    with (
+        patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
+        patch.object(
+            SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
+        ),
+        patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
+    ):
+        devices = await fetch_cloud_devices_by_token(
+            MagicMock(spec=aiohttp.ClientSession), "test-token"
+        )
+        assert devices == {"AA:BB:CC:DD:EE:FF": SwitchbotModel.UWB_LOCK}
+        assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] is SwitchbotModel.UWB_LOCK
+
+
 def test_legacy_plug_is_not_mapped_to_plug_mini() -> None:
     """Test the legacy Plug is not identified as a Plug Mini."""
     assert "WoPlug" not in API_MODEL_TO_ENUM

+ 36 - 2
tests/test_lock.py

@@ -13,6 +13,33 @@ from switchbot.models import SwitchBotAdvertisement
 from .test_adv_parser import generate_ble_device
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("replies", "valid"),
+    [
+        (
+            [bytes.fromhex("01 88 00 00 00 00 00"), bytes.fromhex("01 85 28 21 14")],
+            True,
+        ),
+        ([None], False),
+        ([bytes.fromhex("01 88 00 00 00 00 00"), None], False),
+    ],
+)
+async def test_uwb_lock_key_verification(replies, valid):
+    device = generate_ble_device("aa:bb:cc:dd:ee:ff", "UWB Lock")
+    with patch.object(SwitchbotLockUltra, "_send_command", side_effect=replies) as send:
+        assert (
+            await SwitchbotLockUltra.verify_encryption_key(
+                device, "ff", "ff" * 16, SwitchbotModel.UWB_LOCK
+            )
+            is valid
+        )
+    assert [args.kwargs["key"] for args in send.await_args_list] == [
+        "570f4f8107",
+        "5702",
+    ][: len(replies)]
+
+
 def create_device_for_command_testing(model: str, cls=lock.SwitchbotLock):
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
     return cls(ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model)
@@ -26,6 +53,7 @@ def create_device_for_command_testing(model: str, cls=lock.SwitchbotLock):
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA_MAX,
+        SwitchbotModel.UWB_LOCK,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,
@@ -74,6 +102,7 @@ def test_default_model_classvar():
         (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.UWB_LOCK, 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"),
@@ -104,6 +133,7 @@ async def test_lock(model: str, command: bytes):
         (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.UWB_LOCK, 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"),
@@ -134,6 +164,7 @@ async def test_unlock(model: str, command: bytes):
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA_MAX,
+        SwitchbotModel.UWB_LOCK,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,
@@ -241,7 +272,8 @@ def test_parse_basic_data(battery):
 
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
-    "model", [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX]
+    "model",
+    [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX, SwitchbotModel.UWB_LOCK],
 )
 @pytest.mark.parametrize(
     ("basic_info", "battery", "firmware"),
@@ -278,7 +310,8 @@ async def test_lock2_basic_info(model, basic_info, battery, firmware):
 
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
-    "model", [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX]
+    "model",
+    [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX, SwitchbotModel.UWB_LOCK],
 )
 async def test_lock2_battery_after_lock(model):
     device = create_device_for_command_testing(model, SwitchbotLockUltra)
@@ -882,6 +915,7 @@ async def test_half_lock_not_calibrated():
         SwitchbotModel.LOCK_LITE,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA_MAX,
+        SwitchbotModel.UWB_LOCK,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,