Explorar o código

feat (lock): allow locking and unlocking with a code

Tuya locks following the standard BLE lock template use an 8 digit
code for unlocking operations (and sometimes for locking as well).
This is encoded as base64 on dp 61 remote_no_dp_key, as:
  Byte 1: Action (0x00 = Lock, 0x01 = Unlock)
  Byte 2-3: User (0x0001 - 0x0064)
  Byte 4-11: Code (ASCII)
  Byte 12-13: Source (0x0000 = Unknown, 0x0001 = App, 0x0002 = Voice)
  Byte 14: Admin flag (0x00 = normal user, 0x01 = admin user)

The above format is supported as code_unlock in the config, using
hardcoded values for user=0x0001, Source=0x0000, Admin=0x00, but the
8 digit code needs to be extracted from elsewhere by the user and
passed in to the unlock method.

Related to Issue #1921 (slightly different, as the request there was
for setting temporary and offline passwords for manual use on the
device keypad, while this is using codes for remote unlocking).
Jason Rumney hai 3 meses
pai
achega
c1c677c0f4

+ 1 - 0
custom_components/tuya_local/devices/README.md

@@ -713,6 +713,7 @@ no information will be available about which specific credential was used to unl
 - **approve_unlock** (optional, boolean): a dp to unlock the lock in response to a request.
 - **request_intercom** (optional, integer): a dp to signal that a request has been made via intercom to unlock, the value should indicate the time remaining for approval.
 - **approve_intercom** (optional, boolean): a dp to unlock the lock in response to an intercom request.
+- **code_unlock** (optional, base64): a dp to unlock the lock by giving an 8 digit code. This corresponds in the Tuya info to `remote_no_dp_key` and has a specific format. The 8 digit key assigned to user 1 must be sent to unlock (and optionally lock) the lock.
 - **jammed** (optional, boolean): a dp to signal that the lock is jammed.
 
 ### `number`

+ 2 - 2
custom_components/tuya_local/devices/ailrinni_fingerprint_lock.yaml

@@ -88,10 +88,10 @@ entities:
         sensitive: true
         name: remote_no_pd_setkey
       - id: 61
-        type: string
+        type: base64
         optional: true
         sensitive: true
-        name: remote_no_dp_key
+        name: code_unlock
       - id: 62
         type: integer
         name: unlock_app

+ 2 - 3
custom_components/tuya_local/devices/ble_kb150a_lock.yaml

@@ -60,10 +60,9 @@ entities:
         optional: true
         persist: false
       - id: 61
-        type: string
-        name: remote_no_dp_key
+        type: base64
+        name: code_unlock
         optional: true
-        persist: false
         sensitive: true
       - id: 62
         type: integer

+ 4 - 2
custom_components/tuya_local/devices/ble_positivo_smart_fechadura.yaml

@@ -48,9 +48,11 @@ entities:
         type: string
         name: remote_no_pd_setkey
         optional: true
+        sensitive: true
       - id: 61
-        type: string
-        name: remote_no_dp_key
+        type: base64
+        name: code_unlock
+        sensitive: true
         optional: true
       - id: 62
         type: integer

+ 2 - 2
custom_components/tuya_local/devices/gainsboroughliberty_entrance_lock.yaml

@@ -97,10 +97,10 @@ entities:
         optional: true
         persist: false
       - id: 61
-        type: string
+        type: base64
         optional: true
         sensitive: true
-        name: remote_no_dp_key
+        name: code_unlock
       - id: 62
         type: integer
         name: unlock_app

+ 2 - 2
custom_components/tuya_local/devices/hornbill_y4_smart_lock.yaml

@@ -157,11 +157,11 @@ entities:
       # door opening and closing function be realized with smart speaker
       # products such as Alexa or Google Home.
       - id: 61
-        type: string
+        type: base64
         optional: true
         persist: false
         sensitive: true
-        name: remote_no_dp_key
+        name: code_unlock
       # [Report unlocking records] is used for the device to report the records
       # of remote unlocking via mobile phone.
       - id: 62

+ 2 - 2
custom_components/tuya_local/devices/nice_digi_lock.yaml

@@ -47,8 +47,8 @@ entities:
         optional: true
         sensitive: true
       - id: 61
-        type: string
-        name: remote_no_dp_key
+        type: base64
+        name: code_unlock
         optional: true
         sensitive: true
       - id: 62

+ 2 - 2
custom_components/tuya_local/devices/orion_dl033ha_lock.yaml

@@ -92,10 +92,10 @@ entities:
         optional: true
         persist: false
       - id: 61
-        type: string
+        type: base64
         optional: true
         sensitive: true
-        name: remote_no_dp_key
+        name: code_unlock
       - id: 62
         type: integer
         name: unlock_app

+ 2 - 2
custom_components/tuya_local/devices/otu_r1o1_lock.yaml

