Browse Source

Add support for switchbot keypad vision (#436)

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 1 day ago
parent
commit
c9be995fca

+ 2 - 0
switchbot/__init__.py

@@ -45,6 +45,7 @@ from .devices.device import (
 from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
 from .devices.fan import SwitchbotFan
 from .devices.humidifier import SwitchbotHumidifier
+from .devices.keypad_vision import SwitchbotKeypadVision
 from .devices.light_strip import (
     SwitchbotLightStrip,
     SwitchbotRgbicLight,
@@ -97,6 +98,7 @@ __all__ = [
     "SwitchbotFan",
     "SwitchbotGarageDoorOpener",
     "SwitchbotHumidifier",
+    "SwitchbotKeypadVision",
     "SwitchbotLightStrip",
     "SwitchbotLock",
     "SwitchbotModel",

+ 25 - 0
switchbot/adv_parser.py

@@ -26,6 +26,7 @@ from .adv_parsers.hub3 import process_hub3
 from .adv_parsers.hubmini_matter import process_hubmini_matter
 from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
 from .adv_parsers.keypad import process_wokeypad
+from .adv_parsers.keypad_vision import process_keypad_vision, process_keypad_vision_pro
 from .adv_parsers.leak import process_leak
 from .adv_parsers.light_strip import process_light, process_rgbic_light, process_wostrip
 from .adv_parsers.lock import (
@@ -730,6 +731,30 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_art_frame,
         "manufacturer_id": 2409,
     },
+    b"\x00\x11\x03x": {
+        "modelName": SwitchbotModel.KEYPAD_VISION,
+        "modelFriendlyName": "Keypad Vision",
+        "func": process_keypad_vision,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x11\x03x": {
+        "modelName": SwitchbotModel.KEYPAD_VISION,
+        "modelFriendlyName": "Keypad Vision",
+        "func": process_keypad_vision,
+        "manufacturer_id": 2409,
+    },
+    b"\x00\x11Q\x98": {
+        "modelName": SwitchbotModel.KEYPAD_VISION_PRO,
+        "modelFriendlyName": "Keypad Vision Pro",
+        "func": process_keypad_vision_pro,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x11Q\x98": {
+        "modelName": SwitchbotModel.KEYPAD_VISION_PRO,
+        "modelFriendlyName": "Keypad Vision Pro",
+        "func": process_keypad_vision_pro,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR: defaultdict[SwitchbotModel, list[str | bytes]] = defaultdict(

+ 79 - 0
switchbot/adv_parsers/keypad_vision.py

@@ -0,0 +1,79 @@
+"""Keypad Vision (Pro) device data parsers."""
+
+import logging
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def process_common_mfr_data(mfr_data: bytes | None) -> dict[str, bool | int]:
+    """Process common Keypad Vision (Pro) manufacturer data."""
+    if mfr_data is None:
+        return {}
+
+    sequence_number = mfr_data[6]
+    battery_charging = bool(mfr_data[7] & 0b10000000)
+    battery = mfr_data[7] & 0b01111111
+    lockout_alarm = bool(mfr_data[8] & 0b00000001)
+    tamper_alarm = bool(mfr_data[8] & 0b00000010)
+    duress_alarm = bool(mfr_data[8] & 0b00000100)
+    low_temperature = bool(mfr_data[8] & 0b10000000)
+    high_temperature = bool(mfr_data[8] & 0b01000000)
+    doorbell = bool(mfr_data[12] & 0b00001000)
+
+    return {
+        "sequence_number": sequence_number,
+        "battery_charging": battery_charging,
+        "battery": battery,
+        "lockout_alarm": lockout_alarm,
+        "tamper_alarm": tamper_alarm,
+        "duress_alarm": duress_alarm,
+        "low_temperature": low_temperature,
+        "high_temperature": high_temperature,
+        "doorbell": doorbell,
+    }
+
+
+def process_keypad_vision(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int | str]:
+    """Process Keypad Vision data."""
+    result = process_common_mfr_data(mfr_data)
+
+    if not result:
+        return {}
+
+    pir_triggered_level = mfr_data[13] & 0x03
+
+    result.update(
+        {
+            "pir_triggered_level": pir_triggered_level,
+        }
+    )
+
+    _LOGGER.debug("Keypad Vision mfr data: %s, result: %s", mfr_data.hex(), result)
+
+    return result
+
+
+def process_keypad_vision_pro(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int | str]:
+    """Process Keypad Vision Pro data."""
+    result = process_common_mfr_data(mfr_data)
+
+    if not result:
+        return {}
+
+    radar_triggered_level = mfr_data[13] & 0x03
+    radar_triggered_distance = (mfr_data[13] >> 2) & 0x03
+
+    result.update(
+        {
+            "radar_triggered_level": radar_triggered_level,
+            "radar_triggered_distance": radar_triggered_distance,
+        }
+    )
+
+    _LOGGER.debug("Keypad Vision Pro mfr data: %s, result: %s", mfr_data.hex(), result)
+
+    return result

+ 2 - 0
switchbot/const/__init__.py

@@ -103,6 +103,8 @@ class SwitchbotModel(StrEnum):
     S20_VACUUM = "S20 Vacuum"
     PRESENCE_SENSOR = "Presence Sensor"
     ART_FRAME = "Art Frame"
+    KEYPAD_VISION = "Keypad Vision"
+    KEYPAD_VISION_PRO = "Keypad Vision Pro"
 
 
 __all__ = [

+ 0 - 13
switchbot/devices/base_cover.py

@@ -43,19 +43,6 @@ class SwitchbotBaseCover(SwitchbotDevice):
         self._is_opening: bool = False
         self._is_closing: bool = False
 
-    async def _send_multiple_commands(self, keys: list[str]) -> bool:
-        """
-        Send multiple commands to device.
-
-        Since we current have no way to tell which command the device
-        needs we send both.
-        """
-        final_result = False
-        for key in keys:
-            result = await self._send_command(key)
-            final_result |= self._check_command_result(result, 0, {1})
-        return final_result
-
     @update_after_operation
     async def stop(self) -> bool:
         """Send stop command to device."""

+ 0 - 13
switchbot/devices/base_light.py

@@ -119,19 +119,6 @@ class SwitchbotBaseLight(SwitchbotDevice):
             self._override_state({"effect": effect})
         return result
 
-    async def _send_multiple_commands(self, keys: list[str]) -> bool:
-        """
-        Send multiple commands to device.
-
-        Since we current have no way to tell which command the device
-        needs we send both.
-        """
-        final_result = False
-        for key in keys:
-            result = await self._send_command(key)
-            final_result |= self._check_command_result(result, 0, {1})
-        return final_result
-
     async def _get_multi_commands_results(
         self, commands: list[str]
     ) -> tuple[bytes, bytes] | None:

+ 26 - 0
switchbot/devices/device.py

@@ -931,6 +931,32 @@ class SwitchbotDevice(SwitchbotBaseDevice):
         super().update_from_advertisement(advertisement)
         self._set_advertisement_data(advertisement)
 
+    async def _send_multiple_commands(self, keys: list[str]) -> bool:
+        """
+        Send multiple commands to device.
+
+        Returns True if any command succeeds. Used when we don't know
+        which command the device needs, so we send multiple and consider
+        it successful if any one works.
+        """
+        final_result = False
+        for key in keys:
+            result = await self._send_command(key)
+            final_result |= self._check_command_result(result, 0, {1})
+        return final_result
+
+    async def _send_command_sequence(self, keys: list[str]) -> bool:
+        """
+        Send a sequence of commands to device where all must succeed.
+
+        Returns True only if all commands succeed.
+        """
+        for key in keys:
+            result = await self._send_command(key)
+            if not self._check_command_result(result, 0, {1}):
+                return False
+        return True
+
 
 class SwitchbotEncryptedDevice(SwitchbotDevice):
     """A Switchbot device that uses encryption."""

+ 167 - 0
switchbot/devices/keypad_vision.py

@@ -0,0 +1,167 @@
+"""Keypad Vision (Pro) device handling."""
+
+import logging
+import re
+from typing import Any
+
+from bleak.backends.device import BLEDevice
+
+from ..const import SwitchbotModel
+from .device import SwitchbotEncryptedDevice, SwitchbotSequenceDevice
+
+PASSWORD_RE = re.compile(r"^\d{6,12}$")
+COMMAND_GET_PASSWORD_COUNT = "570F530100"
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class SwitchbotKeypadVision(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
+    """Representation of a Switchbot Keypad Vision (Pro) device."""
+
+    def __init__(
+        self,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        model: SwitchbotModel,
+        **kwargs: Any,
+    ) -> None:
+        """Initialize Keypad Vision (Pro) device."""
+        super().__init__(device, key_id, encryption_key, model, **kwargs)
+
+    @classmethod
+    async def verify_encryption_key(
+        cls,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        model: SwitchbotModel,
+        **kwargs: Any,
+    ) -> bool:
+        return await super().verify_encryption_key(
+            device, key_id, encryption_key, model, **kwargs
+        )
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info()):
+            return None
+        _LOGGER.debug("Raw model %s basic info data: %s", self._model, _data.hex())
+
+        battery = _data[1] & 0x7F
+        firmware = _data[2] / 10.0
+        hardware = _data[3]
+        support_fingerprint = _data[4]
+        lock_button_enabled = bool(_data[5] != 1)
+        tamper_alarm_enabled = bool(_data[9])
+        backlight_enabled = bool(_data[10] != 1)
+        backlight_level = _data[11]
+        prompt_tone_enabled = bool(_data[12] != 1)
+
+        if self._model == SwitchbotModel.KEYPAD_VISION:
+            battery_charging = bool((_data[14] & 0x06) >> 1)
+        else:
+            battery_charging = bool((_data[14] & 0x0E) >> 1)
+
+        result = {
+            "battery": battery,
+            "firmware": firmware,
+            "hardware": hardware,
+            "support_fingerprint": support_fingerprint,
+            "lock_button_enabled": lock_button_enabled,
+            "tamper_alarm_enabled": tamper_alarm_enabled,
+            "backlight_enabled": backlight_enabled,
+            "backlight_level": backlight_level,
+            "prompt_tone_enabled": prompt_tone_enabled,
+            "battery_charging": battery_charging,
+        }
+
+        _LOGGER.debug("%s basic info: %s", self._model, result)
+        return result
+
+    def _check_password_rules(self, password: str) -> None:
+        """Check if the password compliant with the rules."""
+        if not PASSWORD_RE.fullmatch(password):
+            raise ValueError("Password must be 6-12 digits.")
+
+    def _build_password_payload(self, password: str) -> bytes:
+        """Build password payload."""
+        pwd_bytes = bytes(int(ch) for ch in password)
+        pwd_length = len(pwd_bytes)
+
+        payload = bytearray()
+        payload.append(0xFF)
+        payload.append(0x00)
+        payload.append(pwd_length)
+        payload.extend(pwd_bytes)
+
+        return bytes(payload)
+
+    def _build_add_password_cmd(self, password: str) -> list[str]:
+        """Build command to add a password."""
+        cmd_header = bytes.fromhex("570F520202")
+
+        payload = self._build_password_payload(password)
+
+        max_payload = 11
+
+        chunks = [
+            payload[i : i + max_payload] for i in range(0, len(payload), max_payload)
+        ]
+        total = len(chunks)
+        cmds: list[str] = []
+
+        for idx, chunk in enumerate(chunks):
+            packet_info = ((total & 0x0F) << 4) | (idx & 0x0F)
+
+            cmd = bytearray()
+            cmd.extend(cmd_header)
+            cmd.append(packet_info)
+            cmd.extend(chunk)
+
+            cmds.append(cmd.hex().upper())
+
+        _LOGGER.debug(
+            "device: %s add password commands: %s", self._device.address, cmds
+        )
+
+        return cmds
+
+    async def add_password(self, password: str) -> bool:
+        """Add a password to the Keypad Vision (Pro)."""
+        self._check_password_rules(password)
+        cmds = self._build_add_password_cmd(password)
+        return await self._send_command_sequence(cmds)
+
+    async def get_password_count(self) -> dict[str, int] | None:
+        """Get the number of passwords stored in the Keypad Vision (Pro)."""
+        if not (_data := await self._send_command(COMMAND_GET_PASSWORD_COUNT)):
+            return None
+        _LOGGER.debug("Raw model %s password count data: %s", self._model, _data.hex())
+
+        pin = _data[1]
+        nfc = _data[2]
+        fingerprint = _data[3]
+        duress_pin = _data[4]
+        duress_fingerprint = _data[5]
+
+        result = {
+            "pin": pin,
+            "nfc": nfc,
+            "fingerprint": fingerprint,
+            "duress_pin": duress_pin,
+            "duress_fingerprint": duress_fingerprint,
+        }
+
+        if self._model == SwitchbotModel.KEYPAD_VISION_PRO:
+            face = _data[6]
+            palm_vein = _data[7]
+            result.update(
+                {
+                    "face": face,
+                    "palm_vein": palm_vein,
+                }
+            )
+
+        _LOGGER.debug("%s password count: %s", self._model, result)
+        return result

+ 41 - 0
tests/__init__.py

@@ -120,3 +120,44 @@ ART_FRAME_INFO = AdvTestCase(
     "Art Frame",
     SwitchbotModel.ART_FRAME,
 )
+
+KEYPAD_VISION_INFO = AdvTestCase(
+    b"\xb0\xe9\xfe\xe5\x04\x1e\xac\xdf\x00\x00\x00\x00\x00\x02",
+    b"\x00\x00_\x01\x11\x03x",
+    {
+        "battery": 95,
+        "battery_charging": True,
+        "doorbell": False,
+        "duress_alarm": False,
+        "high_temperature": False,
+        "lockout_alarm": False,
+        "low_temperature": False,
+        "pir_triggered_level": 2,
+        "sequence_number": 172,
+        "tamper_alarm": False,
+    },
+    b"\x01\x11\x03x",
+    "Keypad Vision",
+    SwitchbotModel.KEYPAD_VISION,
+)
+
+KEYPAD_VISION_PRO_INFO = AdvTestCase(
+    b"\xb0\xe9\xfe\xde\xb6\x8c+`\x00\x00\x00\x00\x00\x002",
+    b"\x00\x00`\x01\x11Q\x98",
+    {
+        "battery": 96,
+        "battery_charging": False,
+        "doorbell": False,
+        "duress_alarm": False,
+        "high_temperature": False,
+        "lockout_alarm": False,
+        "low_temperature": False,
+        "radar_triggered_distance": 0,
+        "radar_triggered_level": 0,
+        "sequence_number": 43,
+        "tamper_alarm": False,
+    },
+    b"\x01\x11Q\x98",
+    "Keypad Vision Pro",
+    SwitchbotModel.KEYPAD_VISION_PRO,
+)

+ 94 - 0
tests/test_adv_parser.py

@@ -3549,6 +3549,45 @@ def test_humidifer_with_empty_data() -> None:
             "Art Frame",
             SwitchbotModel.ART_FRAME,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xe5\x04\x1e\xac\xdf\x00\x00\x00\x00\x00\x02",
+            b"\x00\x00_\x01\x11\x03x",
+            {
+                "battery": 95,
+                "battery_charging": True,
+                "doorbell": False,
+                "duress_alarm": False,
+                "high_temperature": False,
+                "lockout_alarm": False,
+                "low_temperature": False,
+                "pir_triggered_level": 2,
+                "sequence_number": 172,
+                "tamper_alarm": False,
+            },
+            b"\x01\x11\x03x",
+            "Keypad Vision",
+            SwitchbotModel.KEYPAD_VISION,
+        ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xde\xb6\x8c+`\x00\x00\x00\x00\x00\x002",
+            b"\x00\x00`\x01\x11Q\x98",
+            {
+                "battery": 96,
+                "battery_charging": False,
+                "doorbell": False,
+                "duress_alarm": False,
+                "high_temperature": False,
+                "lockout_alarm": False,
+                "low_temperature": False,
+                "radar_triggered_distance": 0,
+                "radar_triggered_level": 0,
+                "sequence_number": 43,
+                "tamper_alarm": False,
+            },
+            b"\x01\x11Q\x98",
+            "Keypad Vision Pro",
+            SwitchbotModel.KEYPAD_VISION_PRO,
+        ),
     ],
 )
 def test_adv_active(test_case: AdvTestCase) -> None:
@@ -3849,6 +3888,45 @@ def test_adv_active(test_case: AdvTestCase) -> None:
             "Art Frame",
             SwitchbotModel.ART_FRAME,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xe5\x04\x1e\xac\xdf\x00\x00\x00\x00\x00\x02",
+            None,
+            {
+                "battery": 95,
+                "battery_charging": True,
+                "doorbell": False,
+                "duress_alarm": False,
+                "high_temperature": False,
+                "lockout_alarm": False,
+                "low_temperature": False,
+                "pir_triggered_level": 2,
+                "sequence_number": 172,
+                "tamper_alarm": False,
+            },
+            b"\x00\x11\x03x",
+            "Keypad Vision",
+            SwitchbotModel.KEYPAD_VISION,
+        ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xde\xb6\x8c+`\x00\x00\x00\x00\x00\x002",
+            None,
+            {
+                "battery": 96,
+                "battery_charging": False,
+                "doorbell": False,
+                "duress_alarm": False,
+                "high_temperature": False,
+                "lockout_alarm": False,
+                "low_temperature": False,
+                "radar_triggered_distance": 0,
+                "radar_triggered_level": 0,
+                "sequence_number": 43,
+                "tamper_alarm": False,
+            },
+            b"\x00\x11Q\x98",
+            "Keypad Vision Pro",
+            SwitchbotModel.KEYPAD_VISION_PRO,
+        ),
     ],
 )
 def test_adv_passive(test_case: AdvTestCase) -> None:
@@ -4054,6 +4132,22 @@ def test_adv_passive(test_case: AdvTestCase) -> None:
             "Art Frame",
             SwitchbotModel.ART_FRAME,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x00_\x01\x11\x03x",
+            {},
+            b"\x01\x11\x03x",
+            "Keypad Vision",
+            SwitchbotModel.KEYPAD_VISION,
+        ),
+        AdvTestCase(
+            None,
+            b"\x00\x00`\x01\x11Q\x98",
+            {},
+            b"\x01\x11Q\x98",
+            "Keypad Vision Pro",
+            SwitchbotModel.KEYPAD_VISION_PRO,
+        ),
     ],
 )
 def test_adv_with_empty_data(test_case: AdvTestCase) -> None:

+ 39 - 2
tests/test_device.py

@@ -4,7 +4,7 @@ from __future__ import annotations
 
 import logging
 from typing import Any
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import aiohttp
 import pytest
@@ -16,7 +16,13 @@ from switchbot.const import (
     SwitchbotAuthenticationError,
     SwitchbotModel,
 )
-from switchbot.devices.device import SwitchbotBaseDevice, _extract_region
+from switchbot.devices.device import (
+    SwitchbotBaseDevice,
+    SwitchbotDevice,
+    _extract_region,
+)
+
+from .test_adv_parser import generate_ble_device
 
 
 @pytest.fixture
@@ -377,3 +383,34 @@ def test_extract_region() -> None:
 
     # Test with empty dict
     assert _extract_region({}) == "us"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("commands", "results", "final_result"),
+    [
+        # All fail -> False
+        (("command1", "command2"), [(b"\x01", False), (None, False)], False),
+        # First fails -> False (short-circuits, second not called)
+        (("command1", "command2"), [(b"\x01", False)], False),
+        # First succeeds, second fails -> False
+        (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], False),
+        # All succeed -> True
+        (("command1", "command2"), [(b"\x01", True), (b"\x01", True)], True),
+    ],
+)
+async def test_send_command_sequence(
+    commands: tuple[str, ...],
+    results: list[tuple[bytes | None, bool]],
+    final_result: bool,
+) -> None:
+    """Test sending command sequence where all must succeed."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = SwitchbotDevice(ble_device)
+
+    device._send_command = AsyncMock(side_effect=[r[0] for r in results])
+    device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
+
+    result = await device._send_command_sequence(list(commands))
+
+    assert result is final_result

+ 259 - 0
tests/test_keypad_vision.py

@@ -0,0 +1,259 @@
+"""Test keypad vision series device parsing and functionality."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import SwitchBotAdvertisement
+from switchbot.devices.device import SwitchbotEncryptedDevice
+from switchbot.devices.keypad_vision import (
+    COMMAND_GET_PASSWORD_COUNT,
+    SwitchbotKeypadVision,
+)
+
+from . import KEYPAD_VISION_INFO, KEYPAD_VISION_PRO_INFO
+from .test_adv_parser import AdvTestCase, generate_ble_device
+
+
+def create_device_for_command_testing(
+    adv_info: AdvTestCase,
+    init_data: dict | None = None,
+):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = SwitchbotKeypadVision(
+        ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=adv_info.modelName
+    )
+
+    device._send_command = AsyncMock()
+    device._send_command_sequence = AsyncMock()
+    device.update = AsyncMock()
+    device.update_from_advertisement(
+        make_advertisement_data(ble_device, adv_info, init_data)
+    )
+    return device
+
+
+def make_advertisement_data(
+    ble_device: BLEDevice, adv_info: AdvTestCase, init_data: dict | None = None
+):
+    """Set advertisement data with defaults."""
+    if init_data is None:
+        init_data = {}
+
+    return SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": adv_info.service_data,
+            "data": adv_info.data | init_data,
+            "isEncrypted": False,
+            "model": adv_info.model,
+            "modelFriendlyName": adv_info.modelFriendlyName,
+            "modelName": adv_info.modelName,
+        }
+        | init_data,
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("adv_info"),
+    [
+        (KEYPAD_VISION_INFO),
+        (KEYPAD_VISION_PRO_INFO),
+    ],
+)
+async def test_get_basic_info_none(adv_info: AdvTestCase) -> None:
+    """Test getting basic info returns None when no data."""
+    device = create_device_for_command_testing(adv_info)
+    device._get_basic_info = AsyncMock(return_value=None)
+
+    info = await device.get_basic_info()
+    assert info is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("adv_info", "basic_info", "result"),
+    [
+        (
+            KEYPAD_VISION_INFO,
+            b"\x01_\x18\x16\x01\x02\x00\n\x01\x02\x03\x05\x02\x00\x01\x00",
+            [95, 2.4, 22, 1, True, True, True, 5, True, False],
+        ),
+        (
+            KEYPAD_VISION_PRO_INFO,
+            b"\x01_\x0b\x18\x01\x02\x00\n\x01\x02\x03\x05\x02\x00\x03\x00",
+            [95, 1.1, 24, 1, True, True, True, 5, True, True],
+        ),
+    ],
+)
+async def test_get_basic_info(
+    adv_info: AdvTestCase, basic_info: bytes, result: dict
+) -> None:
+    """Test getting basic info from Keypad Vision devices."""
+    device = create_device_for_command_testing(adv_info)
+    device._get_basic_info = AsyncMock(return_value=basic_info)
+
+    info = await device.get_basic_info()
+    assert info["battery"] == result[0]
+    assert info["firmware"] == result[1]
+    assert info["hardware"] == result[2]
+    assert info["support_fingerprint"] == result[3]
+    assert info["lock_button_enabled"] == result[4]
+    assert info["tamper_alarm_enabled"] == result[5]
+    assert info["backlight_enabled"] == result[6]
+    assert info["backlight_level"] == result[7]
+    assert info["prompt_tone_enabled"] == result[8]
+    assert info["battery_charging"] == result[9]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "adv_info",
+    [
+        KEYPAD_VISION_INFO,
+        KEYPAD_VISION_PRO_INFO,
+    ],
+)
+async def test_add_invalid_password(adv_info: AdvTestCase) -> None:
+    """Test adding an invalid password raises ValueError."""
+    device = create_device_for_command_testing(adv_info)
+
+    invalid_passwords = ["123", "abcdef", "1234567890123", "12 3456", "passw0rd!"]
+
+    for password in invalid_passwords:
+        with pytest.raises(ValueError, match=r"Password must be 6-12 digits."):
+            await device.add_password(password)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "adv_info",
+    [
+        KEYPAD_VISION_INFO,
+        KEYPAD_VISION_PRO_INFO,
+    ],
+)
+@pytest.mark.parametrize(
+    ("password", "expected_payload"),
+    [
+        (
+            "123456",
+            ["570F52020210FF0006010203040506"],
+        ),
+        (
+            "123456789012",
+            ["570F52020220FF000C0102030405060708", "570F5202022109000102"],
+        ),
+    ],
+)
+async def test_add_password(
+    adv_info: AdvTestCase, password: str, expected_payload: list[str]
+) -> None:
+    """Test adding a valid password sends correct command."""
+    device = create_device_for_command_testing(adv_info)
+
+    await device.add_password(password)
+
+    device._send_command_sequence.assert_awaited_once_with(expected_payload)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "adv_info",
+    [
+        KEYPAD_VISION_INFO,
+        KEYPAD_VISION_PRO_INFO,
+    ],
+)
+async def test_get_password_count_no_response(adv_info: AdvTestCase) -> None:
+    """Test getting password count returns None when no response."""
+    device = create_device_for_command_testing(adv_info)
+
+    device._send_command.return_value = None
+
+    result = await device.get_password_count()
+    device._send_command.assert_awaited_once_with(COMMAND_GET_PASSWORD_COUNT)
+
+    assert result is None
+
+
+@pytest.mark.asyncio
+async def test_get_password_count_for_keypad_vision_pro() -> None:
+    """Test getting password count for Keypad Vision Pro."""
+    device = create_device_for_command_testing(KEYPAD_VISION_PRO_INFO)
+
+    device._send_command.return_value = bytes(
+        [0x01, 0x05, 0x02, 0x03, 0x00, 0x02, 0x01, 0x00]
+    )
+
+    result = await device.get_password_count()
+    device._send_command.assert_awaited_once_with(COMMAND_GET_PASSWORD_COUNT)
+
+    assert result == {
+        "pin": 5,
+        "nfc": 2,
+        "fingerprint": 3,
+        "duress_pin": 0,
+        "duress_fingerprint": 2,
+        "face": 1,
+        "palm_vein": 0,
+    }
+
+
+@pytest.mark.asyncio
+async def test_get_password_count_for_keypad_vision() -> None:
+    """Test getting password count for Keypad Vision."""
+    device = create_device_for_command_testing(KEYPAD_VISION_INFO)
+
+    device._send_command.return_value = bytes([0x01, 0x03, 0x02, 0x01, 0x01, 0x00])
+
+    result = await device.get_password_count()
+    device._send_command.assert_awaited_once_with(COMMAND_GET_PASSWORD_COUNT)
+
+    assert result == {
+        "pin": 3,
+        "nfc": 2,
+        "fingerprint": 1,
+        "duress_pin": 1,
+        "duress_fingerprint": 0,
+    }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "adv_info",
+    [
+        KEYPAD_VISION_INFO,
+        KEYPAD_VISION_PRO_INFO,
+    ],
+)
+@patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
+async def test_verify_encryption_key(
+    mock_parent_verify: AsyncMock, adv_info: AdvTestCase
+):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    key_id = "ff"
+    encryption_key = "ffffffffffffffffffffffffffffffff"
+
+    mock_parent_verify.return_value = True
+
+    result = await SwitchbotKeypadVision.verify_encryption_key(
+        device=ble_device,
+        key_id=key_id,
+        encryption_key=encryption_key,
+        model=adv_info.modelName,
+    )
+
+    mock_parent_verify.assert_awaited_once_with(
+        ble_device,
+        key_id,
+        encryption_key,
+        adv_info.modelName,
+    )
+
+    assert result is True