Skip to content

Commit 56ba120

Browse files
hcarter-775claude
andauthored
Zigbee Lock: make code sync more robust (#3330)
* zigbee-lock: make credential code sync more robust Sync the lock's PIN codes into the credentials/users tables more often and more reliably, and recover from failures instead of stopping early. - Start the sync from slot 0, and try it more often: on init (after a 15s delay so NumberOfPINUsersSupported is known) and on refresh. The duplicate init sync in doConfigure is dropped. - Move the sync checks into sync_device_state and continue_code_sync into lock_utils' sync section. - Skip a sync while the device is busy, expiring stale busy state so a stalled sync can be retried, rather than clobbering a command in flight. Refresh waits 15s for updates to arrive. - Persist the latest synced code index so a failed sync resumes from where it stopped, and ignore responses for any other index. - Use a bump-able CODES_SYNC_VERSION: a persisted index started under a different version is cleared, which permits a re-sync when needed. - Handle Default Responses during the sync. Default responses do not count towards the consecutive-unoccupied count that ends the sync. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 6a506c0 commit 56ba120

10 files changed

Lines changed: 382 additions & 38 deletions

‎drivers/SmartThings/zigbee-lock/src/init.lua‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ function LockLifecycle.init(driver, device)
4646
-- We should check its PIN users if it should be re-profiled.
4747
device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device))
4848
end
49+
50+
-- retry sync on each driver init until it has been run to completion, in case of some kind of interruption (network, power, etc.)
51+
-- ensure our user/credential state is accurate to the current device state
52+
device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end)
4953
end
5054

5155
function LockLifecycle.do_configure(driver, device)
@@ -57,11 +61,6 @@ function LockLifecycle.do_configure(driver, device)
5761

5862
device:send(device_management.build_bind_request(device, clusters.Alarms.ID, driver.environment_info.hub_zigbee_eui))
5963
device:send(clusters.Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0))
60-
61-
if device:supports_capability(capabilities.lockCredentials) then
62-
-- ensure our user/credential state is accurate to the current device state
63-
device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end)
64-
end
6564
end
6665

6766
function LockLifecycle.info_changed(driver, device, event, args)
@@ -100,6 +99,11 @@ local zigbee_lock_driver = {
10099
infoChanged = LockLifecycle.info_changed,
101100
},
102101
zigbee_handlers = {
102+
global = {
103+
[clusters.DoorLock.ID] = {
104+
[0x0B] = zigbee_handlers.default_response, -- ZCL Default Response
105+
}
106+
},
103107
cluster = {
104108
[clusters.Alarms.ID] = {
105109
[clusters.Alarms.client.commands.Alarm.ID] = zigbee_handlers.alarm

‎drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ function CapabilityHandlers.refresh(driver, device, cmd)
256256
(device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then
257257
device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device))
258258
end
259+
260+
-- Allow callers to try to sync device state- fails silently if the device is busy or already synced.
261+
device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end)
259262
elseif not device:supports_capability(capabilities.lockCodes) then
260263
-- Generically fingerprinted devices may support PINs even though the profile does not yet
261264
-- reflect it. Read NumberOfPINUsersSupported so the response handler can trigger a profile

‎drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
local socket = require "cosock.socket"
55

66
local clusters = require "st.zigbee.zcl.clusters"
7+
local Status = require "st.zigbee.generated.types.ZclStatus"
78
local capabilities = require "st.capabilities"
89

910
local consts = require "lock_utils.constants"
@@ -141,27 +142,32 @@ function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx)
141142

