Explorar o código

feat: add Lock Nano support (#600)

* feat: add Lock Nano support

* fix: limit lock status parsing changes to Lock Nano

* fix: recognize the Lock Nano cloud product ID
Onero-testdev hai 10 horas
pai
achega
530154090a

+ 13 - 0
switchbot/adv_parser.py

@@ -42,6 +42,7 @@ from .adv_parsers.light_strip import (
 )
 )
 from .adv_parsers.lock import (
 from .adv_parsers.lock import (
     process_lock2,
     process_lock2,
+    process_lock_nano,
     process_locklite,
     process_locklite,
     process_wolock,
     process_wolock,
     process_wolock_pro,
     process_wolock_pro,
@@ -923,6 +924,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_lock2,
         "func": process_lock2,
         "manufacturer_id": 2409,
         "manufacturer_id": 2409,
     },
     },
+    b"\x00\x12\x24\x88": {
+        "modelName": SwitchbotModel.LOCK_NANO,
+        "modelFriendlyName": "Lock Nano",
+        "func": process_lock_nano,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x12\x24\x88": {
+        "modelName": SwitchbotModel.LOCK_NANO,
+        "modelFriendlyName": "Lock Nano",
+        "func": process_lock_nano,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x11\x9f\xb8": {
     b"\x00\x11\x9f\xb8": {
         "modelName": SwitchbotModel.LOCK_ULTRA_MAX,
         "modelName": SwitchbotModel.LOCK_ULTRA_MAX,
         "modelFriendlyName": "Lock Ultra Max",
         "modelFriendlyName": "Lock Ultra Max",

+ 25 - 0
switchbot/adv_parsers/lock.py

@@ -84,6 +84,31 @@ def process_wolock_pro(
     return common_data | lock_pro_data
     return common_data | lock_pro_data
 
 
 
 
+def process_lock_nano(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int]:
+    """Parse Lock Nano advertisements."""
+    if mfr_data is None or len(mfr_data) < 11:
+        return {}
+
+    charging_status = ((mfr_data[8] & 1) << 1) | (mfr_data[9] >> 7)
+    return {
+        "sequence_number": mfr_data[6],
+        "calibration": bool(mfr_data[7] & 0x80),
+        "status": LockStatus((mfr_data[7] & 0x78) >> 3),
+        "auto_lock_paused": bool(mfr_data[8] & 0x08),
+        "night_latch": bool(mfr_data[8] & 0x02),
+        "battery": mfr_data[9] & 0x7F,
+        "charging_status": charging_status,
+        "battery_charging": charging_status == 1,
+        "unlocked_alarm": bool(mfr_data[10] & 0x40),
+        "low_temperature_alarm": bool(mfr_data[10] & 0x20),
+        "power_alarm": bool(mfr_data[10] & 0x10),
+        "battery_status": (mfr_data[10] >> 1) & 0x03,
+        "charging_fault": bool(mfr_data[10] & 0x01),
+    }
+
+
 def process_lock2(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
 def process_lock2(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
     """Support for lock2 process data."""
     """Support for lock2 process data."""
     common_data = parse_common_data(mfr_data)
     common_data = parse_common_data(mfr_data)

+ 1 - 0
switchbot/const/__init__.py

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

+ 1 - 0
switchbot/devices/device.py

@@ -124,6 +124,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
     "W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
     "W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
     "W1111000": SwitchbotModel.CLIMATE_PANEL,
     "W1111000": SwitchbotModel.CLIMATE_PANEL,
     "W1130000": SwitchbotModel.ART_FRAME,
     "W1130000": SwitchbotModel.ART_FRAME,
+    "W1189000": SwitchbotModel.LOCK_NANO,
     "W1141001": SwitchbotModel.LOCK_VISION_PRO,
     "W1141001": SwitchbotModel.LOCK_VISION_PRO,
     "W1141000": SwitchbotModel.LOCK_VISION,
     "W1141000": SwitchbotModel.LOCK_VISION,
     "W1114000": SwitchbotModel.LOCK_PRO_WIFI,
     "W1114000": SwitchbotModel.LOCK_PRO_WIFI,

+ 13 - 0
switchbot/devices/lock.py

@@ -24,6 +24,7 @@ COMMAND_LOCK_INFO = {
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8107",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8107",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4f8107",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4f8107",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4f8107",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4f8107",
+    SwitchbotModel.LOCK_NANO: f"{COMMAND_HEADER}0f4f810c",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4f8102",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4f8102",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4f8102",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4f8102",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4f810a",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4f810a",
@@ -35,6 +36,7 @@ COMMAND_UNLOCK = {
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4e0101000080",
+    SwitchbotModel.LOCK_NANO: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000080",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000080",
@@ -46,6 +48,7 @@ COMMAND_UNLOCK_WITHOUT_UNLATCH = {
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4e01010000a0",
+    SwitchbotModel.LOCK_NANO: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e01010000a0",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e01010000a0",
@@ -57,6 +60,7 @@ COMMAND_LOCK = {
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0f4e0101000000",
+    SwitchbotModel.LOCK_NANO: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000000",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000000",
     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",
@@ -79,6 +83,7 @@ COMMAND_ENABLE_NOTIFICATIONS = {
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0e01001e00008107",
     SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0e01001e00008107",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0e01001e00008107",
     SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0e01001e00008107",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0e01001e00008107",
     SwitchbotModel.UWB_LOCK: f"{COMMAND_HEADER}0e01001e00008107",
+    SwitchbotModel.LOCK_NANO: f"{COMMAND_HEADER}0e01001e0000810c",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0e01001e00008102",
     SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0e01001e00008102",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0e01001e00008102",
     SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0e01001e00008102",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0e01001e0000810a",
     SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0e01001e0000810a",
@@ -121,6 +126,7 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
             SwitchbotModel.LOCK_ULTRA,
             SwitchbotModel.LOCK_ULTRA,
             SwitchbotModel.LOCK_ULTRA_MAX,
             SwitchbotModel.LOCK_ULTRA_MAX,
             SwitchbotModel.UWB_LOCK,
             SwitchbotModel.UWB_LOCK,
+            SwitchbotModel.LOCK_NANO,
             SwitchbotModel.LOCK_VISION_PRO,
             SwitchbotModel.LOCK_VISION_PRO,
             SwitchbotModel.LOCK_VISION,
             SwitchbotModel.LOCK_VISION,
             SwitchbotModel.LOCK_PRO_WIFI,
             SwitchbotModel.LOCK_PRO_WIFI,
@@ -483,6 +489,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),
             }
             }
+        if model is SwitchbotModel.LOCK_NANO:
+            return {
+                "calibration": bool(data[0] & 0b10000000),
+                "status": LockStatus((data[0] & 0b01111000) >> 3),
+                "auto_lock_paused": bool(data[1] & 0b00001000),
+                "unlocked_alarm": bool(data[5] & 0b01000000),
+            }
         result = {
         result = {
             "calibration": bool(data[0] & 0b10000000),
             "calibration": bool(data[0] & 0b10000000),
             "status": LockStatus((data[0] & 0b01111000) >> 3),
             "status": LockStatus((data[0] & 0b01111000) >> 3),

+ 74 - 0
tests/test_adv_parser.py

@@ -3151,6 +3151,28 @@ def test_hub3_with_empty_data() -> None:
             "Lock Ultra Max",
             "Lock Ultra Max",
             SwitchbotModel.LOCK_ULTRA_MAX,
             SwitchbotModel.LOCK_ULTRA_MAX,
         ),
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x08\x64\x70",
+            b"\x00\x80\x64\x00\x12\x24\x88",
+            {
+                "sequence_number": 42,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "auto_lock_paused": True,
+                "night_latch": False,
+                "charging_status": 0,
+                "battery_charging": False,
+                "unlocked_alarm": True,
+                "low_temperature_alarm": True,
+                "power_alarm": True,
+                "battery_status": 0,
+                "charging_fault": False,
+            },
+            b"\x00\x12\x24\x88",
+            "Lock Nano",
+            SwitchbotModel.LOCK_NANO,
+        ),
         AdvTestCase(
         AdvTestCase(
             b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
             b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
             b"\x00\x80\x64\x00\x12\x11\x00",
             b"\x00\x80\x64\x00\x12\x11\x00",
@@ -3201,6 +3223,28 @@ def test_hub3_with_empty_data() -> None:
             "Lock Ultra Max",
             "Lock Ultra Max",
             SwitchbotModel.LOCK_ULTRA_MAX,
             SwitchbotModel.LOCK_ULTRA_MAX,
         ),
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x08\x64\x70",
+            b"\x00\x80\x50\x01\x12\x24\x88",
+            {
+                "sequence_number": 42,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "auto_lock_paused": True,
+                "night_latch": False,
+                "charging_status": 0,
+                "battery_charging": False,
+                "unlocked_alarm": True,
+                "low_temperature_alarm": True,
+                "power_alarm": True,
+                "battery_status": 0,
+                "charging_fault": False,
+            },
+            b"\x01\x12\x24\x88",
+            "Lock Nano",
+            SwitchbotModel.LOCK_NANO,
+        ),
         AdvTestCase(
         AdvTestCase(
             b"\xb0\xe9\xfe\x44\x55\x66\x2b\x10\x00\x50\x00\x00",
             b"\xb0\xe9\xfe\x44\x55\x66\x2b\x10\x00\x50\x00\x00",
             b"\x00\x80\x50\x01\x12\x11\x00",
             b"\x00\x80\x50\x01\x12\x11\x00",
@@ -3437,6 +3481,28 @@ def test_lock_active(test_case: AdvTestCase) -> None:
             "Lock Ultra Max",
             "Lock Ultra Max",
             SwitchbotModel.LOCK_ULTRA_MAX,
             SwitchbotModel.LOCK_ULTRA_MAX,
         ),
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x08\x64\x70",
+            b"\x00\x80\x64\x00\x12\x24\x88",
+            {
+                "sequence_number": 42,
+                "battery": 100,
+                "calibration": True,
+                "status": LockStatus.UNLOCKED,
+                "auto_lock_paused": True,
+                "night_latch": False,
+                "charging_status": 0,
+                "battery_charging": False,
+                "unlocked_alarm": True,
+                "low_temperature_alarm": True,
+                "power_alarm": True,
+                "battery_status": 0,
+                "charging_fault": False,
+            },
+            b"\x00\x12\x24\x88",
+            "Lock Nano",
+            SwitchbotModel.LOCK_NANO,
+        ),
         AdvTestCase(
         AdvTestCase(
             b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
             b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91",
             b"\x00\x80\x64\x00\x12\x11\x00",
             b"\x00\x80\x64\x00\x12\x11\x00",
@@ -3555,6 +3621,14 @@ def test_lock_passive(test_case: AdvTestCase) -> None:
             "Lock Ultra Max",
             "Lock Ultra Max",
             SwitchbotModel.LOCK_ULTRA_MAX,
             SwitchbotModel.LOCK_ULTRA_MAX,
         ),
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x80\x64\x00\x12\x24\x88",
+            {},
+            b"\x00\x12\x24\x88",
+            "Lock Nano",
+            SwitchbotModel.LOCK_NANO,
+        ),
         AdvTestCase(
         AdvTestCase(
             None,
             None,
             b"\x00\x80\x64\x00\x12\x11\x00",
             b"\x00\x80\x64\x00\x12\x11\x00",

+ 22 - 0
tests/test_device.py

@@ -155,6 +155,28 @@ def mock_auth_response() -> dict[str, Any]:
     }
     }
 
 
 
 
+@pytest.mark.asyncio
+async def test_cloud_maps_lock_nano(mock_user_info: dict[str, Any]) -> None:
+    """Recognize the Lock Nano product ID in the account device list."""
+    response = {
+        "Items": [
+            {"device_mac": "aabbccddeeff", "device_detail": {"device_type": "W1189000"}}
+        ]
+    }
+    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.LOCK_NANO}
+        assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] is SwitchbotModel.LOCK_NANO
+
+
 @pytest.fixture
 @pytest.fixture
 def mock_user_info() -> dict[str, Any]:
 def mock_user_info() -> dict[str, Any]:
     """Mock user info response."""
     """Mock user info response."""

+ 83 - 1
tests/test_lock.py

@@ -1,6 +1,6 @@
 import logging
 import logging
 from typing import Any
 from typing import Any
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, Mock, call, patch
 
 
 import pytest
 import pytest
 
 
@@ -40,11 +40,85 @@ async def test_uwb_lock_key_verification(replies, valid):
     ][: len(replies)]
     ][: len(replies)]
 
 
 
 
+@pytest.mark.asyncio
+async def test_nano_update_reads_lock_and_basic_info():
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
+    device.update_from_advertisement(
+        SwitchBotAdvertisement(
+            device._device.address, {"data": {"battery": 95}}, device._device, -60
+        )
+    )
+    replies = [bytes.fromhex("01 90 08 5f 00 00 40"), bytes.fromhex("01 4b 21 14")]
+    with patch.object(device, "_send_command", side_effect=replies) as send:
+        await device.update()
+
+    assert send.await_args_list == [
+        call(key="570f4f810c", retry=device._retry_count),
+        call(key="5702", retry=device._retry_count),
+    ]
+    assert device.get_lock_status() is LockStatus.LOCKING
+    assert device.is_calibrated()
+    assert device.is_auto_lock_paused()
+    assert device.is_unlocked_alarm_on()
+    assert device.get_battery_percent() == 75
+    assert device.parsed_data["firmware"] == 3.3
+
+
+@pytest.mark.asyncio
+async def test_nano_key_verification_without_response():
+    device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Lock Nano")
+    with patch.object(lock.SwitchbotLock, "_send_command", return_value=None):
+        assert not await lock.SwitchbotLock.verify_encryption_key(
+            device, "ff", "ff" * 16, SwitchbotModel.LOCK_NANO
+        )
+
+
+@pytest.mark.asyncio
+async def test_nano_lock_info_command():
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
+    with patch.object(device, "_send_command", return_value=b"\x01\x00") as send:
+        assert await device._get_lock_info() == b"\x01\x00"
+    send.assert_awaited_once_with(key="570f4f810c", retry=device._retry_count)
+
+
+@pytest.mark.asyncio
+async def test_nano_notification_command():
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
+    with patch.object(device, "_send_command", return_value=b"\x01\x00") as send:
+        assert await device._enable_notifications()
+    send.assert_awaited_once_with("570e01001e0000810c")
+
+
 def create_device_for_command_testing(model: str, cls=lock.SwitchbotLock):
 def create_device_for_command_testing(model: str, cls=lock.SwitchbotLock):
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
     return cls(ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model)
     return cls(ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model)
 
 
 
 
+def test_nano_status_notification_updates_device():
+    device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
+    advertisement = SwitchBotAdvertisement(
+        device._device.address,
+        {"data": {"status": LockStatus.UNLOCKED}},
+        device._device,
+        -60,
+    )
+    device.update_from_advertisement(advertisement)
+    received = []
+    device.subscribe(lambda: received.append(device.get_lock_status()))
+
+    payload = bytes.fromhex("90 00 00 00 00 00")
+    with (
+        patch.object(device, "_decrypt", return_value=payload) as decrypt,
+        patch.object(device, "_reset_disconnect_timer"),
+    ):
+        frame = bytearray.fromhex("0f 00 00 00 12 34 56 78 9a bc")
+        device._notification_handler(0, frame)
+
+    decrypt.assert_called_once_with(frame[4:])
+    assert received == [LockStatus.LOCKING]
+    assert device.get_lock_status() is LockStatus.LOCKING
+
+
 @pytest.mark.parametrize(
 @pytest.mark.parametrize(
     "model",
     "model",
     [
     [
@@ -54,6 +128,7 @@ def create_device_for_command_testing(model: str, cls=lock.SwitchbotLock):
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.UWB_LOCK,
         SwitchbotModel.UWB_LOCK,
+        SwitchbotModel.LOCK_NANO,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,
         SwitchbotModel.LOCK_PRO_WIFI,
@@ -103,6 +178,7 @@ def test_default_model_classvar():
         (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x86"),
         (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x86"),
         (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x00"),
         (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x00"),
         (SwitchbotModel.UWB_LOCK, b"W\x0fN\x01\x01\x00\x00"),
         (SwitchbotModel.UWB_LOCK, b"W\x0fN\x01\x01\x00\x00"),
+        (SwitchbotModel.LOCK_NANO, b"W\x0fN\x01\x01\x00\x00"),
         (SwitchbotModel.LOCK_VISION, 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_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x82"),
         (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x82"),
@@ -134,6 +210,7 @@ async def test_lock(model: str, command: bytes):
         (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x83"),
         (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x83"),
         (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.UWB_LOCK, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.UWB_LOCK, b"W\x0fN\x01\x01\x00\x80"),
+        (SwitchbotModel.LOCK_NANO, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_VISION, 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_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
         (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x81"),
         (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x81"),
@@ -165,6 +242,7 @@ async def test_unlock(model: str, command: bytes):
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.UWB_LOCK,
         SwitchbotModel.UWB_LOCK,
+        SwitchbotModel.LOCK_NANO,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,
         SwitchbotModel.LOCK_PRO_WIFI,
@@ -189,6 +267,7 @@ async def test_unlock_without_unlatch(model: str):
     [
     [
         SwitchbotModel.LOCK,
         SwitchbotModel.LOCK,
         SwitchbotModel.LOCK_LITE,
         SwitchbotModel.LOCK_LITE,
+        SwitchbotModel.LOCK_NANO,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.LOCK_ULTRA_MAX,
@@ -220,6 +299,7 @@ async def test_get_basic_info(model: str):
     [
     [
         SwitchbotModel.LOCK,
         SwitchbotModel.LOCK,
         SwitchbotModel.LOCK_LITE,
         SwitchbotModel.LOCK_LITE,
+        SwitchbotModel.LOCK_NANO,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION,
@@ -241,6 +321,7 @@ async def test_get_basic_info_no_lock_data(model: str):
     [
     [
         SwitchbotModel.LOCK,
         SwitchbotModel.LOCK,
         SwitchbotModel.LOCK_LITE,
         SwitchbotModel.LOCK_LITE,
+        SwitchbotModel.LOCK_NANO,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_ULTRA,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION,
@@ -916,6 +997,7 @@ async def test_half_lock_not_calibrated():
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_PRO,
         SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.LOCK_ULTRA_MAX,
         SwitchbotModel.UWB_LOCK,
         SwitchbotModel.UWB_LOCK,
+        SwitchbotModel.LOCK_NANO,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_VISION_PRO,
         SwitchbotModel.LOCK_PRO_WIFI,
         SwitchbotModel.LOCK_PRO_WIFI,

+ 78 - 0
tests/test_lock_nano_adv.py

@@ -0,0 +1,78 @@
+import pytest
+
+from switchbot import SwitchbotModel, parse_advertisement_data
+from switchbot.adv_parsers.lock import process_lock_nano
+from switchbot.const.lock import LockStatus
+from switchbot.devices.lock import SwitchbotLock
+
+from .test_adv_parser import generate_advertisement_data, generate_ble_device
+
+
+@pytest.mark.parametrize("pairing", [0, 1])
+def test_nano_discovery(pairing):
+    service_data = bytes([0, 0x80, 95, pairing, 0x12, 0x24, 0x88])
+    manufacturer_data = bytes.fromhex("b0e9fe112233 2a 88 fe df 73")
+    advertisement = generate_advertisement_data(
+        manufacturer_data={2409: manufacturer_data},
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": service_data},
+        rssi=-60,
+    )
+    result = parse_advertisement_data(
+        generate_ble_device("aa:bb:cc:dd:ee:ff", "Lock Nano"), advertisement
+    )
+    assert result.data["modelName"] == SwitchbotModel.LOCK_NANO
+    assert result.data["data"] == {
+        "sequence_number": 42,
+        "calibration": True,
+        "status": LockStatus.UNLOCKED,
+        "auto_lock_paused": True,
+        "night_latch": True,
+        "battery": 95,
+        "charging_status": 1,
+        "battery_charging": True,
+        "unlocked_alarm": True,
+        "low_temperature_alarm": True,
+        "power_alarm": True,
+        "battery_status": 1,
+        "charging_fault": True,
+    }
+
+
+@pytest.mark.parametrize(
+    ("flags", "battery", "expected"),
+    [
+        (0, 95, 0),
+        (0, 223, 1),
+        (1, 95, 2),
+        (1, 223, 3),
+    ],
+)
+def test_nano_charging_status(flags, battery, expected):
+    manufacturer_data = bytes.fromhex("b0e9fe112233 01 80") + bytes([flags, battery, 0])
+    result = process_lock_nano(None, manufacturer_data)
+    assert result["charging_status"] == expected
+    assert result["battery_charging"] == (expected == 1)
+    assert result["battery"] == 95
+
+
+@pytest.mark.parametrize("manufacturer_data", [None, b"", bytes(6), bytes(10)])
+def test_nano_truncated_advertisement(manufacturer_data):
+    assert process_lock_nano(None, manufacturer_data) == {}
+
+
+@pytest.mark.parametrize(("alarm_byte", "expected"), [(0, 0), (2, 1), (4, 2)])
+def test_nano_battery_status(alarm_byte, expected):
+    result = process_lock_nano(None, bytes(10) + bytes([alarm_byte]))
+    assert result["battery_status"] == expected
+    assert not result["charging_fault"]
+
+
+def test_nano_connected_layout():
+    assert SwitchbotLock._parse_lock_data(
+        bytes.fromhex("88 10 64 00 00 80"), SwitchbotModel.LOCK_NANO
+    ) == {
+        "calibration": True,
+        "status": LockStatus.UNLOCKED,
+        "unlocked_alarm": False,
+        "auto_lock_paused": False,
+    }