Summary
lock.py currently assumes the 8 character remote unlock key can only be obtained
by eavesdropping on cloud unlock messages:
# 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.
That turns out not to be necessary. The device accepts a key registration over the
LAN with no authentication beyond the local key, so Home Assistant can simply
register its own key and immediately unlock with it. This is not a workaround - it is
what the Tuya cloud itself does: watching the dp traffic during an app unlock shows the
cloud registering a fresh key on remote_no_pd_setkey microseconds before sending the
unlock on remote_no_dp_key.
The result is fully local remote unlocking on a device family where lock.unlock
currently raises NotImplementedError.
Device
- Config:
sboardiii_weigand_lock.yaml (Secukey Sboard III mini / BSTUOKEY, product id 5k8h97qska6pf5cm)
- Wi-Fi access controller with RFID keypad, protocol 3.3
remote_no_pd_setkey = dp 8, remote_no_dp_key = dp 9
- The device answers
DP_QUERY with json obj data unvalid, so it never reports any
state on poll - everything below was learned from push messages.
Protocol, as observed
Both dps are raw/base64. Big endian.
remote_no_pd_setkey (dp 8)
|
|
| request |
[valid(1)][member(2)][valid_from(4)][valid_to(4)][uses(2)][key(8 ASCII)] |
| reply |
[status(1)][member(2)], status 0x00 = success |
remote_no_dp_key (dp 9)
|
|
| request |
[action(1)][member(2)][key(8 ASCII)][source(2)] |
| reply |
[status(1)][member(2)][...], 0x00 = success, 0x05 = wrong key |
This matches the format already documented in lock.py, and build_code_unlock_msg()
produces the correct unlock request as is.
Cloud unlock captured from the LAN side
A remote unlock triggered from the Smart Life app, seen locally:
remote_no_pd_setkey = AAAB -> 00 00 01 status 0x00, member 1 (cloud registered its key)
remote_no_dp_key = AAABAAA= -> 00 00 01 00 00 status 0x00, member 1 (unlock succeeded)
closed_open_state = AQAB door opened
Note the device only ever publishes the reply on these dps, never the key itself, so
eavesdropping the key locally is in fact impossible - which makes the "obtain by
eavesdropping" note in the source unactionable. Registering our own key is.
Registering our own key and unlocking
-> {"8": "<valid=1, member=2, 0, 0x7FFFFFFF, 0xFFFF, key>", "9": "<action=1, member=2, key, source=1>"}
<- {"8": "AAAC"} 00 00 02 key accepted, member 2
<- {"9": "AAACr2w="} 00 00 02 ... unlock succeeded
<- {"35": "AQAB"} door opened
Round trip is about 600 ms.
Two things that matter for the implementation
1. There is a single key slot. Registering a key replaces whatever the cloud
registered last, and vice versa. This is harmless in both directions, because both
sides register their key immediately before unlocking. But it does mean a key
registered once and used later will eventually fail with 0x05 - on a shared door
every unlock by another resident invalidates it.
2. The registration and the unlock must be sent in one message. If they are sent as
two separate messages, the cloud can slip its own registration in between (observed
consistently, roughly 400-500 ms after ours) and the unlock then fails with 0x05:
18:16:05.489 unlock -> 0x05
18:16:06.198 <- dp 8 = AAAB cloud re-registered its key (member 1)
18:16:07.063 -> dp 8 = our key accepted (AAAC)
18:16:07.509 <- dp 8 = AAAB cloud overwrote it again, 0.45 s later
18:16:07.763 unlock -> 0x05
Passing both dps to async_set_properties() in one call fixes this: pending updates are
sorted by dp id, so the key registration (dp 8) is processed before the unlock (dp 9)
inside a single payload, and there is no window for the cloud to interleave. Verified
repeatedly, including from a deliberately invalidated key state - it always succeeds on
the first attempt.
Ruled out
For completeness, these were tested on the same device and do not work, in case
someone else goes down the same path:
- The user's keypad PIN as the dp 9 key (unpadded, NUL padded, zero padded) -
0x05.
remote_no_pd_* is the "remote unlock without password" channel and has its own
secret; keypad credentials live in a separate table (unlock_password and the
unlock_method_create/delete/modify dps).
- The device's fixed 4 digit master code, same paddings -
0x05.
- Writing dp 104 (mapped as a writable lock in
bstuokey_access_keypad.yaml, same
product id) - device does not respond at all.
Proposed change
Patch below. It:
- picks up
remote_no_pd_setkey and remote_no_dp_key in TuyaLocalLock.__init__
- reports
code_format of .{8} when both are present, so the user supplies the key as
the lock code (HA's default code setting works nicely for this - the key is arbitrary,
we are the ones registering it)
- adds
build_remote_key_msg() alongside the existing build_code_unlock_msg()
- adds an
async_unlock branch that sends both dps in a single async_set_properties()
call
- updates the stale comment at the top of the file
No device config change is needed - sboardiii_weigand_lock.yaml already names both dps
correctly.
The generated messages are byte identical to the ones verified against real hardware.
Open questions for you
REMOTE_MEMBER_ID is hardcoded to 2 (1 belongs to the cloud). Would you prefer this
configurable per device config, e.g. as an optional dp attribute?
- Should
async_lock get the same treatment? action = CODE_LOCK is accepted by the
device but produces no reply and no visible effect on this hardware (it is a strike
controller with auto relock), so I left it alone.
- The reply status on dp 9 is currently only visible as an entity attribute. Surfacing
it (e.g. logging a warning on a non-zero status) would make failures much easier to
diagnose, but needs a push subscription, so I kept it out of this patch.
Happy to add tests and iterate if you would like this upstream.
Patch against custom_components/tuya_local/lock.py
--- a/custom_components/tuya_local/lock.py
+++ b/custom_components/tuya_local/lock.py
@@ -14,13 +14,16 @@
_LOGGER = logging.getLogger(__name__)
-# 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:
+# Remote Code unlocking protocol: 8 digit code set by remote_no_pd_setkey
+# (typically dp 8 or dp 60). The key does not have to be obtained from the
+# cloud: the device accepts a key registration over the LAN, so we can simply
+# register our own key and then use it. This is exactly what the Tuya cloud
+# itself does - it registers a fresh key immediately before every remote
+# unlock (verified by watching the dp traffic during an app unlock).
# 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)
+# Reply: status (1 byte), Member ID (2 bytes)
# Same 8 digit ASCII code used along with binary member ID to generate
# unlock command with remote_no_dp_key (typically dp 61)
@@ -45,6 +48,22 @@
CODE_REPLY_WRONGCODE = 0x05
CODE_REPLY_DOUBLELOCKED = 0x06
+# Member id used for keys that Home Assistant registers itself. Member 1 is
+# what the Tuya cloud uses for its own remote unlock key. Devices seen so far
+# keep a single key slot, so registering a key replaces whatever the cloud
+# registered last - that is harmless, because the cloud re-registers its key
+# before each of its own unlocks. Using a distinct id keeps the unlock reports
+# (unlock_app) attributable to Home Assistant.
+REMOTE_MEMBER_ID = 0x0002
+
+# Validity window for keys we register. The end time has to stay positive if
+# the device treats it as a signed 32 bit value, so 0x7FFFFFFF is used rather
+# than 0xFFFFFFFF, and the start time is 0 so that the key stays valid even if
+# the device loses its clock.
+REMOTE_KEY_VALID_FROM = 0
+REMOTE_KEY_VALID_TO = 0x7FFFFFFF
+REMOTE_KEY_USES = 0xFFFF
+
async def async_setup_entry(hass, config_entry, async_add_entities):
config = {**config_entry.data, **config_entry.options}
@@ -88,6 +107,8 @@
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._remote_key_dp = dps_map.pop("remote_no_pd_setkey", None)
+ self._remote_unlock_dp = dps_map.pop("remote_no_dp_key", 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)
@@ -139,10 +160,15 @@
@property
def code_format(self):
"""Return the code format of the lock."""
- if self._code_unlock_dp:
+ if self._code_unlock_dp or self._remote_unlock_supported:
return r".{8}"
return None
+ @property
+ def _remote_unlock_supported(self):
+ """Can we register our own key and unlock with it?"""
+ return bool(self._remote_key_dp and self._remote_unlock_dp)
+
def unlocker_id(self, dp, how):
if dp:
unlock = dp.get_value(self._device)
@@ -206,6 +232,29 @@
)
_LOGGER.info("%s unlocking with code", self._config.config_id)
await self._code_unlock_dp.async_set_value(self._device, msg)
+ elif self._remote_unlock_supported:
+ code = kwargs.get("code")
+ if not code:
+ raise ValueError("Code required to unlock")
+ _LOGGER.info("%s unlocking with remote key", self._config.config_id)
+ # The key registration and the unlock command are sent in a single
+ # message, in dp order. Sending them separately leaves a window in
+ # which the cloud can register its own key over ours, which makes
+ # the unlock fail with CODE_REPLY_WRONGCODE.
+ await self._device.async_set_properties(
+ {
+ self._remote_key_dp.id: self.build_remote_key_msg(
+ REMOTE_MEMBER_ID,
+ code,
+ ),
+ self._remote_unlock_dp.id: self.build_code_unlock_msg(
+ CODE_UNLOCK,
+ member_id=REMOTE_MEMBER_ID,
+ code=code,
+ source=CODE_SRC_APP,
+ ),
+ }
+ )
elif self._lock_dp and not self._lock_dp.readonly:
_LOGGER.info("%s unlocking", self._config.config_id)
await self._lock_dp.async_set_value(self._device, False)
@@ -230,6 +279,26 @@
_LOGGER.info("%s opening", self._config.config_id)
await self._open_dp.async_set_value(self._device, True)
+ def build_remote_key_msg(
+ self,
+ member_id,
+ code,
+ valid_from=REMOTE_KEY_VALID_FROM,
+ valid_to=REMOTE_KEY_VALID_TO,
+ uses=REMOTE_KEY_USES,
+ ):
+ """Generate the message that registers a remote unlock key."""
+ if len(code) != 8 or not code.isascii():
+ raise ValueError("Code must be 8 ASCII characters")
+ msg = bytearray()
+ msg.append(0x01) # key is valid
+ msg += member_id.to_bytes(2, "big")
+ msg += valid_from.to_bytes(4, "big")
+ msg += valid_to.to_bytes(4, "big")
+ msg += uses.to_bytes(2, "big")
+ msg += code.encode("ascii")
+ return b64encode(msg).decode("utf-8")
+
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():
Summary
lock.pycurrently assumes the 8 character remote unlock key can only be obtainedby eavesdropping on cloud unlock messages:
That turns out not to be necessary. The device accepts a key registration over the
LAN with no authentication beyond the local key, so Home Assistant can simply
register its own key and immediately unlock with it. This is not a workaround - it is
what the Tuya cloud itself does: watching the dp traffic during an app unlock shows the
cloud registering a fresh key on
remote_no_pd_setkeymicroseconds before sending theunlock on
remote_no_dp_key.The result is fully local remote unlocking on a device family where
lock.unlockcurrently raises
NotImplementedError.Device
sboardiii_weigand_lock.yaml(Secukey Sboard III mini / BSTUOKEY, product id5k8h97qska6pf5cm)remote_no_pd_setkey= dp 8,remote_no_dp_key= dp 9DP_QUERYwithjson obj data unvalid, so it never reports anystate on poll - everything below was learned from push messages.
Protocol, as observed
Both dps are raw/base64. Big endian.
remote_no_pd_setkey(dp 8)[valid(1)][member(2)][valid_from(4)][valid_to(4)][uses(2)][key(8 ASCII)][status(1)][member(2)], status0x00= successremote_no_dp_key(dp 9)[action(1)][member(2)][key(8 ASCII)][source(2)][status(1)][member(2)][...],0x00= success,0x05= wrong keyThis matches the format already documented in
lock.py, andbuild_code_unlock_msg()produces the correct unlock request as is.
Cloud unlock captured from the LAN side
A remote unlock triggered from the Smart Life app, seen locally:
Note the device only ever publishes the reply on these dps, never the key itself, so
eavesdropping the key locally is in fact impossible - which makes the "obtain by
eavesdropping" note in the source unactionable. Registering our own key is.
Registering our own key and unlocking
Round trip is about 600 ms.
Two things that matter for the implementation
1. There is a single key slot. Registering a key replaces whatever the cloud
registered last, and vice versa. This is harmless in both directions, because both
sides register their key immediately before unlocking. But it does mean a key
registered once and used later will eventually fail with
0x05- on a shared doorevery unlock by another resident invalidates it.
2. The registration and the unlock must be sent in one message. If they are sent as
two separate messages, the cloud can slip its own registration in between (observed
consistently, roughly 400-500 ms after ours) and the unlock then fails with
0x05:Passing both dps to
async_set_properties()in one call fixes this: pending updates aresorted by dp id, so the key registration (dp 8) is processed before the unlock (dp 9)
inside a single payload, and there is no window for the cloud to interleave. Verified
repeatedly, including from a deliberately invalidated key state - it always succeeds on
the first attempt.
Ruled out
For completeness, these were tested on the same device and do not work, in case
someone else goes down the same path:
0x05.remote_no_pd_*is the "remote unlock without password" channel and has its ownsecret; keypad credentials live in a separate table (
unlock_passwordand theunlock_method_create/delete/modifydps).0x05.bstuokey_access_keypad.yaml, sameproduct id) - device does not respond at all.
Proposed change
Patch below. It:
remote_no_pd_setkeyandremote_no_dp_keyinTuyaLocalLock.__init__code_formatof.{8}when both are present, so the user supplies the key asthe lock code (HA's default code setting works nicely for this - the key is arbitrary,
we are the ones registering it)
build_remote_key_msg()alongside the existingbuild_code_unlock_msg()async_unlockbranch that sends both dps in a singleasync_set_properties()call
No device config change is needed -
sboardiii_weigand_lock.yamlalready names both dpscorrectly.
The generated messages are byte identical to the ones verified against real hardware.
Open questions for you
REMOTE_MEMBER_IDis hardcoded to 2 (1 belongs to the cloud). Would you prefer thisconfigurable per device config, e.g. as an optional dp attribute?
async_lockget the same treatment?action = CODE_LOCKis accepted by thedevice but produces no reply and no visible effect on this hardware (it is a strike
controller with auto relock), so I left it alone.
it (e.g. logging a warning on a non-zero status) would make failures much easier to
diagnose, but needs a push subscription, so I kept it out of this patch.
Happy to add tests and iterate if you would like this upstream.
Patch against
custom_components/tuya_local/lock.py