142143
if command_in_progress == consts.SYNC.CODES_FROM_LOCK then
143144
-- Only add entries for slots that are actually occupied on the lock.
144-
local user_status = zb_rx.body.zcl_body.user_status.value
145-
if user_status == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED then
146-
lock_utils.add_reported_credential(device, user_id)
147-
-- reset the consecutive unoccupied codes counter since we found an occupied code
148-
device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0)
149-
else
150-
local consecutive_unoccupied_codes = (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1
151-
device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, consecutive_unoccupied_codes)
145+
local occupied = zb_rx.body.zcl_body.user_status.value == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED
146+
if user_id < device:get_field(consts.SYNC.CODE_INDEX) then
147+
device.log.warn_with({ hub_logs = true }, "GetPINCodeResponse for index %s already processed, expected %s", user_id, device:get_field(consts.SYNC.CODE_INDEX))
148+
return -- return early if we've already processed this response.
149+
elseif user_id ~= device:get_field(consts.SYNC.CODE_INDEX) then
150+
device.log.warn_with({ hub_logs = true }, "Unexpected GetPINCodeResponse for index %s, expected %s", user_id, device:get_field(consts.SYNC.CODE_INDEX))
151+
occupied = false -- treat this as an unoccupied slot to continue the sync
152152
end
153+
lock_utils.continue_code_sync(device, occupied, false)
154+
end
155+
end
153156

154-
if user_id >= tables.get_max_entries(device, "credentials") or
155-
(device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then
156-
-- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes.
157-
device:set_field(consts.SYNC.CODE_INDEX, nil)
158-
lock_utils.clear_busy_state(device)
159-
else
160-
local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1
161-
device:set_field(consts.SYNC.CODE_INDEX, synced_code_index)
162-
lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index })
163-
device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index))
164-
end
157+
-- Handles a ZCL Default Response on the DoorLock cluster. During a code sync, a non-success Default Response
158+
-- to GetPINCode (e.g. the lock rejects an unsupported index) means no GetPINCodeResponse will arrive,
159+
-- so the request is retried once (in case the error was transient) and then the slot is treated as
160+
-- unoccupied, continuing to the next step rather than stalling the sync.
161+
function ZigbeeHandlers.default_response(driver, device, zb_rx)
162+
local zcl_body = zb_rx.body.zcl_body
163+
if device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then return end
164+
if zcl_body.cmd.value ~= clusters.DoorLock.server.commands.GetPINCode.ID then return end
165+
-- a SUCCESS default response is followed by the real GetPINCodeResponse
166+
if zcl_body.status.value == Status.SUCCESS then
167+
return
168+
else -- else, continue the code sync
169+
device.log.warn_with({ hub_logs = true }, "Default Response for GetPINCode at index %s with status %s", device:get_field(consts.SYNC.CODE_INDEX), zcl_body.status.value)
170+
lock_utils.continue_code_sync(device, false, true)
165171
end
166172
end
167173