@@ -103,10 +103,10 @@ entities:
         sensitive: true
         name: remote_no_pd_setkey
       - id: 61
-        type: string
+        type: base64
         optional: true
         sensitive: true
-        name: remote_no_dp_key
+        name: code_unlock
       - id: 62
         type: integer
         optional: true

+ 2 - 2
custom_components/tuya_local/devices/primebras_athenas_lock.yaml

@@ -110,10 +110,10 @@ entities:
         optional: true
         persist: false
       - id: 61
-        type: string
+        type: base64
         optional: true
         sensitive: true
-        name: remote_no_dp_key
+        name: code_unlock
       - id: 62
         type: integer
         name: unlock_app

+ 2 - 2
custom_components/tuya_local/devices/raykube_a1promax_lock.yaml

@@ -44,9 +44,9 @@ entities:
         optional: true
         name: sync_method
       - id: 61
-        type: string
+        type: base64
         optional: true
-        name: remote_no_dp_key
+        name: code_unlock
       - id: 62
         type: integer
         optional: true

+ 1 - 1
custom_components/tuya_local/devices/xcase_nx4964_lockbox.yaml

@@ -63,7 +63,7 @@ entities:
         type: string
         optional: true
         sensitive: true
-        name: remote_no_dp_key
+        name: code_unlock
       - id: 62
         type: integer
         optional: true

+ 71 - 2
custom_components/tuya_local/lock.py

