From 0d83f50b58726d40ff501b66e2dc8e4a6678f1f3 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:47:30 -0600 Subject: [PATCH 1/9] zigbee-lock: centralize credential/user table updates and tighten addCredential/updateCredential validation (#3331) * zigbee-lock: simplify reported and commanded credential management Centralize how the lock's credential and user tables are updated after a PIN is set on the lock, whether the PIN was set by a SmartThings command or reported by the lock (sync or programming event). addCredential - The credential always takes the next available credential slot. - userIndex 0 creates a new user for the credential; the user index is chosen when the lock confirms the code (next free user index). - A non-zero userIndex must name an existing user, and the credential is attached to it. An unknown userIndex returns invalidCommand without contacting the lock. If that user vanishes before the lock confirms, it is re-added rather than failing an accepted code. - resourceExhausted is returned up front when the credentials table is full, or when a new user is requested and the users table is full. updateCredential - Only handled when the given userIndex already owns the credentialIndex (otherwise invalidCommand); a credential can no longer be moved to another user. Only the credential name can change. Reported credentials - Codes reported by the lock without a command from us get a user and PIN credential via lock_utils.add_reported_credential. Helpers - lock_utils.add_credential_and_user(device, args, add_user) adds a credential and, when add_user is true, its user afterwards so a failed credential add never leaves a user behind. - tables.next_index accepts an optional start index. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lock_handlers/capabilities.lua | 29 +++- .../src/lock_handlers/zigbee_responses.lua | 80 ++--------- .../zigbee-lock/src/lock_utils/tables.lua | 9 +- .../zigbee-lock/src/lock_utils/utils.lua | 72 ++++++++++ .../test/test_lock_credentials_commands.lua | 134 ++++++++++++++++-- .../src/test/test_lock_pre_configured.lua | 22 +-- .../src/test/test_lock_programming_events.lua | 128 +++++++++++------ .../zigbee-lock/src/test/test_lock_tables.lua | 14 +- .../src/test/test_lock_users_commands.lua | 59 +++----- .../src/test/test_zigbee_responses.lua | 28 ++-- 10 files changed, 369 insertions(+), 206 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index 371cf7a765..e33651f97c 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -127,15 +127,27 @@ function CapabilityHandlers.add_credential(driver, device, command) return end - -- A userIndex of 0 means "auto-assign the next available slot" - local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex - local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + -- userIndex 0 means "create a new user for this credential" (its index is chosen when the lock confirms the + -- code). Any other userIndex must name an existing user that does not already have a credential (for now, + -- each user may have at most one credential). + local user_index = command.args.userIndex + if user_index ~= 0 and (not tables.find_entry(device, "users", user_index) + or tables.find_entry_by(device, "credentials", "userIndex", user_index)) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end - if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + local credentials_full = #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") + -- if we are being asked to create a new user (index 0), this also needs a free user index. + local users_full = (user_index == 0) and #(tables.get_state(device, "users") or {}) >= tables.get_max_entries(device, "users") + if credentials_full or users_full then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) return end + -- the credential always takes the next available slot. + local cred_index = tables.next_index(device, "credentials", 1) + -- Set busy state and attempt to set the PIN on-device. lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { userIndex = user_index, @@ -157,11 +169,18 @@ function CapabilityHandlers.update_credential(driver, device, command) return end - if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + local existing = tables.find_entry(device, "credentials", command.args.credentialIndex) + if not existing then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) return end + -- a credential cannot be moved to another user: the given userIndex must already own this credential + if existing.userIndex ~= command.args.userIndex then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { userIndex = command.args.userIndex, credentialIndex = command.args.credentialIndex, diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua index 9d1c53947a..23642012dd 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -36,23 +36,9 @@ function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) } -- apply result based on response and identify command result status - local result_status + local result_status, user_index if set_pin_code_status == SetCodeStatus.SUCCESS then - if command_in_progress == consts.LOCK_CREDENTIALS.ADD then - tables.add_entry(device, "users", { - userIndex = credential_args_in_use.userIndex, - userName = "Guest " .. credential_args_in_use.userIndex, -- default - userType = "guest", -- default - }) - result_status = tables.add_entry(device, "credentials", { - userIndex = credential_args_in_use.userIndex, - credentialIndex = credential_args_in_use.credentialIndex, - credentialType = credential_args_in_use.credentialType, - credentialName = "Guest " .. credential_args_in_use.userIndex, -- default - }) - elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then - result_status = consts.COMMAND_RESULT.SUCCESS - end + result_status, user_index = lock_utils.apply_commanded_credential(device, command_in_progress, credential_args_in_use) elseif RESPONSE_RESULT_MAP[set_pin_code_status] then result_status = RESPONSE_RESULT_MAP[set_pin_code_status] else @@ -62,7 +48,7 @@ function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) -- emit command result if command_in_progress then local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { - userIndex = credential_args_in_use.userIndex, + userIndex = user_index, credentialIndex = credential_args_in_use.credentialIndex, } or nil lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) @@ -157,18 +143,7 @@ function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) -- Only add entries for slots that are actually occupied on the lock. local user_status = zb_rx.body.zcl_body.user_status.value if user_status == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED then - -- If an entry already exists at this index, this will be a no-op. - tables.add_entry(device, "users", { - userIndex = user_id, - userName = "Guest " .. user_id, - userType = "guest", - }) - tables.add_entry(device, "credentials", { - userIndex = user_id, - credentialIndex = user_id, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "Guest " .. user_id, - }) + lock_utils.add_reported_credential(device, user_id) -- reset the consecutive unoccupied codes counter since we found an occupied code device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) else @@ -223,23 +198,9 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} if command_in_progress and credential_args_in_use.credentialIndex == user_id then local result_status - if event_code == ProgramEventCode.PIN_CODE_ADDED then - if command_in_progress == consts.LOCK_CREDENTIALS.ADD then - tables.add_entry(device, "users", { - userIndex = credential_args_in_use.userIndex, - userName = "Guest " .. credential_args_in_use.userIndex, -- default - userType = "guest", -- default - }) - result_status = tables.add_entry(device, "credentials", { - userIndex = credential_args_in_use.userIndex, - credentialIndex = credential_args_in_use.credentialIndex, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional - }) - elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then - -- some devices emit PIN_CODE_ADDED even for UPDATE events - result_status = consts.COMMAND_RESULT.SUCCESS - end + local user_index = credential_args_in_use.userIndex + if event_code == ProgramEventCode.PIN_CODE_ADDED then -- note: some devices emit PIN_CODE_ADDED even for UPDATE events + result_status, user_index = lock_utils.apply_commanded_credential(device, command_in_progress, credential_args_in_use) elseif event_code == ProgramEventCode.PIN_CODE_DELETED and (command_in_progress == consts.LOCK_CREDENTIALS.DELETE or command_in_progress == consts.LOCK_USERS.DELETE) then result_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) -- the deleteUser command injects a deleteCredential command, so ensure the lockUsers command results is emitted in this case. @@ -252,7 +213,7 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) end end elseif event_code == ProgramEventCode.PIN_CODE_CHANGED and command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then - result_status = consts.COMMAND_RESULT.SUCCESS + result_status, user_index = lock_utils.apply_commanded_credential(device, command_in_progress, credential_args_in_use) end if result_status then @@ -260,34 +221,17 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) capabilities.lockCredentials, command_in_progress, result_status, - { userIndex = credential_args_in_use.userIndex, credentialIndex = credential_args_in_use.credentialIndex } + { userIndex = user_index, credentialIndex = credential_args_in_use.credentialIndex } ) lock_utils.clear_busy_state(device) return end end - -- handle the case where we receive a programming event notification for a code we've just deleted, + -- handle the case where we receive a programming event notification for a code we didn't send a command for if event_code == ProgramEventCode.PIN_CODE_ADDED then - -- if no "addCredential" command is in progress, check if the credential is already stored locally. - local credential = tables.find_entry(device, "credentials", user_id) - if not credential then - -- check what user slot we have to associate this with and add a default user - local next_index = tables.next_index(device, "users") - if next_index and next_index <= tables.get_max_entries(device, "users") then - tables.add_entry(device, "users", { - userIndex = next_index, - userName = "Guest " .. next_index, - userType = "guest", - }) - tables.add_entry(device, "credentials", { - userIndex = next_index, - credentialIndex = user_id, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "Guest " .. next_index, - }) - end - end + -- if no "addCredential" command is in progress, ensure the credential is represented locally + lock_utils.add_reported_credential(device, user_id) elseif event_code == ProgramEventCode.PIN_CODE_DELETED then -- check if a credential exists locally for this user id, then -- try to delete the entries in our tables corresponding to this code. diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua index 34a1277b57..7166b21a25 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -131,15 +131,16 @@ function table_utils.find_all_entries_by(device, table_name, key, value) return matches end --- Return the lowest positive integer not yet used as the match_key in the named table. --- Used to auto-assign the next available slot for a new entry. -function table_utils.next_index(device, table_name) +-- Return the lowest integer, starting at the table's minimum, not yet used as the +-- match_key in the named table. Used to auto-assign the next available slot for a new entry. +-- An optional `start` overrides the table's minimum, e.g. to skip a reserved slot. +function table_utils.next_index(device, table_name, start) local def = resolve_table_def(device, table_name) if not def then return 1 end local t = table_utils.get_state(device, def) or {} local occupied = {} for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end - local idx = 1 + local idx = start or def.minimum while occupied[idx] do idx = idx + 1 end return idx end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua index 90bdd96f33..08d29987bc 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua @@ -1,6 +1,7 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local tables = require "lock_utils.tables" local consts = require "lock_utils.constants" local lock_utils = {} @@ -52,6 +53,77 @@ function lock_utils.sync_device_state(device) end +-- [[ CREDENTIAL TABLE MANAGEMENT ]] -- + +-- Add a credential and, optionally, a new user for it. `args.userIndex` is the credential's user. +-- When `add_user` is true the user is created too, so a user never exists without a credential; when false +-- the credential is attached to the already-existing user `args.userIndex`. +-- The credential is added first; the user is only added if that succeeded, so a failed or +-- colliding credential add leaves no user behind. Returns the result status of the credential add. +function lock_utils.add_credential_and_user(device, args, add_user) + local name = "Guest " .. args.userIndex + local credential_status = tables.add_entry(device, "credentials", { + userIndex = args.userIndex, + credentialIndex = args.credentialIndex, + credentialType = args.credentialType or consts.CRED_TYPE_PIN, + credentialName = args.credentialName or name, + }) + if add_user and credential_status == consts.COMMAND_RESULT.SUCCESS then + tables.add_entry(device, "users", { + userIndex = args.userIndex, + userName = args.credentialName or name, + userType = "guest", + }) + end + return credential_status +end + +-- Apply the arguments of an updateCredential command to the credentials table. +-- Returns the command result status and the credential's userIndex. +function lock_utils.update_commanded_credential(device, credential_args) + local existing = tables.find_entry(device, "credentials", credential_args.credentialIndex) + if not existing then return consts.COMMAND_RESULT.FAILURE end + local name = credential_args.credentialName + if name ~= nil and name ~= existing.credentialName then + local status = tables.update_entry(device, "credentials", credential_args.credentialIndex, { credentialName = name }) + if status ~= consts.COMMAND_RESULT.SUCCESS then return status end + end + return consts.COMMAND_RESULT.SUCCESS, existing.userIndex +end + +-- Per command: update the tables after the lock confirmed a set-credential command we sent. +-- Returns the command result status, or nil for any other command, plus the affected userIndex. +-- For addCredential, a userIndex of 0 creates a new user (its index is chosen here); otherwise the credential +-- is attached to the existing user with that index. +function lock_utils.apply_commanded_credential(device, command_in_progress, credential_args) + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + local user_index = credential_args.userIndex + if (user_index == 0) then user_index = tables.next_index(device, "users") end + local user_not_found = not tables.find_entry(device, "users", user_index) + local status = lock_utils.add_credential_and_user(device, { + userIndex = user_index, + credentialIndex = credential_args.credentialIndex, + credentialType = credential_args.credentialType, + credentialName = credential_args.credentialName, + }, user_not_found) + return status, status == consts.COMMAND_RESULT.SUCCESS and user_index or nil + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + return lock_utils.update_commanded_credential(device, credential_args) + end +end + +-- Per report: ensure a user and PIN credential exist for a code the lock reports at `credential_index` +-- without any command from us (sync, or a programming event). If a credential already exists there it +-- is left in place +function lock_utils.add_reported_credential(device, credential_index) + if tables.find_entry(device, "credentials", credential_index) then return end + lock_utils.add_credential_and_user(device, { + userIndex = tables.next_index(device, "users"), + credentialIndex = credential_index, + }, true) +end + + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua index 33293e784d..0189dcc9f1 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -69,11 +69,11 @@ end test.register_coroutine_test( "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", function() - -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + -- Queue the capability command: userIndex=0, userType="guest", credentialType="pin", credentialData="1234" test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) -- Expect SetPINCode to be sent to the lock @@ -93,15 +93,15 @@ test.register_coroutine_test( -- Handler adds the credential and user to the credentials table test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -123,7 +123,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ @@ -155,13 +155,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 2, "guest", "pin", "5678" } }, + args = { 0, "guest", "pin", "5678" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), }) test.wait_for_events() @@ -187,7 +187,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ @@ -213,6 +213,94 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "addCredential: attaches the next free credential slot to an existing user without a credential when userIndex is non-zero", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + -- slot 1 is taken, so the next available credential slot, 2, is used + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 2, credentialIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: fails without contacting the lock when the user already has a credential", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "5678" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: fails without contacting the lock when userIndex does not map to an existing user", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 5, "guest", "pin", "1234" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + test.register_coroutine_test( "addCredential: returns busy when another operation is already in progress", function() @@ -220,7 +308,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -233,7 +321,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 2, "guest", "pin", "5678" } }, + args = { 0, "guest", "pin", "5678" } }, }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", @@ -284,6 +372,28 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "updateCredential: rejects the command when userIndex is not the credential's current user", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 2, 1, "pin", "newPin9" } }, + }) + -- No zigbee message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + test.register_coroutine_test( "updateCredential: returns failure immediately when the credential does not exist in the table", function() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua index 0eb4ac396f..b88fe387a1 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua @@ -243,7 +243,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin03" } }, + args = { 0, "guest", "pin", "pin03" } }, }) test.socket.zigbee:__expect_send({ @@ -260,22 +260,22 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( + capabilities.lockCredentials.credentials( { - { userIndex = 1, userName = "Alice", userType = "guest" }, - { userIndex = 2, userName = "Bob", userType = "guest" }, - { userIndex = 3, userName = "Guest 3", userType = "guest" }, -- default name since lock doesn't provide one + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( + capabilities.lockUsers.users( { - { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, - { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, - { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Guest 3", userType = "guest" }, -- default name since lock doesn't provide one }, { visibility = { displayed = false } } )) @@ -299,7 +299,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin01" } }, + args = { 0, "guest", "pin", "pin01" } }, }) test.socket.zigbee:__expect_send({ @@ -333,7 +333,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin03" } }, + args = { 0, "guest", "pin", "pin03" } }, }) test.socket.zigbee:__expect_send({ diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua index 3c06afef66..8fd5ce2691 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -75,20 +75,20 @@ test.register_coroutine_test( -- Route events to the new capabilities handler, not the legacy lockCodes handler. mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) - -- Users table receives the new entry + -- Credentials table receives the new entry test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } ) ) ) - -- Credentials table receives the new entry + -- Users table receives the new entry test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } ) ) @@ -105,16 +105,16 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } ) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } ) ) @@ -161,23 +161,23 @@ test.register_coroutine_test( mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { - userIndex = 1, credentialIndex = 1, credentialType = "pin" + userIndex = 0, credentialIndex = 1, credentialType = "pin" }, {}) test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) -- Failsafe path: notification handled as command success, user and credential added, commandResult emitted. test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } ) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } ) ) @@ -198,6 +198,23 @@ test.register_coroutine_test( "ProgrammingEventNotification PIN_CODE_CHANGED while updateCredential is in flight acts as failsafe", function() mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Seed the credential that the in-flight updateCredential refers to. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() -- Simulate an updateCredential command being in flight with matching user ID. mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.UPDATE, {}) @@ -219,6 +236,29 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_CHANGED while updateCredential is in flight fails if the credential is gone", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.UPDATE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure", userIndex = nil, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + test.register_coroutine_test( "ProgrammingEventNotification PIN_CODE_DELETED while deleteCredential is in flight acts as failsafe", function() @@ -227,16 +267,16 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } ) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } ) ) @@ -295,16 +335,16 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } ) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } ) ) @@ -322,16 +362,16 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } ) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } ) ) @@ -377,7 +417,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -393,15 +433,15 @@ test.register_coroutine_test( -- User and credential added; commandResult emitted; busy state cleared. test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -431,15 +471,15 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -460,15 +500,15 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -522,15 +562,15 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua index 82ecd7f964..f13a1bbaa9 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -108,16 +108,16 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "add_entry: returns RESOURCE_EXHAUSTED when table is at max capacity", + "add_entry: returns RESOURCE_EXHAUSTED when the credentials table is at max capacity", function() local entries = {} - for i = 1, 20 do - entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + for i = 0, 19 do + entries[#entries + 1] = { userIndex = i + 1, credentialIndex = i, credentialType = "pin" } end - seed_users(entries) + seed_credentials(entries) - local overflow = { userIndex = 21, userType = "guest", userName = "Overflow" } - local result = table_utils.add_entry(mock_device, "users", overflow) + local overflow = { userIndex = 21, credentialIndex = 20, credentialType = "pin" } + local result = table_utils.add_entry(mock_device, "credentials", overflow) assert(result == constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, "Expected RESOURCE_EXHAUSTED, got: " .. tostring(result)) end @@ -513,7 +513,7 @@ test.register_coroutine_test( -- =========================================================================== test.register_coroutine_test( - "get_max_entries: returns the default of 20 when the attribute is not set", + "get_max_entries: returns the default of 20 for the users table when the attribute is not set", function() local result = table_utils.get_max_entries(mock_device, "users") assert(result == 20, diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua index 2f68f0cfb6..8ce128a641 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua @@ -62,15 +62,6 @@ local function seed_credentials(entries) test.wait_for_events() end --- Set totalUsersSupported on the mock device and consume the resulting event. -local function set_total_users_supported(n) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) - ) - mock_device:emit_event(capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) - test.wait_for_events() -end -- ============================================================================ -- addUser @@ -169,31 +160,6 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "addUser: returns resourceExhausted when totalUsersSupported has been reached", - function() - set_total_users_supported(2) - seed_users({ - { userIndex = 1, userName = "Alice", userType = "guest" }, - { userIndex = 2, userName = "Bob", userType = "guest" }, - }) - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, - }) - - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockUsers.commandResult( - { commandName = "addUser", statusCode = "resourceExhausted" }, - { state_change = true, visibility = { displayed = false } } - )) - ) - test.wait_for_events() - end -) - -- ============================================================================ -- updateUser -- ============================================================================ @@ -609,13 +575,13 @@ test.register_coroutine_test( ) test.wait_for_events() - -- Re-add a credential for the new user at slot 2. The old credential at - -- credentialIndex 2 was cleanly removed, so add_entry must succeed without - -- returning OCCUPIED or any stale-state error. + -- Re-add a credential at the reclaimed slot 2. The old credential at credentialIndex 2 was cleanly + -- removed, so add_entry must succeed without returning OCCUPIED or any stale-state error. + -- addCredential always creates a new user, which takes the next free user index, 4. test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 2, "guest", "pin", "9999" } }, + args = { 0, "guest", "pin", "9999" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -628,14 +594,25 @@ test.register_coroutine_test( mock_device.id, DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), }) - -- Credential is freshly added at index 2 alongside the retained entries at 1 and 3. test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( { { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, - { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + { userIndex = 4, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 4" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + { userIndex = 4, userName = "Guest 4", userType = "guest" }, }, { visibility = { displayed = false } } )) @@ -643,7 +620,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( - { commandName = "addCredential", statusCode = "success", userIndex = 2, credentialIndex = 2 }, + { commandName = "addCredential", statusCode = "success", userIndex = 4, credentialIndex = 2 }, { state_change = true, visibility = { displayed = false } } )) ) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua index e561a4867a..77af332b0f 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -118,19 +118,19 @@ test.register_coroutine_test( ), }) - -- Should add user entry + -- Should add credential entry test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) - -- Should add credential entry + -- Should add user entry test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -169,15 +169,15 @@ test.register_coroutine_test( -- Should add entries test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 20, userName = "Guest 20", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 20, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 20" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -207,15 +207,15 @@ test.register_coroutine_test( -- After shifting 256 >> 8 = 1, should add entries for user_id 1 test.socket.capability:__expect_send( mock_device_yale:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device_yale:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) From 34ab8cd6ed3bb4f040d99abb10f5dd0faf45f3ee Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:10:12 -0600 Subject: [PATCH 2/9] 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 --- drivers/SmartThings/zigbee-lock/src/init.lua | 14 +- .../src/lock_handlers/capabilities.lua | 3 + .../src/lock_handlers/zigbee_responses.lua | 44 ++-- .../zigbee-lock/src/lock_utils/constants.lua | 6 +- .../zigbee-lock/src/lock_utils/utils.lua | 55 ++++- ...est_generic_fingerprint_profile_update.lua | 6 + .../src/test/test_init_lifecycle_handlers.lua | 76 ++++++- .../test/test_lock_code_slga_migration.lua | 12 ++ .../src/test/test_zigbee_responses.lua | 203 ++++++++++++++++++ .../test_zigbee_yale_fingerprint-lock.lua | 1 + 10 files changed, 382 insertions(+), 38 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index b8b290af4d..b10bd561f4 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -46,6 +46,10 @@ function LockLifecycle.init(driver, device) -- We should check its PIN users if it should be re-profiled. device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) end + + -- retry sync on each driver init until it has been run to completion, in case of some kind of interruption (network, power, etc.) + -- ensure our user/credential state is accurate to the current device state + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) end function LockLifecycle.do_configure(driver, device) @@ -57,11 +61,6 @@ function LockLifecycle.do_configure(driver, device) device:send(device_management.build_bind_request(device, clusters.Alarms.ID, driver.environment_info.hub_zigbee_eui)) device:send(clusters.Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - if device:supports_capability(capabilities.lockCredentials) then - -- ensure our user/credential state is accurate to the current device state - device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) - end end function LockLifecycle.info_changed(driver, device, event, args) @@ -100,6 +99,11 @@ local zigbee_lock_driver = { infoChanged = LockLifecycle.info_changed, }, zigbee_handlers = { + global = { + [clusters.DoorLock.ID] = { + [0x0B] = zigbee_handlers.default_response, -- ZCL Default Response + } + }, cluster = { [clusters.Alarms.ID] = { [clusters.Alarms.client.commands.Alarm.ID] = zigbee_handlers.alarm diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index e33651f97c..1abb4cbea1 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -256,6 +256,9 @@ function CapabilityHandlers.refresh(driver, device, cmd) (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) end + + -- Allow callers to try to sync device state- fails silently if the device is busy or already synced. + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) elseif not device:supports_capability(capabilities.lockCodes) then -- Generically fingerprinted devices may support PINs even though the profile does not yet -- reflect it. Read NumberOfPINUsersSupported so the response handler can trigger a profile diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua index 23642012dd..ec5b755660 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -4,6 +4,7 @@ local socket = require "cosock.socket" local clusters = require "st.zigbee.zcl.clusters" +local Status = require "st.zigbee.generated.types.ZclStatus" local capabilities = require "st.capabilities" local consts = require "lock_utils.constants" @@ -141,27 +142,32 @@ function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) if command_in_progress == consts.SYNC.CODES_FROM_LOCK then -- Only add entries for slots that are actually occupied on the lock. - local user_status = zb_rx.body.zcl_body.user_status.value - if user_status == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED then - lock_utils.add_reported_credential(device, user_id) - -- reset the consecutive unoccupied codes counter since we found an occupied code - device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) - else - local consecutive_unoccupied_codes = (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1 - device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, consecutive_unoccupied_codes) + local occupied = zb_rx.body.zcl_body.user_status.value == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED + if user_id < device:get_field(consts.SYNC.CODE_INDEX) then + device.log.warn_with({ hub_logs = true }, "GetPINCodeResponse for index %s already processed, expected %s", user_id, device:get_field(consts.SYNC.CODE_INDEX)) + return -- return early if we've already processed this response. + elseif user_id ~= device:get_field(consts.SYNC.CODE_INDEX) then + device.log.warn_with({ hub_logs = true }, "Unexpected GetPINCodeResponse for index %s, expected %s", user_id, device:get_field(consts.SYNC.CODE_INDEX)) + occupied = false -- treat this as an unoccupied slot to continue the sync end + lock_utils.continue_code_sync(device, occupied, false) + end +end - if user_id >= tables.get_max_entries(device, "credentials") or - (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then - -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. - device:set_field(consts.SYNC.CODE_INDEX, nil) - lock_utils.clear_busy_state(device) - else - local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 - device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) - lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) - device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) - end +-- Handles a ZCL Default Response on the DoorLock cluster. During a code sync, a non-success Default Response +-- to GetPINCode (e.g. the lock rejects an unsupported index) means no GetPINCodeResponse will arrive, +-- so the request is retried once (in case the error was transient) and then the slot is treated as +-- unoccupied, continuing to the next step rather than stalling the sync. +function ZigbeeHandlers.default_response(driver, device, zb_rx) + local zcl_body = zb_rx.body.zcl_body + if device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then return end + if zcl_body.cmd.value ~= clusters.DoorLock.server.commands.GetPINCode.ID then return end + -- a SUCCESS default response is followed by the real GetPINCodeResponse + if zcl_body.status.value == Status.SUCCESS then + return + else -- else, continue the code sync + 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) + lock_utils.continue_code_sync(device, false, true) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua index 78537182f1..06d40f520b 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua @@ -12,8 +12,12 @@ lock_constants.DRIVER_STATE = { lock_constants.SYNC = { CODES_FROM_LOCK = "syncCodesFromLock", - CODE_INDEX = "syncCodeIndex", + CODE_INDEX = "syncCodeIndex", -- persisted until codesSynced is set + CODE_INDEX_VERSION = "syncCodeIndexVersion", -- persisted; the CODES_SYNC_VERSION under which CODE_INDEX was started + RETRIED_CODE_INDEX = "syncRetriedCodeIndex", -- the code index whose GetPINCode request was already retried once CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", + CODES_SYNCED = "codesSynced", -- persisted; holds the CODES_SYNC_VERSION of the last completed code sync + 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) } lock_constants.COMMAND_RESULT = { diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua index 08d29987bc..0eee57ecf0 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua @@ -39,19 +39,68 @@ end -- [[ SYNC STATE MANAGEMENT ]] -- function lock_utils.sync_device_state(device) + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" + + -- skip if unsupported, already synced, or busy (including a sync already in progress). is_device_busy + -- expires stale busy state, so a sync that stalled on a lost response can be retried. + if not (device:supports_capability(capabilities.lockCredentials) and + device:get_field(consts.SYNC.CODES_SYNCED) ~= consts.SYNC.CODES_SYNC_VERSION and + not lock_utils.is_device_busy(device)) then + return + end + + -- a persisted index from a sync begun under a different sync version is stale; start over. + if device:get_field(consts.SYNC.CODE_INDEX_VERSION) ~= consts.SYNC.CODES_SYNC_VERSION then + device:set_field(consts.SYNC.CODE_INDEX, nil, { persist = true }) + end + -- 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. device:send(clusters.DoorLock.attributes.SendPINOverTheAir:write(device, true)) -- if consts.SYNC.CODE_INDEX is nil, we haven't started syncing codes from the lock yet, - -- so start the process from 1. note: the Zigbee "Master Code" is associated with index 0. + -- so start the process from 0. Persist the index so we can resume if the driver is restarted mid-sync. if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then - device:set_field(consts.SYNC.CODE_INDEX, 1) + device:set_field(consts.SYNC.CODE_INDEX, 0, { persist = true }) + device:set_field(consts.SYNC.CODE_INDEX_VERSION, consts.SYNC.CODES_SYNC_VERSION, { persist = true }) end lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) device:send(clusters.DoorLock.server.commands.GetPINCode(device, device:get_field(consts.SYNC.CODE_INDEX))) end +-- Advance the sync-codes-from-lock flow after a response for the current code index (consts.SYNC.CODE_INDEX). +-- `occupied` is true only when the lock reported an occupied slot at that index. +-- `default_response` is true when the step was resolved by a Default Response: that is not treated as a +-- failure, but it is not an occupied slot either, so it does not count toward the consecutive unoccupied codes. +function lock_utils.continue_code_sync(device, occupied, default_response) + local clusters = require "st.zigbee.zcl.clusters" + local code_index = device:get_field(consts.SYNC.CODE_INDEX) + if code_index == nil then return end + + if occupied then + lock_utils.add_reported_credential(device, code_index) + -- reset the consecutive unoccupied codes counter since we found an occupied code + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) + elseif not default_response then + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1) + end + + if code_index >= tables.get_max_entries(device, "credentials") or + (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then + -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. + device:set_field(consts.SYNC.CODE_INDEX, nil, { persist = true }) + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, nil) + device:set_field(consts.SYNC.RETRIED_CODE_INDEX, nil) + device:set_field(consts.SYNC.CODES_SYNCED, consts.SYNC.CODES_SYNC_VERSION, { persist = true }) + lock_utils.clear_busy_state(device) + else + local next_index = code_index + 1 + device:set_field(consts.SYNC.CODE_INDEX, next_index, { persist = true }) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, next_index)) + end +end + -- [[ CREDENTIAL TABLE MANAGEMENT ]] -- @@ -117,7 +166,7 @@ end -- is left in place function lock_utils.add_reported_credential(device, credential_index) if tables.find_entry(device, "credentials", credential_index) then return end - lock_utils.add_credential_and_user(device, { + return lock_utils.add_credential_and_user(device, { userIndex = tables.next_index(device, "users"), credentialIndex = credential_index, }, true) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua b/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua index bd15eb3dd8..1a010aad96 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua @@ -34,6 +34,7 @@ test.register_coroutine_test( mock_device:expect_metadata_update({profile = "base-lock"}) test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") })) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) 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( test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + test.wait_for_events() + -- the refresh handler delays the sync by 15 seconds + test.mock_time.advance_time(15) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) }) end, { min_api_version = 14 diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua index 1ff448a719..ae4beefaeb 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua @@ -48,7 +48,7 @@ end -- freshly-started device (no cached capability state, CODE_INDEX starts at 1). local function expect_sync_device_state(device) test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.SendPINOverTheAir:write(device, true) }) - test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 1) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 0) }) end -- Helper: expect the messages produced by the legacy reload_all_codes path after @@ -102,6 +102,7 @@ end test.register_coroutine_test( "added: TYPED device with lockCodes emits migrated event, persists SLGA_MIGRATED, and injects refresh", function() + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) -- Migrated event is emitted before the injected refresh @@ -135,6 +136,10 @@ test.register_coroutine_test( DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), }) test.wait_for_events() + -- the refresh handler delays the sync by 15 seconds + test.mock_time.advance_time(15) + expect_sync_device_state(mock_device_typed) + test.wait_for_events() assert( mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, @@ -206,12 +211,11 @@ test.register_coroutine_test( -- ============================================================================ test.register_coroutine_test( - "doConfigure: SLGA_MIGRATED device with lockCredentials sends bind/configure then calls sync_device_state after 2-second delay", + "doConfigure: SLGA_MIGRATED device with lockCredentials sends bind/configure and does not start a sync (init handles it)", function() -- Pre-seed SLGA_MIGRATED so the main driver's do_configure fires (not legacy-handlers). mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "doConfigure" }) @@ -219,11 +223,35 @@ test.register_coroutine_test( mock_device_base:expect_metadata_update({ provisioning_state = "PROVISIONED" }) test.wait_for_events() - -- Timer fires: sync_device_state is called (GetPINCode slot 1) - test.mock_time.advance_time(2) + test.mock_time.advance_time(15) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "sync_device_state: persists the starting code index so sync can resume after restart", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + test.wait_for_events() + + test.mock_time.advance_time(15) test.socket.zigbee:__set_channel_ordering("relaxed") expect_sync_device_state(mock_device_base) test.wait_for_events() + + assert(mock_device_base.persistent_store[constants.SYNC.CODE_INDEX] == 0) + assert(mock_device_base.transient_store[constants.SYNC.CODE_INDEX] == nil) end, { test_init = make_test_init(mock_device_base) } ) @@ -278,7 +306,7 @@ test.register_coroutine_test( do local dev test.register_coroutine_test( - "infoChanged: switching from non-lockCodes to lockCodes+lockCredentials profile triggers full SLGA migration and two syncs", + "infoChanged: switching from non-lockCodes to lockCodes+lockCredentials profile triggers full SLGA migration and syncs once", function() -- Warm up device_cache with the original (lock-battery) profile via init. -- For a non-SLGA_MIGRATED device, legacy-handlers' init fires (no zigbee sends). @@ -324,7 +352,8 @@ do dev.id, DoorLock.attributes.NumberOfPINUsersSupported:read(dev), }) - -- Immediate sync_device_state + -- Immediate sync_device_state (from the refresh handler) + expect_sync_device_state(dev) test.wait_for_events() assert( @@ -332,10 +361,8 @@ do "SLGA_MIGRATED must be set after infoChanged profile switch" ) - -- Delayed sync_device_state (2 s) + -- Delayed sync_device_state is a no-op: the sync started by the refresh is still in progress test.mock_time.advance_time(2) - test.socket.zigbee:__set_channel_ordering("relaxed") - expect_sync_device_state(dev) test.wait_for_events() end, { @@ -386,6 +413,7 @@ test.register_coroutine_test( function() mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) test.socket.capability:__expect_send( mock_device_base:generate_test_message("main", @@ -396,6 +424,29 @@ test.register_coroutine_test( capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) ) test.wait_for_events() + -- the sync is delayed so the number of supported users can be read first + test.mock_time.advance_time(15) + expect_sync_device_state(mock_device_base) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: migrated device whose codes have already synced does not sync again", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + mock_device_base:set_field(constants.SYNC.CODES_SYNCED, constants.SYNC.CODES_SYNC_VERSION, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + test.wait_for_events() + mock_device_base:set_field(constants.SYNC.CODES_SYNCED, nil, { persist = true }) end, { test_init = make_test_init(mock_device_base) } ) @@ -436,6 +487,7 @@ test.register_coroutine_test( test.register_coroutine_test( "driver_switched: device with lockCodes and migrated=true persists SLGA_MIGRATED and updates metadata", function() + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) -- Migrated event is emitted before the injected refresh @@ -469,6 +521,10 @@ test.register_coroutine_test( DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), }) test.wait_for_events() + -- the refresh handler delays the sync by 15 seconds + test.mock_time.advance_time(15) + expect_sync_device_state(mock_device_typed) + test.wait_for_events() -- driverSwitched occurs after added, so migrated=true is already set in the capability state cache test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "driver_switched" }) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua index 3309f494d7..8760359340 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua @@ -68,6 +68,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) test.wait_for_events() -- Validate `migrate` command functionality. + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) @@ -82,6 +83,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) test.wait_for_events() + -- the refresh handler delays the sync by 15 seconds + test.mock_time.advance_time(15) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) }) + test.wait_for_events() assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") end ) @@ -97,6 +103,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") test.socket.capability:__queue_receive({ zero_slot_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) -- slot 0 is a valid credentialIndex but not a valid userIndex (minimum 1), so its user gets the next free userIndex, 2, without displacing slot 1. test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) @@ -111,6 +118,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ zero_slot_mock_device.id, DoorLock.attributes.LockState:read(zero_slot_mock_device) }) test.socket.zigbee:__expect_send({ zero_slot_mock_device.id, Alarm.attributes.AlarmCount:read(zero_slot_mock_device) }) test.wait_for_events() + -- the refresh handler delays the sync by 15 seconds + test.mock_time.advance_time(15) + test.socket.zigbee:__expect_send({ zero_slot_mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(zero_slot_mock_device, true) }) + test.socket.zigbee:__expect_send({ zero_slot_mock_device.id, DoorLock.server.commands.GetPINCode(zero_slot_mock_device, 0) }) + test.wait_for_events() end ) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua index 77af332b0f..b2c6355b26 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -29,6 +29,25 @@ local OperationEventSource = DoorLock.types.DrlkOperationEventSource local ProgrammingEventCode = DoorLock.types.ProgramEventCode local LockState = DoorLock.attributes.LockState +local messages = require "st.zigbee.messages" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local data_types = require "st.zigbee.data_types" +local ZclStatus = require "st.zigbee.generated.types.ZclStatus" + +local function build_default_response_rx(device, cluster, cmd, status) + local body = default_response.DefaultResponse(cmd, status) + return messages.ZigbeeMessageRx({ + address_header = messages.AddressHeader(device:get_short_address(), device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, zb_const.HA_PROFILE_ID, cluster), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(body.ID) }), + zcl_body = body, + }), + }) +end + local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), }) @@ -143,6 +162,37 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "get_pin_code_response: persists the sync code index while sync is in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- unoccupied slot: no credential events, just advance to the next index + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.AVAILABLE, + DoorLockUserType.UNRESTRICTED, + "" + ), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 2), + }) + test.wait_for_events() + + -- the index must be in the persistent store so sync can resume after a driver restart + assert(mock_device.persistent_store[constants.SYNC.CODE_INDEX] == 2) + assert(mock_device.transient_store[constants.SYNC.CODE_INDEX] == nil) + end +) + test.register_coroutine_test( "get_pin_code_response: completes sync when max entries reached", function() @@ -187,6 +237,77 @@ test.register_coroutine_test( -- Verify sync state is cleared (clear_busy_state sets BUSY to false, not nil) assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil) assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false) + assert(mock_device:get_field(constants.SYNC.CODES_SYNCED) == constants.SYNC.CODES_SYNC_VERSION) + end +) + +test.register_coroutine_test( + "get_pin_code_response: clears the consecutive unoccupied counter when the sync completes", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 6, { persist = true }) + mock_device:set_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 5, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, 6, DoorLockUserStatus.AVAILABLE, DoorLockUserType.UNRESTRICTED, ""), + }) + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) == nil) + assert(mock_device:get_field(constants.SYNC.CODES_SYNCED) == constants.SYNC.CODES_SYNC_VERSION) + end +) + +test.register_coroutine_test( + "sync_device_state: does not interrupt another command in progress", + function() + local lock_utils = require "lock_utils.utils" + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { credentialIndex = 4 }, {}) + + lock_utils.sync_device_state(mock_device) + test.wait_for_events() + + assert(mock_device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) == constants.LOCK_CREDENTIALS.ADD) + assert(mock_device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE).credentialIndex == 4) + end +) + +test.register_coroutine_test( + "sync_device_state: retries a stalled sync once the busy window has expired", + function() + local lock_utils = require "lock_utils.utils" + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time() - 60, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 4, { persist = true }) + mock_device:set_field(constants.SYNC.CODE_INDEX_VERSION, constants.SYNC.CODES_SYNC_VERSION, { persist = true }) + + -- resumes from the persisted index + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) + lock_utils.sync_device_state(mock_device) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "sync_device_state: restarts from index 0 when the persisted index belongs to another sync version", + function() + local lock_utils = require "lock_utils.utils" + mock_device:set_field(constants.SYNC.CODE_INDEX, 7, { persist = true }) + mock_device:set_field(constants.SYNC.CODE_INDEX_VERSION, constants.SYNC.CODES_SYNC_VERSION - 1, { persist = true }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) }) + lock_utils.sync_device_state(mock_device) + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CODE_INDEX_VERSION) == constants.SYNC.CODES_SYNC_VERSION) end ) @@ -563,4 +684,86 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "default_response: repeated failure to GetPINCode at the same index continues to the next index", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) + mock_device:set_field(constants.SYNC.RETRIED_CODE_INDEX, 3, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_rx(mock_device, DoorLock.ID, DoorLock.server.commands.GetPINCode.ID, ZclStatus.FAILURE), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 4), + }) + test.wait_for_events() + assert(mock_device.persistent_store[constants.SYNC.CODE_INDEX] == 4) + end +) + +test.register_coroutine_test( + "default_response: success to GetPINCode does not advance the sync", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_rx(mock_device, DoorLock.ID, DoorLock.server.commands.GetPINCode.ID, ZclStatus.SUCCESS), + }) + test.wait_for_events() + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == 3) + end +) + +test.register_coroutine_test( + "default_response: repeated failures complete the sync", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) + -- a default response does not increment the counter, so it must already be over the limit to end the sync + mock_device:set_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 6, {}) + mock_device:set_field(constants.SYNC.RETRIED_CODE_INDEX, 3, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_rx(mock_device, DoorLock.ID, DoorLock.server.commands.GetPINCode.ID, ZclStatus.NOT_FOUND), + }) + test.wait_for_events() + assert(mock_device:get_field(constants.SYNC.CODES_SYNCED) == constants.SYNC.CODES_SYNC_VERSION) + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil) + assert(mock_device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) == nil) + end +) + +test.register_coroutine_test( + "default_response: repeated failure does not count as an unoccupied code", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) + mock_device:set_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 5, {}) + mock_device:set_field(constants.SYNC.RETRIED_CODE_INDEX, 3, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_rx(mock_device, DoorLock.ID, DoorLock.server.commands.GetPINCode.ID, ZclStatus.FAILURE), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 4), + }) + test.wait_for_events() + assert(mock_device:get_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) == 5) + end +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua index 560e0b3768..ecb4b09364 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua @@ -29,6 +29,7 @@ local function test_init_new_capabilities() test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + -- the sync started by refresh is delayed by 15 seconds, and this test does not advance time end test.register_message_test( From bea20da93c038658b70eed8948f375e70d6abc10 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 16:14:31 -0600 Subject: [PATCH 3/9] Z-Wave Lock: Ensure a default pin length is set (#3336) * Z-Wave Lock: Ensure a default pin length is set * Z-Wave Lock: Ensure pin length bounds are set during migration --- drivers/SmartThings/zwave-lock/src/init.lua | 3 + .../zwave-lock/src/legacy-handlers/init.lua | 14 +++-- .../zwave-lock/src/lock_utils/constants.lua | 4 ++ .../zwave-lock/src/lock_utils/utils.lua | 18 ++++++ .../zwave-lock/src/schlage-lock/init.lua | 18 +++++- .../src/test/test_init_lifecycle_handlers.lua | 56 +++++++++++++++++++ .../test/test_lock_code_slga_migration.lua | 8 +++ .../zwave-lock/src/test/test_schlage_lock.lua | 29 ++++++++++ 8 files changed, 141 insertions(+), 9 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index 6c85639625..908488dc6f 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -56,6 +56,9 @@ function LockLifecycle.init(driver, device) device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) end + -- existing devices may predate the default PIN length being set + lock_utils.ensure_pin_length_bounds(device) + if device:supports_capability(capabilities.tamperAlert) then -- ensure our user/credential state is accurate to the current device state device:emit_event(capabilities.tamperAlert.tamper.clear()) diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 65cd6cca4b..118b0c5206 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -82,11 +82,15 @@ local function migrate(driver, device, cmd) device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) + local consts = require "lock_utils.constants" + + -- lockCodes tracks a single fixed code length, which only some locks (e.g. Schlage) report. + -- Without one, fall back to the Z-Wave spec default range so the bounds are always set. local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - if code_length then - device:emit_event(capabilities.lockCredentials.minPinCodeLen(code_length, { visibility = { displayed = false } })) - device:emit_event(capabilities.lockCredentials.maxPinCodeLen(code_length, { visibility = { displayed = false } })) - end + local min_pin_length = code_length or consts.DEFAULT_MIN_PIN_LENGTH + local max_pin_length = code_length or consts.DEFAULT_MAX_PIN_LENGTH + device:emit_event(capabilities.lockCredentials.minPinCodeLen(min_pin_length, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(max_pin_length, { visibility = { displayed = false } })) local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) if max_codes then @@ -99,8 +103,6 @@ local function migrate(driver, device, cmd) device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - local consts = require("lock_utils.constants") - device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore end diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index f66c452beb..f31cd28d36 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -41,6 +41,10 @@ lock_constants.LOCK_USERS = { } lock_constants.CRED_TYPE_PIN = "pin" +-- User Code Command Class has no capabilities report for PIN length, so +-- the zwave spec fixes pin length to 4-10 digits +lock_constants.DEFAULT_MIN_PIN_LENGTH = 4 +lock_constants.DEFAULT_MAX_PIN_LENGTH = 10 lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" lock_constants.DELAY_LOCK_EVENT_TIMER = "_delay_lock_event_timer" lock_constants.MAX_DELAY = 10 diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 64851e485a..8c472590b1 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -49,6 +49,24 @@ function lock_utils.sync_device_state(device) device:send(UserCode:Get({user_identifier = device:get_field(consts.SYNC.CODE_INDEX)})) end +-- Emit the spec default PIN length range (4-10) if no min/max has been reported yet. +-- Migrated devices copy theirs from lockCodes, so those are left alone to avoid +-- clobbering/misinterpreting them. Schlage locks report their own length later via a +-- configuration parameter, which overrides these defaults. +function lock_utils.ensure_pin_length_bounds(device) + if not device:supports_capability(capabilities.lockCredentials) then + return + end + if device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil then + device:emit_event(capabilities.lockCredentials.minPinCodeLen(consts.DEFAULT_MIN_PIN_LENGTH, + { visibility = { displayed = false } })) + end + if device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil then + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(consts.DEFAULT_MAX_PIN_LENGTH, + { visibility = { displayed = false } })) + end +end + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- diff --git a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua index 5d15d507f2..2d8477455d 100644 --- a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua @@ -16,6 +16,15 @@ local tables = require "lock_utils.tables" local zwave_handlers = require "lock_handlers.zwave_responses" local cap_handlers = require "lock_handlers.capabilities" +-- If the code length reported by the lock differs from the last known length, the +-- driver assumes the lock wiped all of its codes (as the legacy Schlage handler did +-- by synthesizing an ALL_USER_CODES_DELETED notification) and clears its own records. +-- +-- Before the lock reports its PIN length, the driver emits the default bounds of 4-10. +-- Many devices do not report PIN length, so this is specific to Schlage. +-- PIN_LENGTH_FROM_DEVICE is set to true once the lock itself has reported its length, +-- so the default bounds are never mistaken for a reported length and treated as a change. +local PIN_LENGTH_FROM_DEVICE = "pinLengthFromDevice" local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} local function do_configure(self, device) @@ -33,7 +42,10 @@ local function configuration_report(self, device, cmd) if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then local reported_code_length = cmd.args.configuration_value local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) - if current_code_length ~= nil and current_code_length ~= reported_code_length then + -- only a length previously reported by the lock counts; the spec default emitted at startup does not + local previously_reported = device:get_field(PIN_LENGTH_FROM_DEVICE) == true + device:set_field(PIN_LENGTH_FROM_DEVICE, true, { persist = true }) + if previously_reported and current_code_length ~= nil and current_code_length ~= reported_code_length then -- when the code length is changed, all the codes have been wiped tables.delete_all_entries(device, "credentials") tables.delete_all_entries(device, "users") @@ -79,11 +91,11 @@ end local function add_credential_handler(self, device, cmd) local DEFAULT_COMMANDS_DELAY = 4.2 - local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) local base_handler = function() cap_handlers.add_credential(self, device, cmd) end - if current_code_length == nil then + -- the default PIN length is not authoritative; wait for the lock's own value if we don't have it yet + if device:get_field(PIN_LENGTH_FROM_DEVICE) ~= true then device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, base_handler) else diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua index ac127f234a..add0119922 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -45,6 +45,13 @@ local mock_device_typed = test.mock_device.build_test_zwave_device({ _provisioning_state = "TYPED", }) +-- Schlage manufacturer (reports its own PIN length via configuration) +local mock_device_schlage = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x003B, +}) + -- lock-battery profile: lock + battery only (no lockCodes / lockCredentials) local mock_device_battery = test.mock_device.build_test_zwave_device({ profile = t_utils.get_profile_definition("lock-battery.yml"), @@ -58,6 +65,16 @@ local function make_test_init(device) end end + +local function expect_default_pin_length(device) + test.socket.capability:__expect_send( + device:generate_test_message("main", + capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + device:generate_test_message("main", + capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) +end + -- ============================================================================ -- added (device_added) -- ============================================================================ @@ -137,6 +154,7 @@ test.register_coroutine_test( mock_device_base:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) ) + expect_default_pin_length(mock_device_base) test.wait_for_events() end, { test_init = make_test_init(mock_device_base) } @@ -152,6 +170,44 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device_base) } ) +test.register_coroutine_test( + "init: default PIN length is only emitted once", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + -- migrated emits + test.socket.capability:__expect_send(mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + expect_default_pin_length(mock_device_base) + test.wait_for_events() + -- second init: values are now cached, so only the migrated events are re-emitted + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send(mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: Schlage device also gets the default PIN length (the config report overrides it later)", + function() + mock_device_schlage:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.socket.device_lifecycle:__queue_receive({ mock_device_schlage.id, "init" }) + test.socket.capability:__expect_send(mock_device_schlage:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_schlage:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + expect_default_pin_length(mock_device_schlage) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_schlage) } +) + test.register_coroutine_test( "init: lock-battery device (no lockCodes) does nothing regardless of SLGA_MIGRATED", function() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua index a486f943e8..7724fcea1e 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -75,6 +75,8 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) @@ -90,6 +92,8 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) @@ -107,6 +111,8 @@ test.register_coroutine_test( -- slot 0 is below the minimum credentialIndex for Z-Wave (1), so it is dropped test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) @@ -124,6 +130,8 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=2, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}, {credentialIndex=10, credentialType="pin", userIndex=3}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}, {userIndex=3, userName="Alex", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua index ea86f44a35..be743df1d6 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua @@ -16,6 +16,8 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local PIN_LENGTH_FROM_DEVICE = "pinLengthFromDevice" + local zwave_lock_endpoints = { { command_classes = { @@ -127,6 +129,33 @@ test.register_coroutine_test( { min_api_version = 15 } ) +test.register_coroutine_test( + "First configuration report after the default PIN length should not wipe existing credentials", + function() + added() + -- default (4-10) is in place; add a cached credential to confirm it survives the first real report + mock_device:set_field(PIN_LENGTH_FROM_DEVICE, nil, {}) + local tables = require "lock_utils.tables" + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { visibility = { displayed = false } }))) + tables.add_entry(mock_device, "credentials", { userIndex = 1, credentialIndex = 1, credentialType = "pin" }) + test.socket.zwave:__queue_receive({ + mock_device.id, + Configuration:Report({ parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, configuration_value = 6 }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6))) + test.wait_for_events() + assert(#(tables.get_state(mock_device, "credentials") or {}) == 1, "credential must not be wiped") + end, + { min_api_version = 15 } +) + test.register_coroutine_test( "Configuration report indicating code deletion should be handled", function() From c2f99b13703bc5d8847f4954f93933d56a2ad531 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:26:42 -0600 Subject: [PATCH 4/9] zigbee lock: remove unused constant (#3347) --- drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua | 1 - drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua | 1 - .../SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua | 3 --- 3 files changed, 5 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua index 06d40f520b..0e0ab066ea 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua @@ -14,7 +14,6 @@ lock_constants.SYNC = { CODES_FROM_LOCK = "syncCodesFromLock", CODE_INDEX = "syncCodeIndex", -- persisted until codesSynced is set CODE_INDEX_VERSION = "syncCodeIndexVersion", -- persisted; the CODES_SYNC_VERSION under which CODE_INDEX was started - RETRIED_CODE_INDEX = "syncRetriedCodeIndex", -- the code index whose GetPINCode request was already retried once CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", CODES_SYNCED = "codesSynced", -- persisted; holds the CODES_SYNC_VERSION of the last completed code sync 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) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua index 0eee57ecf0..dcada6e0cb 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua @@ -90,7 +90,6 @@ function lock_utils.continue_code_sync(device, occupied, default_response) -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. device:set_field(consts.SYNC.CODE_INDEX, nil, { persist = true }) device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, nil) - device:set_field(consts.SYNC.RETRIED_CODE_INDEX, nil) device:set_field(consts.SYNC.CODES_SYNCED, consts.SYNC.CODES_SYNC_VERSION, { persist = true }) lock_utils.clear_busy_state(device) else diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua index b2c6355b26..fc8c1da485 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -691,7 +691,6 @@ test.register_coroutine_test( mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) - mock_device:set_field(constants.SYNC.RETRIED_CODE_INDEX, 3, {}) test.socket.zigbee:__queue_receive({ mock_device.id, @@ -730,7 +729,6 @@ test.register_coroutine_test( mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) -- a default response does not increment the counter, so it must already be over the limit to end the sync mock_device:set_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 6, {}) - mock_device:set_field(constants.SYNC.RETRIED_CODE_INDEX, 3, {}) test.socket.zigbee:__queue_receive({ mock_device.id, @@ -751,7 +749,6 @@ test.register_coroutine_test( mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) mock_device:set_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 5, {}) - mock_device:set_field(constants.SYNC.RETRIED_CODE_INDEX, 3, {}) test.socket.zigbee:__queue_receive({ mock_device.id, From 01d20496fde5e50e1433f2ab11b2d39a3cf4fdfc Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:55:53 -0600 Subject: [PATCH 5/9] zigbee-lock: support programmingUser typed entries (#3332) Represent the Zigbee master code pins as a programmingUser, keep it across delete-all, refuse to delete these kinds of codes, refuse to create these kinds of codes, and promote reported codes to master when the lock says so. A user cannot be changed to or from the programmingUser type via updateUser, and auto-assigned credentials start at index 1 so they never claim the master slot. Move Yale-specific handling into its own sub-driver, which normalizes Yale's misreported user ids. Extend the lockCodes migration to take the known master code slot (0 for Yale) and always migrate it as a programming user. --------- Co-authored-by: Claude Sonnet 5.5 --- .../src/legacy-handlers/migrate.lua | 16 +- .../yale-fingerprint-lock/can_handle.lua | 2 +- .../src/legacy-handlers/yale/init.lua | 7 +- .../src/lock_handlers/capabilities.lua | 39 ++++- .../src/lock_handlers/zigbee_responses.lua | 40 ++--- .../zigbee-lock/src/lock_utils/constants.lua | 6 + .../zigbee-lock/src/lock_utils/tables.lua | 17 ++ .../zigbee-lock/src/lock_utils/utils.lua | 69 +++++++- .../zigbee-lock/src/sub_drivers.lua | 2 +- .../test/test_lock_code_slga_migration.lua | 52 +++++- .../test/test_lock_credentials_commands.lua | 156 ++++++++++++++++++ .../src/test/test_lock_users_commands.lua | 115 +++++++++++++ .../src/test/test_zigbee_responses.lua | 131 +++++++++++++++ .../zigbee-lock/src/test/test_zigbee_yale.lua | 151 +++++++++++++++++ .../test_zigbee_yale_fingerprint-lock.lua | 4 +- .../zigbee-lock/src/yale/can_handle.lua | 12 ++ .../SmartThings/zigbee-lock/src/yale/init.lua | 61 +++++++ .../zigbee-lock/src/yale/sub_drivers.lua | 8 + .../yale-fingerprint-lock/can_handle.lua | 4 +- .../yale-fingerprint-lock/fingerprints.lua | 0 .../{ => yale}/yale-fingerprint-lock/init.lua | 2 +- 21 files changed, 851 insertions(+), 43 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/yale/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/yale/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/yale/sub_drivers.lua rename drivers/SmartThings/zigbee-lock/src/{ => yale}/yale-fingerprint-lock/can_handle.lua (80%) rename drivers/SmartThings/zigbee-lock/src/{ => yale}/yale-fingerprint-lock/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-lock/src/{ => yale}/yale-fingerprint-lock/init.lua (93%) diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/migrate.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/migrate.lua index 66930d1b64..daf631c95b 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/migrate.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/migrate.lua @@ -12,7 +12,9 @@ local LockUsers = capabilities.lockUsers local LockCredentials = capabilities.lockCredentials -- Migrate lockCodes state to lockUsers/lockCredentials. -return function(driver, device, command) +-- `programming_code_slot` is the user_id, if any, known to hold a master code on this device. It is migrated +-- as a programmingUser user rather than a guest. +return function(driver, device, command, programming_code_slot) -- set supported credentials device:emit_event(LockCredentials.supportedCredentials({ post_migration_consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) @@ -43,7 +45,7 @@ return function(driver, device, command) for code_slot_str, _ in pairs(cached_lock_codes) do local code_slot = tonumber(code_slot_str) -- Slots that are not valid credential indices are dropped rather than migrated. - if math.type(code_slot) == "integer" and code_slot >= min_credential then + if code_slot and math.type(code_slot) == "integer" and code_slot >= min_credential then table.insert(ordered_code_slots, code_slot) if code_slot >= min_user then occupied_user_indices[code_slot] = true end else @@ -51,6 +53,13 @@ return function(driver, device, command) end end + -- A passed-in programming user code slot should always be migrated, even if it is not among the known codes. + if math.type(programming_code_slot) == "integer" and programming_code_slot >= min_credential + and cached_lock_codes[tostring(programming_code_slot)] == nil then + cached_lock_codes[tostring(programming_code_slot)] = post_migration_consts.PROGRAMMING_USER.DEFAULT_NAME + table.insert(ordered_code_slots, programming_code_slot) + if programming_code_slot >= min_user then occupied_user_indices[programming_code_slot] = true end + end table.sort(ordered_code_slots) local next_free_user_index = min_user for _, code_slot in ipairs(ordered_code_slots) do @@ -64,7 +73,8 @@ return function(driver, device, command) occupied_user_indices[user_index] = true end -- cached_lock_codes is formatted as { ["1"] = "Guest 1", ["2"] = "Guest 2" }, etc - table.insert(users, { userIndex = user_index, userType = "guest", userName = code_name }) + local user_type = code_slot == programming_code_slot and post_migration_consts.PROGRAMMING_USER.USER_TYPE or "guest" + table.insert(users, { userIndex = user_index, userType = user_type, userName = code_name }) table.insert(credentials, { userIndex = user_index, credentialIndex = code_slot, credentialType = post_migration_consts.CRED_TYPE_PIN, credentialName = code_name }) end -- manually ensure user/cred state is persisted, then emit events to populate the new capabilities with the migrated data. diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua index 3baa8f368c..ac61185642 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua @@ -2,7 +2,7 @@ -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device) - local fingerprints = require("yale-fingerprint-lock.fingerprints") + local fingerprints = require("yale.yale-fingerprint-lock.fingerprints") for _, fingerprint in ipairs(fingerprints) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then local subdriver = require("legacy-handlers.yale-fingerprint-lock") diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua index a85a9f8798..3669e7fa70 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua @@ -127,6 +127,10 @@ local get_pin_response_handler = function(driver, device, zb_mess) end end +local migrate = function(driver, device, command) + require("legacy-handlers.migrate")(driver, device, command, 0) +end + local yale_door_lock_driver = { NAME = "Yale Door Lock", zigbee_handlers = { @@ -139,7 +143,8 @@ local yale_door_lock_driver = { capability_handlers = { [LockCodes.ID] = { [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [LockCodes.commands.setCode.NAME] = set_code + [LockCodes.commands.setCode.NAME] = set_code, + [LockCodes.commands.migrate.NAME] = migrate } }, can_handle = require("legacy-handlers.yale.can_handle") diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index 1abb4cbea1..eb086a8e78 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -35,6 +35,12 @@ function CapabilityHandlers.add_user(driver, device, command) return end + -- programming users cannot be created through the capability + if command.args.userType == consts.PROGRAMMING_USER.USER_TYPE then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + -- Find the smallest positive userIndex not already in the table local next_available_index = tables.next_index(device, "users") local status = tables.add_entry(device, "users", { @@ -52,6 +58,12 @@ function CapabilityHandlers.update_user(driver, device, command) return end + -- a user cannot be made a programming user, and a programming user cannot be changed to another type + if (command.args.userType == consts.PROGRAMMING_USER.USER_TYPE) ~= lock_utils.is_programming_user(device, command.args.userIndex) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + local result_status if not tables.find_entry(device, "users", command.args.userIndex) then result_status = tables.add_entry(device, "users", { @@ -130,8 +142,11 @@ function CapabilityHandlers.add_credential(driver, device, command) -- userIndex 0 means "create a new user for this credential" (its index is chosen when the lock confirms the -- code). Any other userIndex must name an existing user that does not already have a credential (for now, -- each user may have at most one credential). + -- A programming user cannot be created or given a credential through the capability. local user_index = command.args.userIndex - if user_index ~= 0 and (not tables.find_entry(device, "users", user_index) + if command.args.userType == consts.PROGRAMMING_USER.USER_TYPE + or user_index ~= 0 and (not tables.find_entry(device, "users", user_index) + or lock_utils.is_programming_user(device, user_index) or tables.find_entry_by(device, "credentials", "userIndex", user_index)) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) return @@ -190,17 +205,33 @@ function CapabilityHandlers.update_credential(driver, device, command) device:send(LockCluster.server.commands.SetPINCode(device, command.args.credentialIndex, UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, + -- the credential's stored owner decides its type, so a known programming code stays a programming code + lock_utils.is_programming_user(device, existing.userIndex) and UserTypeEnum.MASTER_USER or UserTypeEnum.UNRESTRICTED, command.args.credentialData) ) end +-- Some locks report a successful ClearPINCode for a programming code but do not actually delete it, +-- so refuse rather than dropping the credential from our tables. +local function refuse_programming_credential_delete(device, injected_by_delete_user) + device.log.warn("ignoring deleteCredential for a programming code, it cannot be deleted") + if injected_by_delete_user then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.FAILURE) + lock_utils.clear_busy_state(device) + end + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) +end + function CapabilityHandlers.delete_credential(driver, device, command) local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) if cmd_in_progress == consts.LOCK_USERS.DELETE then -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + if lock_utils.is_programming_user(device, credential_args.userIndex) then + refuse_programming_credential_delete(device, true) + return + end device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) device:send(LockCluster.server.commands.ClearPINCode(device, credential_args.credentialIndex)) elseif lock_utils.is_device_busy(device) then @@ -212,6 +243,10 @@ function CapabilityHandlers.delete_credential(driver, device, command) lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) return end + if lock_utils.is_programming_user(device, found_cred.userIndex) then + refuse_programming_credential_delete(device, false) + return + end lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { credentialIndex = command.args.credentialIndex, credentialType = command.args.credentialType, diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua index ec5b755660..aa5e7f6309 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -71,11 +71,12 @@ function ZigbeeHandlers.clear_all_pin_codes_response(driver, device, zb_rx) -- apply result and identify command result statuses local user_status, credential_status if clear_pin_code_status == ResponseStatus.PASS then + -- The lock keeps programming codes when clearing all PINs, so keep them locally too. -- Only clear the users table when this response is for a deleteAllUsers flow. if command_in_progress == consts.LOCK_USERS.DELETE_ALL then - user_status = tables.delete_all_entries(device, "users") + user_status = lock_utils.delete_all_non_programming_users(device) end - credential_status = tables.delete_all_entries(device, "credentials") + credential_status = lock_utils.delete_all_non_programming_credentials(device) elseif clear_pin_code_status == ResponseStatus.FAIL then user_status = consts.COMMAND_RESULT.FAILURE credential_status = consts.COMMAND_RESULT.FAILURE @@ -143,6 +144,7 @@ function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) if command_in_progress == consts.SYNC.CODES_FROM_LOCK then -- Only add entries for slots that are actually occupied on the lock. local occupied = zb_rx.body.zcl_body.user_status.value == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED + local is_programming_code = zb_rx.body.zcl_body.user_type.value == clusters.DoorLock.types.DrlkUserType.MASTER_USER if user_id < device:get_field(consts.SYNC.CODE_INDEX) then device.log.warn_with({ hub_logs = true }, "GetPINCodeResponse for index %s already processed, expected %s", user_id, device:get_field(consts.SYNC.CODE_INDEX)) return -- return early if we've already processed this response. @@ -150,7 +152,7 @@ function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) device.log.warn_with({ hub_logs = true }, "Unexpected GetPINCodeResponse for index %s, expected %s", user_id, device:get_field(consts.SYNC.CODE_INDEX)) occupied = false -- treat this as an unoccupied slot to continue the sync end - lock_utils.continue_code_sync(device, occupied, false) + lock_utils.continue_code_sync(device, occupied, false, is_programming_code) end end @@ -179,12 +181,6 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) local event_code = tonumber(zb_rx.body.zcl_body.program_event_code.value) - if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then - if user_id >= 256 then -- Index is incorrectly written on these devices. Attempt to shift it to get an actual value - user_id = user_id >> 8 - end - end - local ProgramEventCode = clusters.DoorLock.types.ProgramEventCode -- MASTER_CODE_CHANGED = 1 -- PIN_CODE_ADDED = 2 @@ -218,7 +214,8 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) command_in_progress = consts.LOCK_CREDENTIALS.DELETE -- ensure the lockCredentials result is emitted after this with the correct command context end end - elseif event_code == ProgramEventCode.PIN_CODE_CHANGED and command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + elseif (event_code == ProgramEventCode.PIN_CODE_CHANGED or event_code == ProgramEventCode.MASTER_CODE_CHANGED) + and command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then result_status, user_index = lock_utils.apply_commanded_credential(device, command_in_progress, credential_args_in_use) end @@ -235,9 +232,12 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) end -- handle the case where we receive a programming event notification for a code we didn't send a command for - if event_code == ProgramEventCode.PIN_CODE_ADDED then + if event_code == ProgramEventCode.MASTER_CODE_CHANGED then + -- ensure the credential is represented locally. no-op if it already exists and state is accurate. + lock_utils.add_reported_credential(device, user_id, true) + elseif event_code == ProgramEventCode.PIN_CODE_ADDED then -- if no "addCredential" command is in progress, ensure the credential is represented locally - lock_utils.add_reported_credential(device, user_id) + lock_utils.add_reported_credential(device, user_id, false) elseif event_code == ProgramEventCode.PIN_CODE_DELETED then -- check if a credential exists locally for this user id, then -- try to delete the entries in our tables corresponding to this code. @@ -349,24 +349,24 @@ function ZigbeeHandlers.lock_state(driver, device, value, zb_rx) end end -function ZigbeeHandlers.max_pin_code_length(driver, device, value) - device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +function ZigbeeHandlers.max_pin_code_length(driver, device, zb_rx) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(zb_rx.value, { visibility = { displayed = false } })) end -function ZigbeeHandlers.min_pin_code_length(driver, device, value) - device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +function ZigbeeHandlers.min_pin_code_length(driver, device, zb_rx) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(zb_rx.value, { visibility = { displayed = false } })) end -function ZigbeeHandlers.number_of_pin_users_supported(driver, device, value) - if not device:supports_capability_by_id(capabilities.lockCodes.ID) and value.value > 0 then +function ZigbeeHandlers.number_of_pin_users_supported(driver, device, zb_rx) + if not device:supports_capability_by_id(capabilities.lockCodes.ID) and zb_rx.value > 0 then -- this device was generically fingerprinted, but supports PIN users, so we should migrate it. device:try_update_metadata({ profile = "base-lock" }) end if device:supports_capability(capabilities.lockCredentials) then - device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(zb_rx.value, {visibility = {displayed = false}})) end if device:supports_capability(capabilities.lockUsers) then - device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + device:emit_event(capabilities.lockUsers.totalUsersSupported(zb_rx.value, {visibility = {displayed = false}})) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua index 0e0ab066ea..bf9b924c3e 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua @@ -19,6 +19,12 @@ lock_constants.SYNC = { 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) } +-- constants defining programming code representation in the lock's state tables. +lock_constants.PROGRAMMING_USER = { + USER_TYPE = "programmingUser", + DEFAULT_NAME = "Programming Code" +} + lock_constants.COMMAND_RESULT = { SUCCESS = "success", FAILURE = "failure", diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua index 7166b21a25..7a0469359e 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -258,6 +258,23 @@ function table_utils.delete_all_entries(device, table_name) return COMMAND_RESULT.SUCCESS end +-- Delete every entry of a table for which `keep(entry)` is false, emitting a single update. +function table_utils.delete_entries_except(device, table_name, keep) + device.log.debug("table_helpers: attempting to delete all non-kept entries from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + local kept = {} + for _, entry in ipairs(t) do + if keep(entry) then table.insert(kept, entry) end + end + device:emit_event(def.attribute(kept, {visibility = {displayed = false}})) + persist_table(device, def, kept) + return COMMAND_RESULT.SUCCESS +end + -- Restore capability state from the persistent field store. -- Called during init to re-emit table events if the capability state cache -- has been wiped (e.g. after a hub reboot). Only emits for tables that have diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua index dcada6e0cb..3359739e3c 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua @@ -72,13 +72,13 @@ end -- `occupied` is true only when the lock reported an occupied slot at that index. -- `default_response` is true when the step was resolved by a Default Response: that is not treated as a -- failure, but it is not an occupied slot either, so it does not count toward the consecutive unoccupied codes. -function lock_utils.continue_code_sync(device, occupied, default_response) +function lock_utils.continue_code_sync(device, occupied, default_response, is_programming_code) local clusters = require "st.zigbee.zcl.clusters" local code_index = device:get_field(consts.SYNC.CODE_INDEX) if code_index == nil then return end if occupied then - lock_utils.add_reported_credential(device, code_index) + lock_utils.add_reported_credential(device, code_index, is_programming_code) -- reset the consecutive unoccupied codes counter since we found an occupied code device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) elseif not default_response then @@ -109,18 +109,24 @@ end -- The credential is added first; the user is only added if that succeeded, so a failed or -- colliding credential add leaves no user behind. Returns the result status of the credential add. function lock_utils.add_credential_and_user(device, args, add_user) - local name = "Guest " .. args.userIndex + local default_name = "Guest " .. args.userIndex + if args.userType == consts.PROGRAMMING_USER.USER_TYPE then + default_name = consts.PROGRAMMING_USER.DEFAULT_NAME + elseif args.userType == "admin" then + default_name = "Admin " .. args.userIndex + end + local credential_status = tables.add_entry(device, "credentials", { userIndex = args.userIndex, credentialIndex = args.credentialIndex, credentialType = args.credentialType or consts.CRED_TYPE_PIN, - credentialName = args.credentialName or name, + credentialName = args.credentialName or default_name, }) if add_user and credential_status == consts.COMMAND_RESULT.SUCCESS then tables.add_entry(device, "users", { userIndex = args.userIndex, - userName = args.credentialName or name, - userType = "guest", + userName = args.credentialName or default_name, + userType = args.userType or "guest", }) end return credential_status @@ -162,16 +168,61 @@ end -- Per report: ensure a user and PIN credential exist for a code the lock reports at `credential_index` -- without any command from us (sync, or a programming event). If a credential already exists there it --- is left in place -function lock_utils.add_reported_credential(device, credential_index) - if tables.find_entry(device, "credentials", credential_index) then return end +-- is left in place. A programming_user report creates a programming user. +function lock_utils.add_reported_credential(device, credential_index, is_programming_code) + local credential = tables.find_entry(device, "credentials", credential_index) + if credential then + if is_programming_code then lock_utils.update_entries_to_programming_credential_and_user(device, credential) end + return + end return lock_utils.add_credential_and_user(device, { userIndex = tables.next_index(device, "users"), credentialIndex = credential_index, + userType = is_programming_code and consts.PROGRAMMING_USER.USER_TYPE or nil, }, true) end +--[[ PROGRAMMING USER MANAGEMENT ]] -- + +-- Whether the user at `user_index` is a programming user. +function lock_utils.is_programming_user(device, user_index) + local user = tables.find_entry(device, "users", user_index) + return user ~= nil and user.userType == consts.PROGRAMMING_USER.USER_TYPE +end + +function lock_utils.update_entries_to_programming_credential_and_user(device, credential) + -- If the credential name is the default guest name, update it to the default programming code name. + if credential.credentialName == "Guest " .. credential.userIndex then + tables.update_entry(device, "credentials", credential.credentialIndex, { credentialName = consts.PROGRAMMING_USER.DEFAULT_NAME }) + end + + -- update userType as needed + local user = tables.find_entry(device, "users", credential.userIndex) + if not user or user.userType ~= "guest" then return end + local user_updates = { userType = consts.PROGRAMMING_USER.USER_TYPE } + -- If the user name is the default guest name, update it to the programming code name as well. + if user.userName == "Guest " .. user.userIndex then + user_updates.userName = consts.PROGRAMMING_USER.DEFAULT_NAME + end + tables.update_entry(device, "users", user.userIndex, user_updates) +end + +-- Delete every credential whose user is not a programming user. Returns the command result status. +function lock_utils.delete_all_non_programming_credentials(device) + return tables.delete_entries_except(device, "credentials", function(cred) + return lock_utils.is_programming_user(device, cred.userIndex) + end) +end + +-- Delete every user that is not a programming user. Returns the command result status. +function lock_utils.delete_all_non_programming_users(device) + return tables.delete_entries_except(device, "users", function(user) + return user.userType == consts.PROGRAMMING_USER.USER_TYPE + end) +end + + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) diff --git a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua index d3491ed664..1851ed5a0a 100644 --- a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua @@ -6,6 +6,6 @@ local sub_drivers = { lazy_load_if_possible("legacy-handlers"), lazy_load_if_possible("samsungsds"), lazy_load_if_possible("bad-battery-reporter"), - lazy_load_if_possible("yale-fingerprint-lock"), + lazy_load_if_possible("yale"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua index 8760359340..e69945daa4 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua @@ -41,11 +41,32 @@ local zero_slot_mock_device = test.mock_device.build_test_zigbee_device( } ) +local yale_zero_slot_mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Yale", + model = "YRD256", + server_clusters = { DoorLock.ID }, + }, + }, + data = { + lockCodes = json.encode({ + ["0"] = "Programming Code", + ["1"] = "Zach" + }), + } + } +) + zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) test.mock_device.add_test_device(zero_slot_mock_device) + test.mock_device.add_test_device(yale_zero_slot_mock_device) end test.set_test_init_function(test_init) @@ -126,4 +147,33 @@ test.register_coroutine_test( end ) -test.run_registered_tests() \ No newline at end of file +test.register_coroutine_test( + "Yale migrate treats slot 0 as a programming code", + function() + test.socket.device_lifecycle:__queue_receive({ yale_zero_slot_mock_device.id, "init" }) + test.socket.zigbee:__queue_receive({ yale_zero_slot_mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(yale_zero_slot_mock_device, 5) }) + test.socket.zigbee:__queue_receive({ yale_zero_slot_mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(yale_zero_slot_mock_device, 10) }) + test.socket.zigbee:__queue_receive({ yale_zero_slot_mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(yale_zero_slot_mock_device, 4) }) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.capability:__queue_receive({ yale_zero_slot_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + -- slot 0 is a valid credentialIndex but not a valid userIndex (minimum 1). It must not displace slot 1's valid + -- userIndex, so slot 0's user gets the next free value, 2. + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=2, userName="Programming Code", userType="programmingUser"}, {userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=0, credentialType="pin", userIndex=2, credentialName="Programming Code"}, {credentialIndex=1, credentialType="pin", userIndex=1, credentialName="Zach"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ yale_zero_slot_mock_device.id, DoorLock.attributes.LockState:read(yale_zero_slot_mock_device) }) + test.socket.zigbee:__expect_send({ yale_zero_slot_mock_device.id, Alarm.attributes.AlarmCount:read(yale_zero_slot_mock_device) }) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua index 0189dcc9f1..184f965eef 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -117,6 +117,23 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "addCredential: auto-assign (userIndex 0) skips credential slot 0, which is reserved for the programming code", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + end +) + test.register_coroutine_test( "addCredential: emits failure when the lock returns GENERAL_FAILURE", function() @@ -496,6 +513,83 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "deleteCredential: removes a credential whose userIndex maps to no user", + function() + seed_credentials({ { userIndex = 7, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 7 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: removes a credential whose credentialIndex maps to no credential", + function() + -- the credential exists in state, but the lock has nothing stored in that slot + seed_users({ { userIndex = 3, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 3, credentialIndex = 3, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 3, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 3) }) + test.wait_for_events() + + -- clearing an empty slot succeeds on the lock, so the stale state entry should be removed + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 3, userIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + test.register_coroutine_test( "deleteCredential: emits failure when the lock returns FAIL", function() @@ -656,6 +750,42 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "deleteAllCredentials: keeps programming code credentials locally when the lock returns PASS", + function() + seed_users({ + { userIndex = 1, userName = "Programming Code", userType = constants.PROGRAMMING_USER.USER_TYPE }, + { userIndex = 2, userName = "Alice", userType = "guest" }, + }) + local programming_credential = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + seed_credentials({ programming_credential, { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ programming_credential }, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + test.register_coroutine_test( "deleteAllCredentials: returns busy when another operation is already in progress", function() @@ -685,4 +815,30 @@ test.register_coroutine_test( end ) +-- ============================================================================ +-- Programming Codes: known programming codes keep their type on update +-- ============================================================================ + +local PROGRAMMING_USER = { userIndex = 1, userName = "Programming Code", userType = "programmingUser" } + +test.register_coroutine_test( + "updateCredential: a known programming code is sent as PROGRAMMING_USER, based on the credential's stored user", + function() + seed_users({ PROGRAMMING_USER }) + seed_credentials({ { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Programming Code" } }) + -- the userIndex must match the credential's stored owner; the owner's type decides PROGRAMMING_USER + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 0, "pin", "4321" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.MASTER_USER, "4321"), + }) + test.wait_for_events() + end +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua index 8ce128a641..95eaa6e818 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua @@ -628,4 +628,119 @@ test.register_coroutine_test( end ) +-- ============================================================================ +-- Programming Codes: the programmingUser userType cannot be set or cleared via addUser/updateUser +-- ============================================================================ + +local function expect_invalid(command_name) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = command_name, statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) +end + +test.register_coroutine_test( + "addUser: rejects the programmingUser userType", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Programming Code", "programmingUser" } }, + }) + expect_invalid("addUser") + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: rejects the programmingUser userType", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "programmingUser", "pin", "1234" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: rejects adding a credential to a programming user", + function() + seed_users({ { userIndex = 1, userName = "Programming Code", userType = "programmingUser" } }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 1, "guest", "pin", "1234" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: rejects changing a guest to the programmingUser userType", + function() + seed_users({ { userIndex = 1, userName = "Guest 1", userType = "guest" } }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Guest 1", "programmingUser" } }, + }) + expect_invalid("updateUser") + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: rejects changing a programming user to another userType", + function() + seed_users({ { userIndex = 1, userName = "Programming Code", userType = "programmingUser" } }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Programming Code", "guest" } }, + }) + expect_invalid("updateUser") + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: allows renaming a programming user", + function() + seed_users({ { userIndex = 1, userName = "Programming Code", userType = "programmingUser" } }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Front Door", "programmingUser" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Front Door", userType = "programmingUser" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua index fc8c1da485..e4a33c2ca6 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -763,4 +763,135 @@ test.register_coroutine_test( end ) +-- ============================================================================ +-- Programming Codes: represented as a programmingUser user + credential +-- ============================================================================ + +local PROGRAMMING_USERS = { { userIndex = 1, userName = "Programming Code", userType = "programmingUser" } } +local PROGRAMMING_CREDENTIALS = { { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Programming Code" } } + +test.register_coroutine_test( + "programming_event_notification: MASTER_CODE_CHANGED adds a programming user and credential if unknown", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.socket.zigbee:__queue_receive( + build_programming_event(mock_device, ProgrammingEventCode.MASTER_CODE_CHANGED, 0) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(PROGRAMMING_CREDENTIALS, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(PROGRAMMING_USERS, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "programming_event_notification: MASTER_CODE_CHANGED is a no-op if the credential is already known", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(PROGRAMMING_USERS, { visibility = { displayed = false } }))) + table_utils.add_entry(mock_device, "users", PROGRAMMING_USERS[1]) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(PROGRAMMING_CREDENTIALS, { visibility = { displayed = false } }))) + table_utils.add_entry(mock_device, "credentials", PROGRAMMING_CREDENTIALS[1]) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + build_programming_event(mock_device, ProgrammingEventCode.MASTER_CODE_CHANGED, 0) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "programming_event_notification: MASTER_CODE_CHANGED promotes a known guest credential's user to programmingUser", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + local guest_users = { { userIndex = 1, userName = "Guest 1", userType = "guest" } } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(guest_users, { visibility = { displayed = false } }))) + table_utils.add_entry(mock_device, "users", guest_users[1]) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } }))) + table_utils.add_entry(mock_device, "credentials", + { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Guest 1" }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + build_programming_event(mock_device, ProgrammingEventCode.MASTER_CODE_CHANGED, 0) + ) + -- the default-named guest credential is renamed along with its user + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(PROGRAMMING_CREDENTIALS, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(PROGRAMMING_USERS, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "get_pin_code_response: a programmingUser type syncs as a programming user", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 0, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.MASTER_USER, "1234"), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(PROGRAMMING_CREDENTIALS, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(PROGRAMMING_USERS, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.wait_for_events() + end +) + +local function sync_slot_zero(device, user_type, expected_users, expected_credentials) + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + device:set_field(constants.SYNC.CODE_INDEX, 0, {}) + + test.socket.zigbee:__queue_receive({ + device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + device, 0, DoorLockUserStatus.OCCUPIED_ENABLED, user_type, "1234"), + }) + test.socket.capability:__expect_send( + device:generate_test_message("main", + capabilities.lockCredentials.credentials(expected_credentials, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + device:generate_test_message("main", + capabilities.lockUsers.users(expected_users, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 1) }) + test.wait_for_events() +end + +test.register_coroutine_test( + "get_pin_code_response: on non-Yale locks, an occupied slot 0 syncs as a guest with the next free user index", + function() + sync_slot_zero(mock_device, DoorLockUserType.UNRESTRICTED, + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Guest 1" } }) + end +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua new file mode 100644 index 0000000000..f69f0ef8c4 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua @@ -0,0 +1,151 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Tests for the post-migration Yale subdriver: slot 255 handling and programming code deletion. + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local constants = require "lock_utils.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints = { + [1] = { id = 1, manufacturer = "Yale", model = "YRD256", server_clusters = { DoorLock.ID } }, + }, +}) +zigbee_test_utils.prepare_zigbee_env_info() + +local PROGRAMMING_USERS = { { userIndex = 1, userName = "Programming Code", userType = "programmingUser" } } +local PROGRAMMING_CREDENTIALS = { { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Programming Code" } } + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_programming_state() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(PROGRAMMING_CREDENTIALS, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(PROGRAMMING_USERS, { visibility = { displayed = false } }))) +end + +test.register_coroutine_test( + "GetPINCodeResponse for user id 0xFFEE is recorded as the slot 0 programming code and sync continues", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 0, {}) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, 0xFFEE, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.MASTER_USER, "1234"), + }) + expect_programming_state() + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "GetPINCodeResponse for slot 0 with a non-master type is still recorded as the programming code", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 0, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, 0xFFEE, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED_USER, "1234"), + }) + expect_programming_state() + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential(0) is ignored: no ClearPINCode is sent, the programming code is kept, and a failure is reported", + function() + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 0, {}) + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.MASTER_USER, "1234"), + }) + expect_programming_state() + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.wait_for_events() + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + mock_device:set_field(constants.SYNC.CODE_INDEX, nil) + mock_device:set_field(constants.DRIVER_STATE.BUSY, nil) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 0, "pin" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } }))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential for another slot still clears the PIN on a Yale lock", + function() + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) + test.wait_for_events() + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + mock_device:set_field(constants.SYNC.CODE_INDEX, nil) + mock_device:set_field(constants.DRIVER_STATE.BUSY, nil) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 3, "pin" } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 3) }) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua index ecb4b09364..4092ee49c5 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua @@ -21,8 +21,8 @@ local function test_init_new_capabilities() test.mock_device.add_test_device(mock_device) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Programming Code", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=0, credentialType="pin", userIndex=1, credentialName="Programming Code"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) diff --git a/drivers/SmartThings/zigbee-lock/src/yale/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale/can_handle.lua new file mode 100644 index 0000000000..fee6b159ff --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local consts = require "lock_utils.constants" + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + local manufacturer = device:get_manufacturer() + if slga_migrated and (manufacturer == "Yale" or manufacturer == "ASSA ABLOY iRevo") then + return true, require("yale") + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/yale/init.lua new file mode 100644 index 0000000000..ca9221d8b2 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale/init.lua @@ -0,0 +1,61 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local utils = require "st.utils" +local zigbee_handlers = require "lock_handlers.zigbee_responses" + +-- Yale (and ASSA ABLOY iRevo) locks hold their programming code at user id 0. +local YALE_PROGRAMMING_USER_ID = 0 +-- However, some Yale locks report the programming code (though it can only be altered via user_id 0) as being at user_id 0xFFEE. +local YALE_UNKNOWN_USER_ID = 0xFFEE + +-- Yale locks report some messages incorrectly: user ids may be in the high byte, and the programming code (slot 0) may be +-- reported as 0xFFEE and/or with a non-MASTER_USER user type. Returns a corrected copy of `zb_rx`, leaving the original +-- untouched. Messages without a user type (e.g. operating events) only get their user id corrected. +local function normalize_user(zb_rx) + local zcl_body = zb_rx.body.zcl_body + local user_id = tonumber(zcl_body.user_id.value) + local normalized_user_id = user_id + if user_id >= 256 then + normalized_user_id = (user_id == YALE_UNKNOWN_USER_ID) and YALE_PROGRAMMING_USER_ID or user_id >> 8 + end + + local programming_type = clusters.DoorLock.types.DrlkUserType.MASTER_USER + local needs_programming_type = normalized_user_id == YALE_PROGRAMMING_USER_ID and zcl_body.user_type ~= nil + and zcl_body.user_type.value ~= programming_type + if normalized_user_id == user_id and not needs_programming_type then + return zb_rx + end + + local new_zb_rx = utils.deep_copy(zb_rx) + new_zb_rx.body.zcl_body.user_id.value = normalized_user_id + if needs_programming_type then + new_zb_rx.body.zcl_body.user_type.value = programming_type + end + return new_zb_rx +end + +-- Wraps main driver's attribute handlers so that messages are all parsed with a corrected user id. +local function normalizing_response(handler) + return function(driver, device, zb_rx) + handler(driver, device, normalize_user(zb_rx)) + end +end + +local yale_lock_driver = { + NAME = "Yale Lock", + zigbee_handlers = { + cluster = { + [clusters.DoorLock.ID] = { + [clusters.DoorLock.client.commands.GetPINCodeResponse.ID] = normalizing_response(zigbee_handlers.get_pin_code_response), + [clusters.DoorLock.client.commands.ProgrammingEventNotification.ID] = normalizing_response(zigbee_handlers.programming_event_notification), + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = normalizing_response(zigbee_handlers.operating_event_notification), + } + } + }, + sub_drivers = require("yale.sub_drivers"), + can_handle = require("yale.can_handle") +} + +return yale_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/yale/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/yale/sub_drivers.lua new file mode 100644 index 0000000000..d71decd549 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("yale.yale-fingerprint-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/can_handle.lua similarity index 80% rename from drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/can_handle.lua index 48a6521059..ac6d2ec420 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/can_handle.lua @@ -5,10 +5,10 @@ local yale_fingerprint_lock_models = function(opts, driver, device) local consts = require "lock_utils.constants" local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false if not slga_migrated then return false end - local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") + local FINGERPRINTS = require("yale.yale-fingerprint-lock.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("yale-fingerprint-lock") + return true, require("yale.yale-fingerprint-lock") end end return false diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/init.lua similarity index 93% rename from drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua rename to drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/init.lua index 9965ef181e..74354267f5 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale/yale-fingerprint-lock/init.lua @@ -26,7 +26,7 @@ local yale_fingerprint_lock_driver = { } } }, - can_handle = require("yale-fingerprint-lock.can_handle") + can_handle = require("yale.yale-fingerprint-lock.can_handle") } return yale_fingerprint_lock_driver From fe766a9aeb36f09589c3a42beb9a41d7b924d529 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:49:02 -0600 Subject: [PATCH 6/9] Zwave Lock: Standardize Credential/User Addition Management (#3337) * zwave lock: standardize management of credential and user additions/update * zwave-lock: align credential/user management with zigbee-lock Co-authored-by: Cooper Towns Co-authored-by: Claude Opus 5.5 --- .../src/lock_handlers/capabilities.lua | 29 ++- .../src/lock_handlers/zwave_responses.lua | 13 +- .../zwave-lock/src/lock_utils/utils.lua | 106 ++++++--- .../test/test_lock_credentials_commands.lua | 215 +++++++++++++++++- .../src/test/test_lock_pre_configured.lua | 20 +- .../src/test/test_lock_users_commands.lua | 31 +++ .../zwave-lock/src/test/test_samsung_lock.lua | 9 +- .../zwave-lock/src/test/test_schlage_lock.lua | 17 +- .../src/test/test_zwave_responses.lua | 44 ++-- 9 files changed, 398 insertions(+), 86 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 7ebd38d191..d5173441dd 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -117,15 +117,27 @@ function CapabilityHandlers.add_credential(driver, device, command) return end - -- A userIndex of 0 means "auto-assign the next available slot" - local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex - local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + -- userIndex 0 means "create a new user for this credential" (its index is chosen when the lock confirms the + -- code). Any other userIndex must name an existing user that does not already have a credential (for now, + -- each user may have at most one credential). + local user_index = command.args.userIndex + if user_index ~= 0 and (not tables.find_entry(device, "users", user_index) + or tables.find_entry_by(device, "credentials", "userIndex", user_index)) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end - if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + local credentials_full = #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") + -- if we are being asked to create a new user (index 0), this also needs a free user index. + local users_full = (user_index == 0) and #(tables.get_state(device, "users") or {}) >= tables.get_max_entries(device, "users") + if credentials_full or users_full then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) return end + -- the credential always takes the next available slot. + local cred_index = tables.next_index(device, "credentials") + -- Set busy state and attempt to set the PIN on-device. lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { userIndex = user_index, @@ -152,11 +164,18 @@ function CapabilityHandlers.update_credential(driver, device, command) return end - if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + local existing = tables.find_entry(device, "credentials", command.args.credentialIndex) + if not existing then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) return end + -- a credential cannot be moved to another user: the given userIndex must already own this credential + if existing.userIndex ~= command.args.userIndex then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { userIndex = command.args.userIndex, credentialIndex = command.args.credentialIndex, diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua index 7822ab2bfe..4643978d2d 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -37,18 +37,7 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) if command_in_progress == consts.SYNC.CODES_FROM_LOCK then if user_code_occupied then - -- If an entry already exists at this index, this will be a no-op. - tables.add_entry(device, "users", { - userIndex = credential_index, - userName = "Guest " .. credential_index, - userType = "guest", - }) - tables.add_entry(device, "credentials", { - userIndex = credential_index, - credentialIndex = credential_index, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "Guest " .. credential_index, - }) + lock_utils.add_reported_credential(device, credential_index) -- reset the consecutive unoccupied codes counter since we found an occupied code device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) elseif user_code_available then diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 8c472590b1..21e2c4f595 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -91,48 +91,94 @@ function lock_utils.get_code_id_from_notification_event(event_params, v1_alarm_l return tostring(code_id) end -function lock_utils.set_credential_report_helper(device, credential_index) - -- cached values from capability command - local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) - local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} - local result_status +-- [[ CREDENTIAL TABLE MANAGEMENT ]] -- + +-- Add a credential and, optionally, a new user for it. `args.userIndex` is the credential's user. +-- When `add_user` is true the user is created too, so a user never exists without a credential; when false +-- the credential is attached to the already-existing user `args.userIndex`. +-- The credential is added first; the user is only added if that succeeded, so a failed or +-- colliding credential add leaves no user behind. Returns the result status of the credential add. +function lock_utils.add_credential_and_user(device, args, add_user) + local name = "Guest " .. args.userIndex + local credential_status = tables.add_entry(device, "credentials", { + userIndex = args.userIndex, + credentialIndex = args.credentialIndex, + credentialType = args.credentialType or consts.CRED_TYPE_PIN, + credentialName = args.credentialName or name, + }) + if add_user and credential_status == consts.COMMAND_RESULT.SUCCESS then + tables.add_entry(device, "users", { + userIndex = args.userIndex, + userName = args.credentialName or name, + userType = "guest", + }) + end + return credential_status +end + +-- Apply the arguments of an updateCredential command to the credentials table. +-- Returns the command result status and the credential's userIndex. +function lock_utils.update_commanded_credential(device, credential_args) + local existing = tables.find_entry(device, "credentials", credential_args.credentialIndex) + if not existing then return consts.COMMAND_RESULT.FAILURE end + local name = credential_args.credentialName + if name ~= nil and name ~= existing.credentialName then + local status = tables.update_entry(device, "credentials", credential_args.credentialIndex, { credentialName = name }) + if status ~= consts.COMMAND_RESULT.SUCCESS then return status end + end + return consts.COMMAND_RESULT.SUCCESS, existing.userIndex +end + +-- Per command: update the tables after the lock confirmed a set-credential command we sent. +-- Returns the command result status, or nil for any other command, plus the affected userIndex. +-- For addCredential, a userIndex of 0 creates a new user (its index is chosen here); otherwise the credential +-- is attached to the existing user with that index. +function lock_utils.apply_commanded_credential(device, command_in_progress, credential_args) if command_in_progress == consts.LOCK_CREDENTIALS.ADD then - result_status = tables.add_entry(device, "credentials", { - userIndex = credential_args.userIndex, + local user_index = credential_args.userIndex + if user_index == 0 then user_index = tables.next_index(device, "users") end + -- also covers a requested user that was deleted while the lock was confirming the code + local user_not_found = not tables.find_entry(device, "users", user_index) + local status = lock_utils.add_credential_and_user(device, { + userIndex = user_index, credentialIndex = credential_args.credentialIndex, - credentialType = consts.CRED_TYPE_PIN, + credentialType = credential_args.credentialType, credentialName = credential_args.credentialName, - }) + }, user_not_found) + return status, status == consts.COMMAND_RESULT.SUCCESS and user_index or nil elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then - result_status = consts.COMMAND_RESULT.SUCCESS - elseif not tables.find_entry(device, "credentials", credential_index) then - -- credential does not exist and no add/update command in progress, therefore it was added out-of-band - -- check what user slot we have to associate this with, and add a default user - local next_available_index = tables.next_index(device, "users") - if next_available_index <= tables.get_max_entries(device, "users") then - tables.add_entry(device, "users", { - userIndex = next_available_index, - userName = "Guest " .. next_available_index, - userType = "guest", - }) - tables.add_entry(device, "credentials", { - userIndex = next_available_index, - credentialIndex = credential_index, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "Guest " .. next_available_index, - }) - end + return lock_utils.update_commanded_credential(device, credential_args) end +end - -- emit command result - if command_in_progress and result_status then +-- Per report: ensure a user and PIN credential exist for a code the lock reports at `credential_index` +-- without any command from us (sync, or a user code event). If a credential already exists there it +-- is left in place. +function lock_utils.add_reported_credential(device, credential_index) + if tables.find_entry(device, "credentials", credential_index) then return end + lock_utils.add_credential_and_user(device, { + userIndex = tables.next_index(device, "users"), + credentialIndex = credential_index, + }, true) +end + +function lock_utils.set_credential_report_helper(device, credential_index) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + + local result_status, user_index = lock_utils.apply_commanded_credential(device, command_in_progress, credential_args) + if result_status then local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { - userIndex = credential_args.userIndex, + userIndex = user_index, credentialIndex = credential_args.credentialIndex, } or nil lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) lock_utils.clear_busy_state(device) + else + -- no add/update command in progress, therefore the credential was added out-of-band + lock_utils.add_reported_credential(device, credential_index) end end diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua index a4bc87bfe0..7cbfb825fd 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua @@ -144,11 +144,11 @@ end test.register_coroutine_test( "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", function() - -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + -- Queue the capability command: userIndex=0 (new user), userType="guest", credentialType="pin", credentialData="1234" test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) -- Expect SetPINCode to be sent to the lock @@ -158,11 +158,18 @@ test.register_coroutine_test( -- Lock responds with SUCCESS queue_user_code_added(1) - -- Handler adds the credential to the credentials table. + -- Handler adds the credential, then a new user, to the tables. test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -187,7 +194,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) expect_set_pin_code(1, "1234") @@ -216,7 +223,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) expect_set_pin_code(1, "1234") test.wait_for_events() @@ -225,7 +232,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 2, "guest", "pin", "5678" } }, + args = { 0, "guest", "pin", "5678" } }, }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", @@ -318,6 +325,200 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "addCredential: names the new user and credential after credentialName when one is given", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "guest", "pin", "1234", "Front Door" } }, + }) + expect_set_pin_code(1, "1234") + test.wait_for_events() + + queue_user_code_added(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Front Door" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Front Door", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: attaches the next free credential slot to an existing user without a credential when userIndex is non-zero", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + -- slot 1 is taken, so the next available credential slot, 2, is used + expect_set_pin_code(2, "5678") + test.wait_for_events() + + queue_user_code_added(2) + -- the credential is attached to the existing user 2; no user is added + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 2, credentialIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: fails without contacting the lock when the user already has a credential", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "5678" } }, + }) + -- No Z-Wave message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: fails without contacting the lock when userIndex does not map to an existing user", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 5, "guest", "pin", "1234" } }, + }) + -- No Z-Wave message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateCredential: rejects the command when userIndex is not the credential's current user", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 2, 1, "pin", "newPin9" } }, + }) + -- No Z-Wave message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateCredential: applies a changed credentialName to the credentials table", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Old" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin9", "New" } }, + }) + expect_set_pin_code(1, "newPin9") + test.wait_for_events() + + queue_user_code_added(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "New" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + -- ============================================================================ -- deleteCredential -- ============================================================================ diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua index c5f89b6672..5e65dcfa27 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua @@ -342,13 +342,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin03", "Guest 3" } }, + args = { 0, "guest", "pin", "pin03", "Guest 3" } }, }) expect_set_pin_code(3, "pin03") test.wait_for_events() - queue_user_code_added(1) + queue_user_code_added(3) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( @@ -360,6 +360,18 @@ test.register_coroutine_test( { visibility = { displayed = false } } )) ) + -- userIndex 0 creates a new user, which takes the next free user index; the credential takes the next free slot + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Guest 3", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( @@ -382,7 +394,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin01" } }, + args = { 0, "guest", "pin", "pin01" } }, }) expect_set_pin_code(3, "pin01") @@ -412,7 +424,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin03" } }, + args = { 0, "guest", "pin", "pin03" } }, }) expect_set_pin_code(3, "pin03") diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua index 6198015fde..310fae786f 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua @@ -298,6 +298,37 @@ test.register_coroutine_test( min_api_version = 15 }) +test.register_coroutine_test( + "addCredential: returns resourceExhausted for a new user when totalUsersSupported has been reached", + function() + -- Both supported users exist but only one has a credential, so the credentials table is not full. + set_total_users_supported(2) + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "guest", "pin", "1234" } }, + }) + -- No Z-Wave message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + -- ============================================================================ -- updateUser -- ============================================================================ diff --git a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua index 532e1da1a9..25034e8ef6 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua @@ -100,7 +100,14 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" }}, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({{ userIndex = 1, userName = "Guest 1", userType = "guest" }}, { visibility = { displayed = false } }) ) ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua index be743df1d6..0f971a3247 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua @@ -91,7 +91,14 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" }}, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({{ userIndex = 1, userName = "Guest 1", userType = "guest" }}, { visibility = { displayed = false } }) ) ) @@ -307,15 +314,15 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua index 24db26a3ba..b1b49d8b2a 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -81,15 +81,15 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -124,15 +124,15 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 20, userName = "Guest 20", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 20, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 20" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -161,19 +161,19 @@ test.register_coroutine_test( }), }) - -- User entry added + -- Credential entry added test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) - -- Credential entry added + -- User entry added test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -203,15 +203,15 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) @@ -237,15 +237,15 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, { visibility = { displayed = false } } )) ) From bfd7ccab040210ba492d1eb2b9f7b55af94fdd1e Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:21:28 -0600 Subject: [PATCH 7/9] zwave lock: make sync codes more robust, add it to refresh handling (#3339) * zwave lock: make sync codes more robust, add it to refresh handling * break apart occupied vs available as conceptually different * zwave-lock: ignore sync reports for other code indexes Co-authored-by: Cooper Towns Co-authored-by: Claude Opus 5.5 --- drivers/SmartThings/zwave-lock/src/init.lua | 6 +- .../zwave-lock/src/legacy-handlers/init.lua | 9 +- .../src/lock_handlers/capabilities.lua | 8 +- .../src/lock_handlers/zwave_responses.lua | 32 +-- .../zwave-lock/src/lock_utils/constants.lua | 5 +- .../zwave-lock/src/lock_utils/utils.lua | 51 +++- .../src/test/test_init_lifecycle_handlers.lua | 51 ++++ .../test/test_lock_code_slga_migration.lua | 18 +- .../src/test/test_zwave_responses.lua | 247 ++++++++++++++++++ 9 files changed, 394 insertions(+), 33 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index 908488dc6f..1979a9eaeb 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -40,8 +40,6 @@ function LockLifecycle.device_added(driver, device) command = capabilities.refresh.commands.refresh.NAME, args = {} }) - -- ensure our user/credential state is accurate to the current device state - device.thread:call_with_delay(5, function() lock_utils.sync_device_state(device) end) end function LockLifecycle.init(driver, device) @@ -63,6 +61,10 @@ function LockLifecycle.init(driver, device) -- ensure our user/credential state is accurate to the current device state device:emit_event(capabilities.tamperAlert.tamper.clear()) end + + -- ensure our user/credential state is accurate to the current device state. + -- the delay allows the number of supported users to be read first. + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) end function LockLifecycle.driver_switched(driver, device) diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 118b0c5206..95a8283714 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -96,14 +96,17 @@ local function migrate(driver, device, cmd) if max_codes then device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) - else - -- if we don't have a code length, request it from the device - device:send(UserCode:UsersNumberGet({})) end device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore + + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) end local legacy_capabilities = { diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index d5173441dd..441a4862b9 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -263,7 +263,10 @@ end function CapabilityHandlers.refresh(driver, device, cmd) device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) + + if device:supports_capability(capabilities.battery) then + device:send(Battery:Get({})) + end if device:supports_capability(capabilities.lockCredentials) then -- If we are missing the cached values for these attributes, read them so we can properly manage them locally @@ -271,6 +274,9 @@ function CapabilityHandlers.refresh(driver, device, cmd) (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then device:send(UserCode:UsersNumberGet({})) end + + -- Allow callers to try to sync device state- fails silently if the device is busy or already synced. + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) end end diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua index 4643978d2d..60fa0dd2e1 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -36,28 +36,18 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) local user_code_available = user_id_status == UserCode.user_id_status.AVAILABLE if command_in_progress == consts.SYNC.CODES_FROM_LOCK then - if user_code_occupied then - lock_utils.add_reported_credential(device, credential_index) - -- reset the consecutive unoccupied codes counter since we found an occupied code - device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) - elseif user_code_available then - local consecutive_unoccupied_codes = (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1 - device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, consecutive_unoccupied_codes) + local code_index = device:get_field(consts.SYNC.CODE_INDEX) + if code_index == nil then return end + if credential_index < code_index then + device.log.warn_with({ hub_logs = true }, "UserCode Report for index %s already processed, expected %s", credential_index, code_index) + return -- return early if we've already processed this report. + elseif credential_index ~= code_index then + device.log.warn_with({ hub_logs = true }, "Unexpected UserCode Report for index %s, expected %s", credential_index, code_index) + -- treat this as an unoccupied slot to continue the sync + user_code_occupied = false + user_code_available = true end - - -- Sync: continue to next code or finish - if credential_index >= tables.get_max_entries(device, "credentials") or - (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then - -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. - device:set_field(consts.SYNC.CODE_INDEX, nil) - lock_utils.clear_busy_state(device) - else - local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 - device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) - lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) - device:send(UserCode:Get({user_identifier = synced_code_index})) - end - return + lock_utils.continue_code_sync(device, user_code_occupied, user_code_available) elseif user_code_occupied then -- Code slot is now occupied: add or update credential and associated user lock_utils.set_credential_report_helper(device, credential_index) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index f31cd28d36..f3b7689da3 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -12,8 +12,11 @@ lock_constants.DRIVER_STATE = { lock_constants.SYNC = { CODES_FROM_LOCK = "syncCodesFromLock", - CODE_INDEX = "syncCodeIndex", + CODE_INDEX = "syncCodeIndex", -- persisted until codesSynced is set + CODE_INDEX_VERSION = "syncCodeIndexVersion", -- persisted; the CODES_SYNC_VERSION under which CODE_INDEX was started CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", + CODES_SYNCED = "codesSynced", -- persisted; holds the CODES_SYNC_VERSION of the last completed code sync + CODES_SYNC_VERSION = 1, -- bump to force every device to re-sync its codes (a mismatch with codesSynced triggers it; with syncCodeIndexVersion, restarts from the first index) } lock_constants.COMMAND_RESULT = { diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 21e2c4f595..de7ae9bf63 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -42,8 +42,23 @@ end function lock_utils.sync_device_state(device) local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + -- skip if unsupported, already synced, or busy (including a sync already in progress). + if not (device:supports_capability(capabilities.lockCredentials) and + device:get_field(consts.SYNC.CODES_SYNCED) ~= consts.SYNC.CODES_SYNC_VERSION and + not lock_utils.is_device_busy(device)) then + return + end + + -- a persisted index from a sync begun under a different sync version is stale; start over. + if device:get_field(consts.SYNC.CODE_INDEX_VERSION) ~= consts.SYNC.CODES_SYNC_VERSION then + device:set_field(consts.SYNC.CODE_INDEX, nil, { persist = true }) + end + + -- if consts.SYNC.CODE_INDEX is nil, we haven't started syncing codes from the lock yet, so start the + -- process from the first valid index. Persist the index so we can resume after a restart as needed. if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then - device:set_field(consts.SYNC.CODE_INDEX, 1) + device:set_field(consts.SYNC.CODE_INDEX, tables.get_minimum(device, "credentials"), { persist = true }) + device:set_field(consts.SYNC.CODE_INDEX_VERSION, consts.SYNC.CODES_SYNC_VERSION, { persist = true }) end lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) device:send(UserCode:Get({user_identifier = device:get_field(consts.SYNC.CODE_INDEX)})) @@ -67,6 +82,38 @@ function lock_utils.ensure_pin_length_bounds(device) end end +-- Advance the sync-codes-from-lock flow after a report for the current code index (consts.SYNC.CODE_INDEX). +-- `occupied` is true when the lock reported an occupied slot at that index; `available` is true only when +-- it explicitly reported the slot as available. Any other status (reserved, disabled, unknown) neither +-- resets nor advances the consecutive-unoccupied counter, so it can't end the sync early. +function lock_utils.continue_code_sync(device, occupied, available) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + local code_index = device:get_field(consts.SYNC.CODE_INDEX) + if code_index == nil then return end + + if occupied then + lock_utils.add_reported_credential(device, code_index) + -- reset the consecutive unoccupied codes counter since we found an occupied code + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) + elseif available then + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1) + end + + if code_index >= tables.get_max_entries(device, "credentials") or + (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then + -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. + device:set_field(consts.SYNC.CODE_INDEX, nil, { persist = true }) + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, nil) + device:set_field(consts.SYNC.CODES_SYNCED, consts.SYNC.CODES_SYNC_VERSION, { persist = true }) + lock_utils.clear_busy_state(device) + else + local next_index = code_index + 1 + device:set_field(consts.SYNC.CODE_INDEX, next_index, { persist = true }) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(UserCode:Get({user_identifier = next_index})) + end +end + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- @@ -157,7 +204,7 @@ end -- is left in place. function lock_utils.add_reported_credential(device, credential_index) if tables.find_entry(device, "credentials", credential_index) then return end - lock_utils.add_credential_and_user(device, { + return lock_utils.add_credential_and_user(device, { userIndex = tables.next_index(device, "users"), credentialIndex = credential_index, }, true) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua index add0119922..3cb38c81ab 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -160,6 +160,57 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device_base) } ) +test.register_coroutine_test( + "init: syncs codes after a delay and persists the starting code index so the sync can resume after restart", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + expect_default_pin_length(mock_device_base) + test.wait_for_events() + + -- the sync is delayed so the number of supported users can be read first + test.mock_time.advance_time(15) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device_base.id)) + test.wait_for_events() + + assert(mock_device_base.persistent_store[constants.SYNC.CODE_INDEX] == 1) + assert(mock_device_base.transient_store[constants.SYNC.CODE_INDEX] == nil) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: device whose codes have already synced does not sync again", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + mock_device_base:set_field(constants.SYNC.CODES_SYNCED, constants.SYNC.CODES_SYNC_VERSION, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + expect_default_pin_length(mock_device_base) + test.wait_for_events() + + test.mock_time.advance_time(15) + test.wait_for_events() + mock_device_base:set_field(constants.SYNC.CODES_SYNCED, nil, { persist = true }) + end, + { test_init = make_test_init(mock_device_base) } +) + test.register_coroutine_test( "init: device with lockCodes but SLGA_MIGRATED not set does nothing", function() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua index 7724fcea1e..4417114578 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -7,6 +7,8 @@ local capabilities = require "st.capabilities" local zw = require "st.zwave" --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) local t_utils = require "integration_test.utils" --- @type st.zwave.constants @@ -81,6 +83,8 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device.id)) test.wait_for_events() assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") end @@ -94,9 +98,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.wait_for_events() assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") end @@ -113,9 +119,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.wait_for_events() end ) @@ -132,9 +140,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}, {userIndex=3, userName="Alex", userType="guest"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) test.wait_for_events() end ) @@ -160,6 +170,8 @@ test.register_coroutine_test( test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(schlage_mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(schlage_mock_device.id)) test.wait_for_events() assert(schlage_mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") end diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua index b1b49d8b2a..08e3f610dc 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -28,6 +28,8 @@ local constants = require "lock_utils.constants" --- @type st.zwave.CommandClass.DoorLock local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) --- @type st.zwave.CommandClass.Notification @@ -142,6 +144,101 @@ test.register_coroutine_test( "CODE_INDEX must be nil after sync completes") assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false, "BUSY must be false after sync completes") + assert(mock_device:get_field(constants.SYNC.CODES_SYNCED) == constants.SYNC.CODES_SYNC_VERSION, + "CODES_SYNCED must record the completed sync version") + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "user_code_report: sync does not end early on non-available, non-occupied slots", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- more than 5 consecutive "disabled" slots must not count as unoccupied + for i = 1, 7 do + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = i, + user_id_status = UserCode.user_id_status.DISABLED, + }), + }) + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = i + 1 }):build_test_tx(mock_device.id) + ) + end + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == 8, "sync must still be in progress, index=" .. tostring(mock_device:get_field(constants.SYNC.CODE_INDEX) or "nil")) + assert(mock_device:get_field(constants.SYNC.CODES_SYNCED) == nil, "sync must not be marked complete") + end +, + { + min_api_version = 15 + }) + +-- the reads sent by the refresh handler on a device with no cached state +local function expect_refresh_reads() + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) +end + +test.register_coroutine_test( + "sync_device_state: restarts from the first index when the persisted index is from a different sync version", + function() + mock_device:set_field(constants.SYNC.CODE_INDEX, 7, { persist = true }) + mock_device:set_field(constants.SYNC.CODE_INDEX_VERSION, constants.SYNC.CODES_SYNC_VERSION - 1, { persist = true }) + + -- refresh schedules a delayed sync attempt + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.refresh.ID, command = "refresh", args = {} }, + }) + expect_refresh_reads() + test.wait_for_events() + test.mock_time.advance_time(15) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device.id)) + test.wait_for_events() + assert(mock_device.persistent_store[constants.SYNC.CODE_INDEX_VERSION] == constants.SYNC.CODES_SYNC_VERSION) + + -- reset shared device state + mock_device:set_field(constants.DRIVER_STATE.BUSY, false, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, nil, { persist = true }) + mock_device:set_field(constants.SYNC.CODE_INDEX_VERSION, nil, { persist = true }) + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "sync_device_state: does nothing while the device is busy", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.refresh.ID, command = "refresh", args = {} }, + }) + expect_refresh_reads() + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil, "sync must not start while busy") + + -- reset shared device state + mock_device:set_field(constants.DRIVER_STATE.BUSY, false, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, nil, { persist = true }) + mock_device:set_field(constants.SYNC.CODE_INDEX_VERSION, nil, { persist = true }) end , { @@ -423,4 +520,154 @@ test.register_coroutine_test( min_api_version = 15 }) +-- clear the sync state the tests below leave on the shared mock device +local function reset_sync_state() + mock_device:set_field(constants.DRIVER_STATE.BUSY, false, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, nil, { persist = true }) + mock_device:set_field(constants.SYNC.CODE_INDEX_VERSION, nil, { persist = true }) + mock_device:set_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, nil, {}) + mock_device:set_field(constants.SYNC.CODES_SYNCED, nil, { persist = true }) +end + +test.register_coroutine_test( + "user_code_report: sync ignores a report for an index that was already processed", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) + + -- a late, duplicate report for slot 2: no credential is added and no request is sent + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == 3, "sync must still be waiting on index 3") + reset_sync_state() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "user_code_report: sync treats a report for an unexpected index as an unoccupied slot and continues", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 3, {}) + + -- an occupied report for slot 5 while waiting on slot 3: no credential is recorded at slot 3 + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 5, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 4 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) == 1, + "an unexpected report counts as an unoccupied code") + reset_sync_state() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "user_code_report: persists the sync code index while sync is in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- unoccupied slot: no credential events, just advance to the next index + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE, + }), + }) + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + -- the index must be in the persistent store so sync can resume after a driver restart + assert(mock_device.persistent_store[constants.SYNC.CODE_INDEX] == 2) + assert(mock_device.transient_store[constants.SYNC.CODE_INDEX] == nil) + reset_sync_state() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "user_code_report: clears the consecutive unoccupied counter when the sync completes", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 6, { persist = true }) + mock_device:set_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 5, {}) + + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 6, + user_id_status = UserCode.user_id_status.AVAILABLE, + }), + }) + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) == nil) + assert(mock_device:get_field(constants.SYNC.CODES_SYNCED) == constants.SYNC.CODES_SYNC_VERSION) + reset_sync_state() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "sync_device_state: retries a stalled sync once the busy window has expired", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time() - 60, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 4, { persist = true }) + mock_device:set_field(constants.SYNC.CODE_INDEX_VERSION, constants.SYNC.CODES_SYNC_VERSION, { persist = true }) + + -- refresh schedules a delayed sync attempt, which resumes from the persisted index + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.refresh.ID, command = "refresh", args = {} }, + }) + expect_refresh_reads() + test.wait_for_events() + test.mock_time.advance_time(15) + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 4 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + reset_sync_state() + end, + { + min_api_version = 15 + } +) + test.run_registered_tests() From 89bad2409c548615655532c60b414f62cbde9ed2 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Thu, 12 Nov 2026 12:33:50 -0600 Subject: [PATCH 8/9] zwave lock: migrate codeName to credentialName by default --- .../SmartThings/zwave-lock/src/legacy-handlers/init.lua | 2 +- .../zwave-lock/src/test/test_lock_code_slga_migration.lua | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 95a8283714..81f513c094 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -77,7 +77,7 @@ local function migrate(driver, device, cmd) for _, code in ipairs(ordered_codes) do local user_index = #lock_users + 1 table.insert(lock_users, {userIndex = user_index, userType = "guest", userName = lock_codes[code.slot]}) - table.insert(lock_credentials, {userIndex = user_index, credentialIndex = code.index, credentialType = "pin"}) + table.insert(lock_credentials, {userIndex = user_index, credentialIndex = code.index, credentialType = "pin", credentialName = lock_codes[code.slot]}) end device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua index 4417114578..8fb281e59e 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -75,7 +75,7 @@ test.register_coroutine_test( test.wait_for_events() -- Validate migrate command test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1, credentialName="Zach"}, {credentialIndex=5, credentialType="pin", userIndex=2, credentialName="Steven"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) @@ -115,7 +115,7 @@ test.register_coroutine_test( init_code_slot(1, "Zach", mock_device) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) -- slot 0 is below the minimum credentialIndex for Z-Wave (1), so it is dropped - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1, credentialName="Zach"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) @@ -136,7 +136,7 @@ test.register_coroutine_test( init_code_slot(5, "Steven", mock_device) init_code_slot(10, "Alex", mock_device) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=2, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}, {credentialIndex=10, credentialType="pin", userIndex=3}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=2, credentialType="pin", userIndex=1, credentialName="Zach"}, {credentialIndex=5, credentialType="pin", userIndex=2, credentialName="Steven"}, {credentialIndex=10, credentialType="pin", userIndex=3, credentialName="Alex"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}, {userIndex=3, userName="Alex", userType="guest"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) @@ -162,7 +162,7 @@ test.register_coroutine_test( test.wait_for_events() -- Validate migrate command test.socket.capability:__queue_receive({ schlage_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) - test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1, credentialName="Zach"}, {credentialIndex=5, credentialType="pin", userIndex=2, credentialName="Steven"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6, { visibility = { displayed = false } }))) From c1ac8ec87bbfb8cb01edb57c632ad471f1f9b12f Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 13 Nov 2026 13:29:24 -0600 Subject: [PATCH 9/9] Revert "Z-Wave Lock: Restore Migration on Device Added (#3323)" This reverts commit 91a74981880faf8157e3cb711f48795dcc469c02. Conflicts with later changes (#3337, #3339, #3346) in the zwave-lock tests were resolved by dropping the tests added by #3323 and keeping the lock_utils require and Schlage mock device that remaining code and tests still use. Co-Authored-By: Claude Sonnet 5.5 --- drivers/SmartThings/zwave-lock/src/init.lua | 7 - .../src/test/test_init_lifecycle_handlers.lua | 63 ----- .../zwave-lock/src/test/test_keywe_lock.lua | 3 - .../zwave-lock/src/test/test_samsung_lock.lua | 224 --------------- .../zwave-lock/src/test/test_schlage_lock.lua | 260 ------------------ 5 files changed, 557 deletions(-) delete mode 100644 drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index 1979a9eaeb..a7d0d09852 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -24,13 +24,6 @@ local capability_handlers = require "lock_handlers.capabilities" local LockLifecycle = {} function LockLifecycle.device_added(driver, device) - if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then - -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. - -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, - -- and should be migrated manually by the user. - device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) - end if device:supports_capability(capabilities.tamperAlert) then device:emit_event(capabilities.tamperAlert.tamper.clear()) end diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua index 3cb38c81ab..ececee80dc 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -38,13 +38,6 @@ local mock_device_base = test.mock_device.build_test_zwave_device({ zwave_endpoints = zwave_lock_endpoints, }) --- Same profile but provisioning_state = "TYPED" (freshly fingerprinted) -local mock_device_typed = test.mock_device.build_test_zwave_device({ - profile = t_utils.get_profile_definition("base-lock.yml"), - zwave_endpoints = zwave_lock_endpoints, - _provisioning_state = "TYPED", -}) - -- Schlage manufacturer (reports its own PIN length via configuration) local mock_device_schlage = test.mock_device.build_test_zwave_device({ profile = t_utils.get_profile_definition("base-lock.yml"), @@ -79,31 +72,6 @@ end -- added (device_added) -- ============================================================================ -test.register_coroutine_test( - "added: TYPED device with lockCodes emits migrated event, persists SLGA_MIGRATED, and injects refresh", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) - - -- Migrated event emitted for TYPED+lockCodes device - test.socket.capability:__expect_send( - mock_device_typed:generate_test_message("main", - capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - ) - -- inject_capability_command calls the refresh handler: - -- DoorLock:OperationGet, Battery:Get, UserCode:UsersNumberGet (no cached values) - test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_typed.id)) - test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_typed.id)) - test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_typed.id)) - test.wait_for_events() - - assert( - mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, - "SLGA_MIGRATED must be true after added fires for a TYPED device" - ) - end, - { test_init = make_test_init(mock_device_typed) } -) - test.register_coroutine_test( "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", function() @@ -270,36 +238,5 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device_battery) } ) --- ============================================================================ --- driverSwitched (LockLifecycle.driver_switched) --- ============================================================================ - -test.register_coroutine_test( - "driver_switched: device with lockCodes and migrated=true persists SLGA_MIGRATED and updates metadata", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) - - -- Migrated event emitted for TYPED+lockCodes device - test.socket.capability:__expect_send( - mock_device_typed:generate_test_message("main", - capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - ) - -- inject_capability_command calls the refresh handler: - -- DoorLock:OperationGet, Battery:Get, UserCode:UsersNumberGet (no cached values) - test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_typed.id)) - test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_typed.id)) - test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_typed.id)) - test.wait_for_events() - - -- driverSwitched occurs after added, so migrated=true is already set in the capability state cache - test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "driver_switched" }) - mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.wait_for_events() - - assert(mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true) - end, - { test_init = make_test_init(mock_device_typed) } -) - -- ============================================================================ test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua index a9861b26bb..0a41f80f2b 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua @@ -9,7 +9,6 @@ local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) -local lock_utils = require "lock_utils.constants".DRIVER_STATE test.disable_startup_messages() @@ -45,7 +44,6 @@ test.set_test_init_function(test_init) local function added() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.socket.zwave:__expect_send( DoorLock:OperationGet({}):build_test_tx(mock_device.id) @@ -58,7 +56,6 @@ local function added() ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) test.wait_for_events() - assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") end test.register_coroutine_test( diff --git a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua deleted file mode 100644 index 25034e8ef6..0000000000 --- a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua +++ /dev/null @@ -1,224 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local test = require "integration_test" -local capabilities = require "st.capabilities" -local zw_test_utils = require "integration_test.zwave_test_utils" -local t_utils = require "integration_test.utils" -local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({version=1}) -local Battery = (require "st.zwave.CommandClass.Battery")({version=1}) -local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local lock_utils = require "lock_utils.constants".DRIVER_STATE - -test.disable_startup_messages() - -local SAMSUNG_MANUFACTURER_ID = 0x022E -local SAMSUNG_PRODUCT_TYPE = 0x0001 -local SAMSUNG_PRODUCT_ID = 0x0001 - -local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - _provisioning_state = "TYPED", - zwave_manufacturer_id = SAMSUNG_MANUFACTURER_ID, - zwave_product_type = SAMSUNG_PRODUCT_TYPE, - zwave_product_id = SAMSUNG_PRODUCT_ID, - } -) - -local function test_init() - test.mock_device.add_test_device(mock_device) -end - -test.set_test_init_function(test_init) - -local function added() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) - - test.socket.zwave:__expect_send( - DoorLock:OperationGet({}):build_test_tx(mock_device.id) - ) - test.socket.zwave:__expect_send( - Battery:Get({}):build_test_tx(mock_device.id) - ) - test.socket.zwave:__expect_send( - UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) - ) - test.wait_for_events() - assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") -end - -test.register_coroutine_test( - "When the device is added an unlocked event should be sent", - function() - added() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) - ) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end -) - -test.register_coroutine_test( - "Setting a user code name should be handled", - function() - added() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234"} } }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) - ) - ) - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.NEW_USER_CODE_ADDED, - event_parameter = "" } - ) - }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - UserCode:Get({user_identifier = 1}) - ) - ) - test.socket.zwave:__queue_receive({ - mock_device.id, - UserCode:Report({ - user_identifier = 1, - user_code = "1234", - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS - }) - }) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" }}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users({{ userIndex = 1, userName = "Guest 1", userType = "guest" }}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.commandResult( - { commandName = "addCredential", statusCode = "success", credentialIndex = 1, userIndex = 1}, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - end -) - -test.register_coroutine_test( - "Notification about correctly added code should be handled", - function() - added() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234"} } }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) - ) - ) - test.wait_for_events() - test.socket.zwave:__queue_receive({ mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.NEW_USER_CODE_ADDED, - event_parameter = string.char(1) - }) - }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - UserCode:Get({user_identifier = 1}) - ) - ) - end -) - -test.register_coroutine_test( - "All user codes should be reported as deleted upon changing Master Code", - function() - added() - mock_device:set_field("persistedUsers", { - { userIndex = 1, userName = "Code 1", userType = "guest" }, - { userIndex = 2, userName = "Code 2", userType = "guest" }, - { userIndex = 3, userName = "Code 3", userType = "guest" }, - }, { persist = true }) - mock_device:set_field("persistedCredentials", { - { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, - { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, - { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, - }, { persist = true }) - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "updateUser", - args = {1, "new name", "guest" } - }, - }) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "success", userIndex = 1 }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users({ - { userIndex = 1, userName = "new name", userType = "guest" }, - { userIndex = 2, userName = "Code 2", userType = "guest" }, - { userIndex = 3, userName = "Code 3", userType = "guest" } - }, - { visibility = { displayed = false } }) - ) - ) - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, - event_parameter = "" } - ) - }) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users({}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials({}, - { visibility = { displayed = false } }) - ) - ) - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua index 0f971a3247..5102cf1169 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua @@ -6,17 +6,9 @@ local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" local zw = require "st.zwave" local constants = require "lock_utils.constants" -local zw_test_utils = require "integration_test.zwave_test_utils" --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) -local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) -local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) -local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) - -local PIN_LENGTH_FROM_DEVICE = "pinLengthFromDevice" local zwave_lock_endpoints = { { @@ -31,7 +23,6 @@ local zwave_lock_endpoints = { local mock_device = test.mock_device.build_test_zwave_device({ profile = t_utils.get_profile_definition("base-lock.yml"), - _provisioning_state = "TYPED", zwave_endpoints = zwave_lock_endpoints, zwave_manufacturer_id = 0x003B, zwave_product_type = 0x0002, @@ -45,257 +36,6 @@ local function test_init() end test.set_test_init_function(test_init) -local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} - -local function added() - mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, nil, {}) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) - - test.socket.zwave:__expect_send( - DoorLock:OperationGet({}):build_test_tx(mock_device.id) - ) - test.socket.zwave:__expect_send( - Battery:Get({}):build_test_tx(mock_device.id) - ) - test.socket.zwave:__expect_send( - UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) - ) - test.wait_for_events() - assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "Device should be marked as migrated") -end - -test.register_coroutine_test( - "Setting a user code should result in the named code changed event firing", - function() - added() - test.timer.__create_and_queue_test_time_advance_timer(4.2, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234"} } }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number}) - ) - ) - test.wait_for_events() - test.mock_time.advance_time(4.2) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) - ) - ) - test.wait_for_events() - test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({user_identifier = 1, user_id_status = UserCode.user_id_status.STATUS_NOT_AVAILABLE, user_code="0000\n\r"}) }) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" }}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users({{ userIndex = 1, userName = "Guest 1", userType = "guest" }}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.commandResult( - { commandName = "addCredential", statusCode = "success", credentialIndex = 1, userIndex = 1}, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - end, - { min_api_version = 15 } -) - -test.register_coroutine_test( - "Configuration report should be handled", - function() - added() - test.socket.zwave:__queue_receive({ - mock_device.id, - Configuration:Report({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, - configuration_value = 6 - }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6)) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6)) - ) - end, - { min_api_version = 15 } -) - -test.register_coroutine_test( - "First configuration report after the default PIN length should not wipe existing credentials", - function() - added() - -- default (4-10) is in place; add a cached credential to confirm it survives the first real report - mock_device:set_field(PIN_LENGTH_FROM_DEVICE, nil, {}) - local tables = require "lock_utils.tables" - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, - { visibility = { displayed = false } }))) - tables.add_entry(mock_device, "credentials", { userIndex = 1, credentialIndex = 1, credentialType = "pin" }) - test.socket.zwave:__queue_receive({ - mock_device.id, - Configuration:Report({ parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, configuration_value = 6 }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6))) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6))) - test.wait_for_events() - assert(#(tables.get_state(mock_device, "credentials") or {}) == 1, "credential must not be wiped") - end, - { min_api_version = 15 } -) - -test.register_coroutine_test( - "Configuration report indicating code deletion should be handled", - function() - added() - test.socket.zwave:__queue_receive({ - mock_device.id, - Configuration:Report({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, - configuration_value = 6 - }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6)) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6)) - ) - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_device.id, - Configuration:Report({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, - configuration_value = 4 - }) - }) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users({}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials({}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4)) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(4)) - ) - end, - { min_api_version = 15 } -) - -test.register_coroutine_test( - "User code report indicating master code is available should indicate code deletion", - function() - added() - test.socket.zwave:__queue_receive({ - mock_device.id, - UserCode:Report({ - user_identifier = 0, - user_id_status = UserCode.user_id_status.AVAILABLE - }) - }) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users({}, - { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials({}, - { visibility = { displayed = false } }) - ) - ) - end, - { min_api_version = 15 } -) - -test.register_coroutine_test( - "Device should send appropriate configuration messages", - function() - added() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Configuration:Get({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number - }) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Association:Set({ - grouping_identifier = 2, - node_ids = {} - }) - ) - ) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end, - { min_api_version = 15 } -) - -test.register_coroutine_test( - "Basic Sets should result in an Association remove", - function() - added() - test.socket.zwave:__queue_receive({ - mock_device.id, - Basic:Set({ - value = 0x00 - }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({})) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Association:Remove({ - grouping_identifier = 1, - node_ids = {} - }) - ) - ) - end, - { min_api_version = 15 } -) - test.register_coroutine_test( "Masked occupied USER_CODE_REPORT during code sync advances to the next slot", function()