‎drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ lock_constants.DRIVER_STATE = {
1212

1313
lock_constants.SYNC = {
1414
CODES_FROM_LOCK = "syncCodesFromLock",
15-
CODE_INDEX = "syncCodeIndex",
15+
CODE_INDEX = "syncCodeIndex", -- persisted until codesSynced is set
16+
CODE_INDEX_VERSION = "syncCodeIndexVersion", -- persisted; the CODES_SYNC_VERSION under which CODE_INDEX was started
17+
RETRIED_CODE_INDEX = "syncRetriedCodeIndex", -- the code index whose GetPINCode request was already retried once
1618
CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes",
19+
CODES_SYNCED = "codesSynced", -- persisted; holds the CODES_SYNC_VERSION of the last completed code sync
20+
CODES_SYNC_VERSION = 1, -- bump to force every device to re-sync its codes (a mismatch with codesSynced triggers it; with syncCodeIndexVersion, restarts from index 0)
1721
}
1822

1923
lock_constants.COMMAND_RESULT = {

‎drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,68 @@ end
3939
-- [[ SYNC STATE MANAGEMENT ]] --
4040

4141
function lock_utils.sync_device_state(device)
42+
local capabilities = require "st.capabilities"
4243
local clusters = require "st.zigbee.zcl.clusters"
44+
45+
-- skip if unsupported, already synced, or busy (including a sync already in progress). is_device_busy
46+
-- expires stale busy state, so a sync that stalled on a lost response can be retried.
47+
if not (device:supports_capability(capabilities.lockCredentials) and
48+
device:get_field(consts.SYNC.CODES_SYNCED) ~= consts.SYNC.CODES_SYNC_VERSION and
49+
not lock_utils.is_device_busy(device)) then
50+
return
51+
end
52+
53+
-- a persisted index from a sync begun under a different sync version is stale; start over.
54+
if device:get_field(consts.SYNC.CODE_INDEX_VERSION) ~= consts.SYNC.CODES_SYNC_VERSION then
55+
device:set_field(consts.SYNC.CODE_INDEX, nil, { persist = true })
56+
end
57+
4358
-- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air.
4459
device:send(clusters.DoorLock.attributes.SendPINOverTheAir:write(device, true))
4560

4661
-- if consts.SYNC.CODE_INDEX is nil, we haven't started syncing codes from the lock yet,
47-
-- so start the process from 1. note: the Zigbee "Master Code" is associated with index 0.
62+
-- so start the process from 0. Persist the index so we can resume if the driver is restarted mid-sync.
4863
if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then
49-
device:set_field(consts.SYNC.CODE_INDEX, 1)
64+
device:set_field(consts.SYNC.CODE_INDEX, 0, { persist = true })
65+
device:set_field(consts.SYNC.CODE_INDEX_VERSION, consts.SYNC.CODES_SYNC_VERSION, { persist = true })
5066
end
5167
lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK)
5268
device:send(clusters.DoorLock.server.commands.GetPINCode(device, device:get_field(consts.SYNC.CODE_INDEX)))
5369
end
5470

71+
-- Advance the sync-codes-from-lock flow after a response for the current code index (consts.SYNC.CODE_INDEX).
72+
-- `occupied` is true only when the lock reported an occupied slot at that index.
73+
-- `default_response` is true when the step was resolved by a Default Response: that is not treated as a
74+
-- failure, but it is not an occupied slot either, so it does not count toward the consecutive unoccupied codes.
75+
function lock_utils.continue_code_sync(device, occupied, default_response)
76+
local clusters = require "st.zigbee.zcl.clusters"
77+
local code_index = device:get_field(consts.SYNC.CODE_INDEX)
78+
if code_index == nil then return end
79+
80+
if occupied then
81+
lock_utils.add_reported_credential(device, code_index)
82+
-- reset the consecutive unoccupied codes counter since we found an occupied code
83+
device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0)
84+
elseif not default_response then
85+
device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1)
86+
end
87+
88+
if code_index >= tables.get_max_entries(device, "credentials") or
89+
(device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then
90+
-- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes.
91+
device:set_field(consts.SYNC.CODE_INDEX, nil, { persist = true })
92+
device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, nil)
93+
device:set_field(consts.SYNC.RETRIED_CODE_INDEX, nil)
94+
device:set_field(consts.SYNC.CODES_SYNCED, consts.SYNC.CODES_SYNC_VERSION, { persist = true })
95+
lock_utils.clear_busy_state(device)
96+
else
97+
local next_index = code_index + 1
98+
device:set_field(consts.SYNC.CODE_INDEX, next_index, { persist = true })
99+
lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK)
100+
device:send(clusters.DoorLock.server.commands.GetPINCode(device, next_index))
101+
end
102+
end
103+
55104

56105
-- [[ CREDENTIAL TABLE MANAGEMENT ]] --
57106

@@ -117,7 +166,7 @@ end
117166
-- is left in place
118167
function lock_utils.add_reported_credential(device, credential_index)
119168
if tables.find_entry(device, "credentials", credential_index) then return end
120-
lock_utils.add_credential_and_user(device, {
169+
return lock_utils.add_credential_and_user(device, {
121170
userIndex = tables.next_index(device, "users"),
122171
credentialIndex = credential_index,
123172
}, true)

‎drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ test.register_coroutine_test(
3434
mock_device:expect_metadata_update({profile = "base-lock"})
3535

3636
test.wait_for_events()
37+
test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot")
3738
test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") }))
3839
test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })))
3940
test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })))
@@ -44,6 +45,11 @@ test.register_coroutine_test(
4445
test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) })
4546
test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) })
4647
test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) })
48+
test.wait_for_events()
49+
-- the refresh handler delays the sync by 15 seconds
50+
test.mock_time.advance_time(15)
51+
test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) })
52+
test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) })
4753
end,
4854
{
4955
min_api_version = 14

0 commit comments

Comments
 (0)