@@ -2,6 +2,8 @@
 Setup for different kinds of Tuya lock devices
 """
 
+from base64 import b64encode
+
 from homeassistant.components.lock import LockEntity, LockEntityFeature
 
 from .device import TuyaLocalDevice
@@ -9,6 +11,37 @@ from .entity import TuyaLocalEntity
 from .helpers.config import async_tuya_setup_platform
 from .helpers.device_config import TuyaEntityConfig
 
+# Remote Code unlocking protocol: 8 digit code set when paired by
+# remote_no_pd_setkey (typically dp 60), user can obtain by
+# eavesdropping cloud unlock messages.
+# Format in case this command can be supported outside of pairing process:
+# Request: Validity (1 byte 0 or 1), Member ID (2 bytes),
+#  Start time (4 byte unixtime), End time (4 byte unixtime),
+#  Usable times (2 bytes, 0=infinite), Key (8 bytes ASCII)
+
+# Same 8 digit ASCII code used along with binary member ID to generate
+# unlock command with remote_no_dp_key (typically dp 61)
+# Locking usually possible without code, but remote_no_dp_key seems
+# to also support locking with code.
+
+# Request: action (1 byte), Member (2 bytes 0-100), code (8 bytes ASCII),
+#    source (2 bytes)
+CODE_LOCK = 0x00
+CODE_UNLOCK = 0x01
+
+CODE_SRC_UNKNOWN = 0x0000
+CODE_SRC_APP = 0x0001
+CODE_SRC_VOICE = 0x0002
+
+# Reply: status (1 byte), Member ID (2 bytes 1 - 100)
+CODE_REPLY_SUCCESS = 0x00
+CODE_REPLY_FAIL = 0x01
+CODE_REPLY_PWD_ERROR = 0x02
+CODE_REPLY_TIMEOUT = 0x03
+CODE_REPLY_OUTOFHOURS = 0x04
+CODE_REPLY_WRONGCODE = 0x05
+CODE_REPLY_DOUBLELOCKED = 0x06
+
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
     config = {**config_entry.data, **config_entry.options}
@@ -51,6 +84,7 @@ class TuyaLocalLock(TuyaLocalEntity, LockEntity):
         self._unlock_ibeacon_dp = dps_map.pop("unlock_ibeacon", None)
         self._req_unlock_dp = dps_map.pop("request_unlock", None)
         self._approve_unlock_dp = dps_map.pop("approve_unlock", None)
+        self._code_unlock_dp = dps_map.pop("code_unlock", None)
         self._req_intercom_dp = dps_map.pop("request_intercom", None)
         self._approve_intercom_dp = dps_map.pop("approve_intercom", None)
         self._jam_dp = dps_map.pop("jammed", None)
@@ -99,6 +133,13 @@ class TuyaLocalLock(TuyaLocalEntity, LockEntity):
         if self._jam_dp:
             return self._jam_dp.get_value(self._device)
 
+    @property
+    def code_format(self):
+        """Return the code format of the lock."""
+        if self._code_unlock_dp:
+            return r".{8}"
+        return None
+
     def unlocker_id(self, dp, type):
         if dp:
             unlock = dp.get_value(self._device)
@@ -136,14 +177,30 @@ class TuyaLocalLock(TuyaLocalEntity, LockEntity):
 
     async def async_lock(self, **kwargs):
         """Lock the lock."""
-        if self._lock_dp:
+        if self._lock_dp and not self._lock_dp.readonly:
             await self._lock_dp.async_set_value(self._device, True)
+        elif self._code_unlock_dp:
+            code = kwargs.get("code")
+            if not code:
+                raise ValueError("Code required to lock")
+            msg = self.build_code_unlock_msg(
+                CODE_LOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
+            )
+            await self._code_unlock_dp.async_set_value(self._device, msg)
         else:
             raise NotImplementedError()
 
     async def async_unlock(self, **kwargs):
         """Unlock the lock."""
-        if self._lock_dp:
+        if self._code_unlock_dp:
+            code = kwargs.get("code")
+            if not code:
+                raise ValueError("Code required to unlock")
+            msg = self.build_code_unlock_msg(
+                CODE_UNLOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
+            )
+            await self._code_unlock_dp.async_set_value(self._device, msg)
+        elif self._lock_dp and not self._lock_dp.readonly:
             await self._lock_dp.async_set_value(self._device, False)
         elif self._approve_unlock_dp:
             if self._req_unlock_dp and not self._req_unlock_dp.get_value(self._device):
@@ -162,3 +219,15 @@ class TuyaLocalLock(TuyaLocalEntity, LockEntity):
         """Open the door latch."""
         if self._open_dp:
             await self._open_dp.async_set_value(self._device, True)
+
+    def build_code_unlock_msg(self, action, member_id, code, source=CODE_SRC_UNKNOWN):
+        """Generate the unlock code message."""
+        if len(code) != 8 or not code.isascii():
+            raise ValueError("Code must be 8 ASCII characters")
+        msg = bytearray()
+        msg.append(action)
+        msg += member_id.to_bytes(2, "big")
+        msg += code.encode("ascii")
+        msg += source.to_bytes(2, "big")
+        msg += b"\x00"  # ordinary user (0x01 is admin)
+        return b64encode(msg).decode("utf-8")

+ 6 - 0
tests/const.py

@@ -1701,3 +1701,9 @@ MUSTOOL_MT15MT29_AIRBOX_PAYLOAD = {
     "118": True,
     # last 3 must be true for the time entities to be enabled
 }
+
+AILRINNI_FINGERPRINTLOCK_PAYLOAD = {
+    "8": 86,
+    "31": "mute",
+    "64": "1712614183",
+}

+ 61 - 0
tests/devices/test_ailrinni_fplock.py

@@ -0,0 +1,61 @@
+from base64 import b64encode
+
+from ..const import AILRINNI_FINGERPRINTLOCK_PAYLOAD
+from ..helpers import assert_device_properties_set
+from .base_device_tests import TuyaDeviceTestCase
+
+BATTERY_DP = "8"
+UNLOCK_FP_DP = "12"
+UNLOCK_PWD_DP = "13"
+UNLOCK_DYN_DP = "14"
+UNLOCK_BLE_DP = "19"
+ALERT_DP = "21"
+VOLUME_DP = "31"
+LOCK_STATE_DP = "47"
+TMPPW_CREATE_DP = "51"
+TMPPW_DELETE_DP = "52"
+TMPPW_MODIFY_DP = "53"
+UNLOCK_TMP_DP = "55"
+CODESET_DP = "60"
+CODE_UNLOCK_DP = "61"
+UNLOCK_APP_DP = "62"
+UNLOCK_VOICE_DP = "63"
+UNLOCK_OFFLINE_DP = "67"
+
+
+class TestAilrinniFingerprintLock(TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig(
+            "ailrinni_fingerprint_lock.yaml", AILRINNI_FINGERPRINTLOCK_PAYLOAD
+        )
+        self.subject = self.entities.get("lock")
+        self.mark_secondary(
+            [
+                "sensor_alert",
+                "number_lock_volume",
+            ]
+        )
+
+    async def test_lock(self):
+        """Test locking the lock."""
+        expected = b64encode(
+            b"\x00" + b"\x00\x01" + b"12345678" + b"\x00\x00\x00"
+        ).decode("utf-8")
+        async with assert_device_properties_set(
+            self.subject._device,
+            {CODE_UNLOCK_DP: expected},
+        ):
+            await self.subject.async_lock(code="12345678")
+
+    async def test_unlock(self):
+        """Test unlocking the lock."""
+        expected = b64encode(
+            b"\x01" + b"\x00\x01" + b"12345678" + b"\x00\x00\x00"
+        ).decode("utf-8")
+        async with assert_device_properties_set(
+            self.subject._device,
+            {CODE_UNLOCK_DP: expected},
+        ):
+            await self.subject.async_unlock(code="12345678")

+ 1 - 0
tests/test_device_config.py

@@ -234,6 +234,7 @@ KNOWN_DPS = {
         "optional": [
             "lock",
             "lock_state",
+            "code_unlock",
             {"and": ["request_unlock", "approve_unlock"]},
             {"and": ["request_intercom", "approve_intercom"]},
             "unlock_fingerprint",