From b840e84f3ef6b741c45752eee00c72aed71ede9c Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 00:52:27 -0600 Subject: [PATCH 01/16] Z-Wave Lock: Let the lock decide whether a credential can be deleted Deletes were only reported when the lock confirmed them. A lock that refuses a delete (a Yale admin code) ignores the User Code Set and sends nothing, so the command never completed. If a delete is not confirmed within DELETE_CONFIRM_DELAY, the slot is now read back. A report that the slot is still occupied fails the deleteCredential, and the deleteUser that injected it, and leaves the user and credential in place. A timer left from an earlier delete is cancelled when a new one starts. The deleteCredential that deleteUser injects is now handled before the index 0 check, so a stale credential at index 0 is removed locally instead of being sent as user identifier 0. The tests for this are in the Yale admin code commit, which adds the lock that refuses deletes. Co-Authored-By: Claude Opus 5.5 --- .../src/lock_handlers/capabilities.lua | 44 +++++++++++++------ .../src/lock_handlers/zwave_responses.lua | 3 ++ .../zwave-lock/src/lock_utils/constants.lua | 3 ++ .../zwave-lock/src/lock_utils/utils.lua | 19 ++++++++ 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 441a4862b9..82740c4c95 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -189,22 +189,41 @@ function CapabilityHandlers.update_credential(driver, device, command) })) end -function CapabilityHandlers.delete_credential(driver, device, command) - -- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials. - if command.args.credentialIndex == 0 then - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) - return +-- Remove a credential from the lock. The busy state must already be set. +local function send_credential_delete(device, credential_index) + if credential_index == 0 then + -- user identifier 0 addresses every user code on the lock, so a stale entry is only removed locally + lock_utils.delete_credential_report_helper(device, credential_index) + else + device:send(UserCode:Set({ + user_identifier = credential_index, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + -- A lock that refuses the delete (e.g. a Yale admin code) ignores the Set and sends nothing, so read the + -- slot back if the delete has not completed. The report tells us whether the code is still there. A timer + -- left from an earlier delete is cancelled so it cannot read back the slot during this one. + local previous_timer = device:get_field(consts.DELETE_CONFIRM_TIMER) + if previous_timer ~= nil then device.thread:cancel_timer(previous_timer) end + device:set_field(consts.DELETE_CONFIRM_TIMER, device.thread:call_with_delay(consts.DELETE_CONFIRM_DELAY, function() + device:set_field(consts.DELETE_CONFIRM_TIMER, nil) + if lock_utils.is_delete_in_progress(device, credential_index) then + device:send(UserCode:Get({ user_identifier = credential_index })) + end + end)) end +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. + -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. A stale + -- credential at index 0 is removed locally by send_credential_delete. local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} - device:send(UserCode:Set({ - user_identifier = credential_args.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + send_credential_delete(device, credential_args.credentialIndex) + elseif command.args.credentialIndex == 0 then + -- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials. + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) elseif lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) else @@ -219,10 +238,7 @@ function CapabilityHandlers.delete_credential(driver, device, command) credentialType = command.args.credentialType, userIndex = found_cred.userIndex, }) - device:send(UserCode:Set({ - user_identifier = command.args.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + send_credential_delete(device, command.args.credentialIndex) 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 60fa0dd2e1..997db20633 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -48,6 +48,9 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) user_code_available = true end lock_utils.continue_code_sync(device, user_code_occupied, user_code_available) + elseif user_code_occupied and lock_utils.is_delete_in_progress(device, credential_index) then + -- the code is still on the lock after a delete, so the lock refused it + lock_utils.emit_delete_failure(device) 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 f3b7689da3..27268fb258 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -51,5 +51,8 @@ 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 +-- Seconds to wait for a delete to be confirmed before reading the slot back +lock_constants.DELETE_CONFIRM_DELAY = 4.2 +lock_constants.DELETE_CONFIRM_TIMER = "_delete_confirm_timer" return lock_constants diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index de7ae9bf63..22c7a17105 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -37,6 +37,25 @@ function lock_utils.clear_busy_state(device) end +-- True if a deleteCredential or deleteUser of the credential at credential_index is waiting on the lock. +function lock_utils.is_delete_in_progress(device, credential_index) + 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 {} + return (command_in_progress == consts.LOCK_CREDENTIALS.DELETE or command_in_progress == consts.LOCK_USERS.DELETE) and + credential_args.credentialIndex == credential_index +end + +-- Report a delete the lock refused, leaving the user and credential in place. +function lock_utils.emit_delete_failure(device) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress == consts.LOCK_USERS.DELETE then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.FAILURE) + end + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) + lock_utils.clear_busy_state(device) +end + + -- [[ SYNC STATE MANAGEMENT ]] -- function lock_utils.sync_device_state(device) From 9b51100ae5b6ce47c652cba88241554827a07e81 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 00:52:44 -0600 Subject: [PATCH 02/16] Z-Wave Lock: Add generic support for a lock admin code A lock may keep an admin code in a credential outside its user slots. It is exposed like any other credential, owned by a user with the programmingUser type (consts.ADMIN). Generic lock code only knows that rule; where the admin code lives is left to the sub-driver for the lock. - tables.get_admin_credential finds the programmingUser credential outside the user slots. A programmingUser in an ordinary user slot is an ordinary user. - The admin code and its user do not count toward the credential and user limits (tables.is_full), and add_entry can skip the limit for the admin code itself. - deleteAllCredentials, deleteAllUsers and the all user codes deleted reports (Notification and Alarm v1 type 32) keep the admin code in state, since the lock keeps it. - send_add_credential and is_code_occupied are shared so a sub-driver can reuse them. The tests for this are in the Yale admin code commit, the first lock with an admin code. Co-Authored-By: Claude Opus 5.5 --- .../src/lock_handlers/capabilities.lua | 38 +++++++------ .../src/lock_handlers/zwave_responses.lua | 8 +-- .../zwave-lock/src/lock_utils/constants.lua | 6 ++ .../zwave-lock/src/lock_utils/tables.lua | 55 ++++++++++++++++--- .../zwave-lock/src/lock_utils/utils.lua | 26 ++++++++- .../src/zwave-alarm-v1-lock/init.lua | 6 +- 6 files changed, 105 insertions(+), 34 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 82740c4c95..9f25c5e999 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -111,6 +111,21 @@ end -- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- +-- Set the busy state and write the PIN to the lock. The caller must already have validated the command. +function CapabilityHandlers.send_add_credential(device, user_index, cred_index, command) + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = user_index, + credentialIndex = cred_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = cred_index, + user_code = command.args.credentialData, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) +end + function CapabilityHandlers.add_credential(driver, device, command) if lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) @@ -127,9 +142,9 @@ function CapabilityHandlers.add_credential(driver, device, command) return end - local credentials_full = #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") + local credentials_full = tables.is_full(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") + local users_full = (user_index == 0) and tables.is_full(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 @@ -139,17 +154,7 @@ function CapabilityHandlers.add_credential(driver, device, command) 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, - credentialIndex = cred_index, - credentialType = command.args.credentialType, - credentialName = command.args.credentialName, - }) - device:send(UserCode:Set({ - user_identifier = cred_index, - user_code = command.args.credentialData, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS - })) + CapabilityHandlers.send_add_credential(device, user_index, cred_index, command) end function CapabilityHandlers.update_credential(driver, device, command) @@ -246,7 +251,8 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local send_clear_commands = function() - -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot + -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with an + -- admin code (Yale) keeps it, so the admin entries are kept in state below. device:send(UserCode:Set({ user_identifier = 0, user_id_status = UserCode.user_id_status.AVAILABLE @@ -254,10 +260,10 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) -- Delayed cleanup: ensure all tables are cleared after the lock has processed the command device.thread:call_with_delay(4, function() if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then - tables.delete_all_entries(device, "users") + lock_utils.delete_all_non_admin_entries(device, "users") lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) end - tables.delete_all_entries(device, "credentials") + lock_utils.delete_all_non_admin_entries(device, "credentials") lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) lock_utils.clear_busy_state(device) 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 997db20633..2d55c185d0 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -31,8 +31,7 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) -- Determine boolean states of user code slot - local user_code_occupied = user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or - (user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code) + local user_code_occupied = lock_utils.is_code_occupied(cmd) local user_code_available = user_id_status == UserCode.user_id_status.AVAILABLE if command_in_progress == consts.SYNC.CODES_FROM_LOCK then @@ -90,8 +89,9 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd) local access_control_event = Notification.event.access_control if event == access_control_event.ALL_USER_CODES_DELETED then - tables.delete_all_entries(device, "credentials") - tables.delete_all_entries(device, "users") + -- Deleting all user codes does not affect the admin code. + lock_utils.delete_all_non_admin_entries(device, "credentials") + lock_utils.delete_all_non_admin_entries(device, "users") elseif event == access_control_event.SINGLE_USER_CODE_DELETED then lock_utils.delete_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 27268fb258..7d5f06da7f 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-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 the first index) } +lock_constants.ADMIN = { + -- The lockUsers userType for the admin code. + USER_TYPE = "programmingUser", + NAME = "Master Code", +} + lock_constants.COMMAND_RESULT = { SUCCESS = "success", FAILURE = "failure", diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua index c6da0ac401..413f8e6122 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -3,7 +3,8 @@ local capabilities = require "st.capabilities" local st_utils = require "st.utils" -local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT +local consts = require "lock_utils.constants" +local COMMAND_RESULT = consts.COMMAND_RESULT local table_utils = {} @@ -156,11 +157,42 @@ function table_utils.get_max_entries(device, table_name) return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing end +-- Returns the admin code credential, or nil if there is none. A lock may keep an admin code in a credential +-- outside its user slots, owned by a user with the admin userType (consts.ADMIN.USER_TYPE); where that +-- credential lives is manufacturer specific and handled by the sub-driver for the lock. A user given the admin +-- userType in one of the lock's user slots is an ordinary user. +function table_utils.get_admin_credential(device) + local user_slots = table_utils.get_max_entries(device, "credentials") + local users = table_utils.get_state(device, "users") or {} + for _, credential in ipairs(table_utils.get_state(device, "credentials") or {}) do + if credential.credentialIndex > user_slots then + for _, user in ipairs(users) do + if user.userIndex == credential.userIndex and user.userType == consts.ADMIN.USER_TYPE then + return credential + end + end + end + end +end + +-- Return true if the named table has reached its max_entries limit. The admin code credential, and the +-- user that owns it, are not one of the lock's user slots, so they do not count toward the limit. +function table_utils.is_full(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return true end + local admin = table_utils.get_admin_credential(device) + local count = 0 + for _, entry in ipairs(table_utils.get_state(device, def) or {}) do + if not (admin and entry.userIndex == admin.userIndex) then count = count + 1 end + end + return count >= table_utils.get_max_entries(device, table_name) +end + -- Add an entry to a named table. The entry must satisfy all required_keys for -- that table. An entry whose match_key value already exists in the -- table is skipped to prevent duplicates. If the table has a max_entries limit, --- entries that exceed the limit are not added. -function table_utils.add_entry(device, table_name, entry) +-- entries that exceed the limit are not added, unless exempt_from_limit is true (used for the admin code). +function table_utils.add_entry(device, table_name, entry, exempt_from_limit) device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) local def = resolve_table_def(device, table_name) if not def then return COMMAND_RESULT.FAILURE end @@ -168,7 +200,7 @@ function table_utils.add_entry(device, table_name, entry) local t = table_utils.get_state(device, def) if not t then return COMMAND_RESULT.FAILURE end - if #t >= table_utils.get_max_entries(device, table_name) then + if not exempt_from_limit and table_utils.is_full(device, table_name) then device.log.warn(string.format( "table_helpers: cannot add entry to %q, max entries reached", table_name )) @@ -247,13 +279,20 @@ function table_utils.delete_entry(device, table_name, matcher) return COMMAND_RESULT.FAILURE end --- Delete all entries from a table. -function table_utils.delete_all_entries(device, table_name) +-- Delete all entries from a table. If keep is given, entries for which keep(entry) +-- returns true are retained. +function table_utils.delete_all_entries(device, table_name, keep) device.log.debug("table_helpers: attempting to delete all entries from " .. table_name) local def = resolve_table_def(device, table_name) if not def then return COMMAND_RESULT.FAILURE end - device:emit_event(def.attribute({}, {visibility = {displayed = false}})) - persist_table(device, def, {}) + local kept = {} + if keep then + for _, entry in ipairs(table_utils.get_state(device, def) or {}) do + if keep(entry) then table.insert(kept, entry) end + end + end + device:emit_event(def.attribute(kept, {visibility = {displayed = false}})) + persist_table(device, def, kept) return COMMAND_RESULT.SUCCESS end diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 22c7a17105..0abdb62078 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -4,6 +4,8 @@ local capabilities = require "st.capabilities" local consts = require "lock_utils.constants" local tables = require "lock_utils.tables" +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) local lock_utils = {} @@ -59,8 +61,6 @@ end -- [[ SYNC STATE MANAGEMENT ]] -- 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 @@ -106,7 +106,6 @@ end -- 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 @@ -134,6 +133,20 @@ function lock_utils.continue_code_sync(device, occupied, available) end +-- [[ ADMIN CODE MANAGEMENT ]] -- +-- +-- The admin code credential is found with tables.get_admin_credential. + +-- Clear every entry of the "users" or "credentials" table except the admin code credential and its user. +function lock_utils.delete_all_non_admin_entries(device, table_name) + local admin = tables.get_admin_credential(device) + local match_key = table_name == "users" and "userIndex" or "credentialIndex" + tables.delete_all_entries(device, table_name, function(entry) + return admin ~= nil and entry[match_key] == admin[match_key] + end) +end + + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) @@ -148,6 +161,13 @@ end -- [[ HELPERS ]] -- +-- True if a User Code Report shows a code in the slot. A lock that does not report the status of a slot +-- still holds a code if it reports one. +function lock_utils.is_code_occupied(cmd) + return cmd.args.user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or + (cmd.args.user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code ~= nil) +end + function lock_utils.get_code_id_from_notification_event(event_params, v1_alarm_level) local code_id = v1_alarm_level if event_params ~= nil and event_params ~= "" then diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua index fa9836498b..ec5736979d 100644 --- a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua @@ -69,9 +69,9 @@ local function alarm_report_handler(driver, device, cmd) event = capabilities.lock.lock.locked() event.data = {method = METHOD.AUTO} elseif (alarm_type == 32) then - -- all credentials have been deleted - tables.delete_all_entries(device, "credentials") - tables.delete_all_entries(device, "users") + -- all user codes have been deleted, which does not affect the admin code + lock_utils.delete_all_non_admin_entries(device, "credentials") + lock_utils.delete_all_non_admin_entries(device, "users") elseif (alarm_type == 33) then -- credential has been deleted lock_utils.delete_credential_report_helper(device, credential_index) From 8e61ee2fee6be54a068942ce8caff322235da968 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 00:52:56 -0600 Subject: [PATCH 03/16] Z-Wave Lock: Add the Yale admin code sub-driver Yale locks keep their admin (master) code in user code slot 251, outside the lock's user slots. The new yale-lock sub-driver, used for migrated Yale locks, exposes it as a credential at index 251 owned by a programmingUser user with the same index. - addCredential at index 251 writes the admin code. It must have the programmingUser type, is not limited by the lock's user slots, and is rejected as occupied if an admin code is already recorded. The lock confirms it with either NEW_USER_CODE_ADDED (ZW2) or the master code changed notification (ZW3). - A report of slot 251 records the admin code, including one set at the lock, one reported during a sync, and one reported during a command for another credential. While the admin code itself is being updated or deleted, its reports go to the generic handlers. - After a sync, slot 251 is read separately, since it is outside the slots the sync reads. - A programmingUser user can only be the admin code user: addUser rejects the type, and updateUser cannot give it to another user or take it from the admin code user. Adds tests for the admin code, and for the generic delete and admin code behavior it relies on. Co-Authored-By: Claude Opus 5.5 --- .../zwave-lock/src/sub_drivers.lua | 1 + .../src/test/test_lock_admin_code.lua | 848 ++++++++++++++++++ .../src/test/test_zwave_responses.lua | 48 + .../zwave-lock/src/yale-lock/admin.lua | 29 + .../zwave-lock/src/yale-lock/can_handle.lua | 12 + .../zwave-lock/src/yale-lock/init.lua | 183 ++++ 6 files changed, 1121 insertions(+) create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua create mode 100644 drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua create mode 100644 drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/yale-lock/init.lua diff --git a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua index af5dc4d41f..dbb9bf779f 100644 --- a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua @@ -7,6 +7,7 @@ local sub_drivers = { lazy_load_if_possible("schlage-lock"), lazy_load_if_possible("samsung-lock"), lazy_load_if_possible("keywe-lock"), + lazy_load_if_possible("yale-lock"), lazy_load_if_possible("legacy-handlers"), } return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua new file mode 100644 index 0000000000..bf64725104 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua @@ -0,0 +1,848 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the Yale admin code, which is kept in user code slot 251: +-- it is set with a plain User Code Set and confirmed by the "master code changed" notification, +-- it is exposed as a programmingUser user whose index matches the slot, a delete is left to the lock and +-- reported as it confirms or refuses it, and delete-all keeps it. User identifier 0 (all users) is never +-- sent for a single credential. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +local access_control_event = Notification.event.access_control + +test.disable_startup_messages() + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local yale_mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x0129, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000, +}) + +-- Capability state is reset for every test: get_latest_state only returns what the test seeded or the driver +-- emitted since, and the tables otherwise come from the persistent store. +local mock_latest_state = {} +local function mock_state_key(capability_id, attribute_name) + return capability_id .. "|" .. attribute_name +end + +local function install_state_mocks(device) + mock_latest_state = {} + device.get_latest_state = function(_, _, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(capability_id, attribute_name)] + if value == nil then return default_value end + return value + end + device.emit_event = function(_, event) + mock_latest_state[mock_state_key(event.capability.ID, event.attribute.NAME)] = event.value.value + local message = device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end +end + +local function make_test_init(device) + return function() + install_state_mocks(device) + device:set_field("persistedUsers", nil, { persist = true }) + device:set_field("persistedCredentials", nil, { persist = true }) + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.mock_device.add_test_device(device) + end +end + +local function seed(device, users, credentials) + mock_latest_state[mock_state_key(capabilities.lockUsers.ID, capabilities.lockUsers.users.NAME)] = users + mock_latest_state[mock_state_key(capabilities.lockCredentials.ID, capabilities.lockCredentials.credentials.NAME)] = credentials + device:set_field("persistedUsers", users, { persist = true }) + device:set_field("persistedCredentials", credentials, { persist = true }) +end + +local function expect_users(device, users) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockUsers.users(users, { visibility = { displayed = false } }))) +end + +local function expect_credentials(device, credentials) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCredentials.credentials(credentials, { visibility = { displayed = false } }))) +end + +local function expect_credential_result(device, result) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCredentials.commandResult(result, { state_change = true, visibility = { displayed = false } }))) +end + +local function queue_admin_code_changed(device) + test.socket.zwave:__queue_receive({ device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, + }) }) +end + +local ADMIN_USER = { userIndex = 251, userName = "Master Code", userType = "programmingUser" } +local ADMIN_CREDENTIAL = { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } +local GUEST_USER = { userIndex = 1, userName = "Guest 1", userType = "guest" } +local GUEST_CREDENTIAL = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + +-- ============================================================================ +-- setting the admin code +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: Yale admin code is written to slot 251 and added when the lock reports it changed", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "programmingUser", "pin", "199111" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "199111", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + -- The lock reports the change with a notification, not a User Code Report. + queue_admin_code_changed(yale_mock_device) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "addCredential: a guest credential cannot be written to the Yale admin code slot", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) + expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "addCredential: an existing Yale admin code is not overwritten and the add is reported as occupied", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "programmingUser", "pin", "9999" } } }) + expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "occupied" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "addCredential: user 251 is an ordinary user on a lock that is not a Yale", + function() + -- the credential takes the next free slot, not the admin code slot + seed(mock_device, { { userIndex = 251, userName = "Guest 251", userType = "guest" } }, {}) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id)) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "addCredential: the Yale admin code can be added when the credentials table is full", + function() + local function guests() + local users, credentials = {}, {} + for i = 1, 20 do + table.insert(users, { userIndex = i, userName = "Guest " .. i, userType = "guest" }) + table.insert(credentials, { userIndex = i, credentialIndex = i, credentialType = "pin" }) + end + return users, credentials + end + seed(yale_mock_device, guests()) + + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "programmingUser", "pin", "199111" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "199111", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + queue_admin_code_changed(yale_mock_device) + local expected_users, expected_credentials = guests() + table.insert(expected_users, ADMIN_USER) + table.insert(expected_credentials, ADMIN_CREDENTIAL) + expect_users(yale_mock_device, expected_users) + expect_credentials(yale_mock_device, expected_credentials) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateCredential: Yale admin code is written to slot 251 and succeeds when the lock reports it changed", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 251, 251, "pin", "5678" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "5678", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + queue_admin_code_changed(yale_mock_device) + expect_credential_result(yale_mock_device, { commandName = "updateCredential", statusCode = "success" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateCredential: Yale admin code update succeeds when the lock confirms it with a new user code event", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 251, 251, "pin", "5678" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "5678", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(251), + }) }) + expect_credential_result(yale_mock_device, + { commandName = "updateCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateCredential: an admin code report during a guest update records the admin code and leaves the update alone", + function() + seed(yale_mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 1, 1, "pin", "5678" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 1, + user_code = "5678", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) }) + expect_users(yale_mock_device, { GUEST_USER, ADMIN_USER }) + expect_credentials(yale_mock_device, { GUEST_CREDENTIAL, ADMIN_CREDENTIAL }) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "5678", + }) }) + expect_credential_result(yale_mock_device, + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "the User Code v2 Master Code Report that follows an admin code change is ignored", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + queue_admin_code_changed(yale_mock_device) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "199111" }) }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +-- ============================================================================ +-- deleting the admin code: the lock decides, and the driver reports what it did +-- ============================================================================ + +local DELETE_CONFIRM_DELAY = constants.DELETE_CONFIRM_DELAY + +local function expect_delete_set(device, slot) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = slot, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(device.id)) +end + +local function queue_delete_credential(device, slot) + test.socket.capability:__queue_receive({ device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { slot, "pin" } } }) +end + +local function queue_delete_admin_credential(device) + queue_delete_credential(device, 251) +end + +local function read_back_after_delay(device, slot) + test.mock_time.advance_time(DELETE_CONFIRM_DELAY) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = slot }):build_test_tx(device.id)) + test.wait_for_events() +end + +test.register_coroutine_test( + "deleteCredential: the Yale admin code is deleted when the lock confirms it", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_admin_credential(yale_mock_device) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.SINGLE_USER_CODE_DELETED, + v1_alarm_level = 251, + }) }) + expect_credentials(yale_mock_device, {}) + expect_credential_result(yale_mock_device, + { commandName = "deleteCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + + -- the delete is already confirmed, so the slot is not read back + test.mock_time.advance_time(DELETE_CONFIRM_DELAY) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: an unconfirmed delete is read back and succeeds when the slot is empty", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_admin_credential(yale_mock_device) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + read_back_after_delay(yale_mock_device, 251) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + expect_credentials(yale_mock_device, {}) + expect_credential_result(yale_mock_device, + { commandName = "deleteCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: a delete the lock refuses is reported as a failure and the admin code is kept", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_admin_credential(yale_mock_device) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + read_back_after_delay(yale_mock_device, 251) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: the read-back timer of an earlier delete does not fire during a later delete of the same slot", + function() + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_credential(mock_device, 1) + expect_delete_set(mock_device, 1) + test.wait_for_events() + + -- the first delete is confirmed right away + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.SINGLE_USER_CODE_DELETED, + v1_alarm_level = 1, + }) }) + expect_credentials(mock_device, {}) + expect_credential_result(mock_device, + { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) + test.wait_for_events() + + -- the slot is set again at the lock and deleted again before the first timer would fire + test.socket.zwave:__queue_receive({ mock_device.id, UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) }) + expect_credentials(mock_device, + { { userIndex = 2, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 2" } }) + expect_users(mock_device, { GUEST_USER, { userIndex = 2, userName = "Guest 2", userType = "guest" } }) + test.wait_for_events() + queue_delete_credential(mock_device, 1) + expect_delete_set(mock_device, 1) + test.wait_for_events() + + -- only the second delete reads the slot back + read_back_after_delay(mock_device, 1) + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "deleteUser: a delete of the admin code user the lock refuses fails for both capabilities", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 251 } } }) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + read_back_after_delay(yale_mock_device, 251) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult({ commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } }))) + expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "all user codes deleted at the lock keeps the Yale admin code", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED, + }) }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { ADMIN_USER }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "an Alarm v1 all codes deleted report keeps the Yale admin code", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.zwave:__queue_receive({ yale_mock_device.id, Alarm:Report({ alarm_type = 32 }) }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { ADMIN_USER }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "all user codes deleted at a Yale keeps only the admin code, not a programmingUser in a user slot", + function() + local slot_user = { userIndex = 3, userName = "Slot 3", userType = "programmingUser" } + local slot_credential = { userIndex = 3, credentialIndex = 3, credentialType = "pin" } + seed(yale_mock_device, { slot_user, ADMIN_USER }, { slot_credential, ADMIN_CREDENTIAL }) + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED, + }) }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { ADMIN_USER }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "all user codes deleted at a lock that is not a Yale clears a programmingUser user", + function() + seed(mock_device, { { userIndex = 1, userName = "Owner", userType = "programmingUser" } }, { GUEST_CREDENTIAL }) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED, + }) }) + expect_credentials(mock_device, {}) + expect_users(mock_device, {}) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "addCredential: a programmingUser credential in a user slot counts toward the credential limit", + function() + local users, credentials = {}, {} + for i = 1, 20 do + table.insert(users, { userIndex = i, userName = "User " .. i, userType = i == 1 and "programmingUser" or "guest" }) + table.insert(credentials, { userIndex = i, credentialIndex = i, credentialType = "pin" }) + end + seed(mock_device, users, credentials) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "resourceExhausted" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +-- ============================================================================ +-- user identifier 0 means "all users" and is never used for a single credential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: credentialIndex 0 is rejected without sending user identifier 0", + function() + seed(mock_device, { { userIndex = 1, userName = "Master Code", userType = "guest" } }, + { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 1, 0, "pin", "5678" } } }) + expect_credential_result(mock_device, { commandName = "updateCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: credentialIndex 0 is rejected without sending user identifier 0", + function() + seed(mock_device, { { userIndex = 1, userName = "Master Code", userType = "guest" } }, + { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 0, "pin" } } }) + expect_credential_result(mock_device, { commandName = "deleteCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "deleteUser: a stale credential at index 0 is removed locally without sending user identifier 0", + function() + seed(mock_device, { GUEST_USER }, { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } } }) + expect_users(mock_device, {}) + expect_credentials(mock_device, {}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult({ commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } }))) + expect_credential_result(mock_device, + { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 0 }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "deleteAllCredentials: sends a single user identifier 0 clear and keeps the admin code in state", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} } }) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 0, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.mock_time.advance_time(4) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_credential_result(yale_mock_device, { commandName = "deleteAllCredentials", statusCode = "success" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +-- ============================================================================ +-- admin code confirmed or reported in other ways +-- ============================================================================ + +local function queue_new_user_code_added(device, slot) + test.socket.zwave:__queue_receive({ device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(slot), + }) }) +end + +test.register_coroutine_test( + "addCredential: Yale admin code is added when the lock confirms the write with a new user code event", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "programmingUser", "pin", "199111" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "199111", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "new user code event at the Yale admin slot with no command in progress adds the admin code", + function() + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "admin code report during an addCredential for another slot records the admin code and leaves the add alone", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "guest", "pin", "5555" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 1, + user_code = "5555", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) }) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + test.wait_for_events() + + -- the guest add still completes when the lock confirms it + queue_new_user_code_added(yale_mock_device, 1) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL, + { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }) + expect_users(yale_mock_device, { ADMIN_USER, { userIndex = 1, userName = "Guest 1", userType = "guest" } }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +local function start_sync(device, code_index) + 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, code_index, {}) +end + +test.register_coroutine_test( + "an admin code report during a sync records the admin code and the sync waits for the slot it asked for", + function() + start_sync(yale_mock_device, 2) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 3 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "a sync that reaches the admin code slot records the admin code and moves on", + function() + -- a lock with more user slots than the admin code slot + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:UsersNumberReport({ supported_users = 255 }) }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(255, { state_change = true, visibility = { displayed = false } }))) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(255, { state_change = true, visibility = { displayed = false } }))) + test.wait_for_events() + + start_sync(yale_mock_device, 251) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 252 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "a new user code event at the admin slot during a sync records the admin code, not a guest", + function() + start_sync(yale_mock_device, 2) + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +-- ============================================================================ +-- updating the admin code user +-- ============================================================================ + +local function expect_user_result(device, result) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockUsers.commandResult(result, { state_change = true, visibility = { displayed = false } }))) +end + +test.register_coroutine_test( + "addUser: a Yale admin code user cannot be added in a user slot", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Owner", "programmingUser" } } }) + expect_user_result(yale_mock_device, { commandName = "addUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "addUser: other users can still be added on a Yale", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Guest", "guest" } } }) + expect_users(yale_mock_device, { { userIndex = 1, userName = "Guest", userType = "guest" } }) + expect_user_result(yale_mock_device, { commandName = "addUser", statusCode = "success", userIndex = 1 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateUser: a guest cannot be made the admin code user", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 1, "Guest", "programmingUser" } } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateUser: the admin code user cannot be changed to another type", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 251, "Master Code", "guest" } } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateUser: the admin code user can be renamed and keeps its type", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 251, "Renamed Master", "programmingUser" } } }) + expect_users(yale_mock_device, { { userIndex = 251, userName = "Renamed Master", userType = "programmingUser" } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "success", userIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateUser: other locks are not restricted by the admin code user type", + function() + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 1, "Guest", "programmingUser" } } }) + expect_users(mock_device, { { userIndex = 1, userName = "Guest", userType = "programmingUser" } }) + expect_user_result(mock_device, { commandName = "updateUser", statusCode = "success", userIndex = 1 }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.run_registered_tests() 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 08e3f610dc..0758c1357e 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -34,6 +34,7 @@ local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +--- @type st.zwave.CommandClass.UserCode local zwave_lock_endpoints = { { @@ -51,6 +52,14 @@ local mock_device = test.mock_device.build_test_zwave_device({ zwave_endpoints = zwave_lock_endpoints, }) +local yale_mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x0129, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000, +}) + local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) @@ -245,6 +254,45 @@ test.register_coroutine_test( min_api_version = 15 }) +test.register_coroutine_test( + "user_code_report: sync on a Yale reads the admin code slot when it completes", + function() + yale_mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + yale_mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + yale_mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + yale_mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 20, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + -- a ZW2 module answers the slot read; the admin code is added at 251 + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.users({ { userIndex = 251, userName = "Master Code", userType = "programmingUser" } }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } }, + { visibility = { displayed = false } }))) + test.wait_for_events() + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(yale_mock_device) + end, + min_api_version = 15 + } +) + test.register_coroutine_test( "user_code_report: out-of-band ENABLED code creates user and credential entries", function() diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua new file mode 100644 index 0000000000..31336b050a --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua @@ -0,0 +1,29 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Yale locks keep their admin code in a user code slot that is not one of the lock's user slots. It is exposed +-- like any other credential, owned by a user with the same index and the admin code userType. +-- Everything that is specific to where Yale puts that slot lives here, so generic lock code only has to know +-- about the admin userType. + +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local yale_admin = { + MANUFACTURER_ID = 0x0129, + SLOT = 251, +} + +-- Returns the credentialIndex of the admin code slot, or nil if the device is not a Yale lock. +function yale_admin.credential_index(device) + if device.zwave_manufacturer_id == yale_admin.MANUFACTURER_ID then + return yale_admin.SLOT + end +end + +-- Read the admin code slot back from the lock, so the admin code is recorded even if it was set outside +-- SmartThings. The report is handled like any other user code report. +function yale_admin.read_admin_code(device) + device:send(UserCode:Get({ user_identifier = yale_admin.SLOT })) +end + +return yale_admin diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua new file mode 100644 index 0000000000..9e9849145a --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated and device.zwave_manufacturer_id == require("yale-lock.admin").MANUFACTURER_ID then + local subdriver = require("yale-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua new file mode 100644 index 0000000000..85d472cad7 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -0,0 +1,183 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local yale_admin = require "yale-lock.admin" +local zwave_handlers = require "lock_handlers.zwave_responses" +local cap_handlers = require "lock_handlers.capabilities" + +-- Yale locks keep their admin code in a user code slot that is not one of the lock's user slots. It is exposed +-- like any other credential, owned by a user that has the same index and the admin code userType. +local function is_admin_credential(device, credential_index) + return credential_index ~= nil and credential_index == yale_admin.credential_index(device) +end + +-- Add the admin code credential and the user that owns it. Both use the admin code slot as their index, +-- since the user index only has meaning inside the capability state. +local function add_admin_entries(device, credential_index, name) + name = name or consts.ADMIN.NAME + local status = tables.add_entry(device, "users", { + userIndex = credential_index, + userName = name, + userType = consts.ADMIN.USER_TYPE, + }, true) + if status ~= consts.COMMAND_RESULT.SUCCESS and status ~= consts.COMMAND_RESULT.OCCUPIED then return status end + return tables.add_entry(device, "credentials", { + userIndex = credential_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = name, + }, true) +end + +-- Record the admin code and, if an addCredential for the admin code is in progress, report the result. A +-- command in progress for any other credential is left alone. +local function admin_code_reported(device) + local admin_index = yale_admin.credential_index(device) + 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 adding_admin = command_in_progress == consts.LOCK_CREDENTIALS.ADD and + credential_args.credentialIndex == admin_index + local status = add_admin_entries(device, admin_index, adding_admin and credential_args.credentialName or nil) + if adding_admin then + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args.userIndex, + credentialIndex = credential_args.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, status, additional_info) + lock_utils.clear_busy_state(device) + end +end + +-- True if an update or delete of the admin code is waiting on the lock. The lock confirms or refuses it like +-- any other credential, so its reports go to the generic handlers. +local function is_admin_change_in_progress(device) + 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 {} + return command_in_progress ~= consts.LOCK_CREDENTIALS.ADD and + is_admin_credential(device, credential_args.credentialIndex) +end + +local function user_code_report(driver, device, cmd) + local credential_index = cmd.args.user_identifier + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local occupied = lock_utils.is_code_occupied(cmd) + + -- a report of the admin code slot records the admin code, unless it answers an update or delete of the + -- admin code (handled like any other credential). + if is_admin_credential(device, credential_index) and occupied and not is_admin_change_in_progress(device) then + admin_code_reported(device) + -- a sync only moves on when the slot it asked for is reported. The admin code is already recorded, so + -- the sync does not add it again. + if command_in_progress ~= consts.SYNC.CODES_FROM_LOCK or + credential_index ~= device:get_field(consts.SYNC.CODE_INDEX) then + return + end + end + + local was_syncing = command_in_progress == consts.SYNC.CODES_FROM_LOCK + zwave_handlers.user_code_report(driver, device, cmd) + if was_syncing and device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then + -- the sync is finished. The admin code slot is outside the lock's user code slots, so it is read separately. + yale_admin.read_admin_code(device) + end +end + +local function notification_report(driver, device, cmd) + if cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL then + local access_control_event = Notification.event.access_control + 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 {} + if cmd.args.event == access_control_event.NEW_USER_CODE_ADDED and + is_admin_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level))) and not is_admin_change_in_progress(device) then + -- the lock confirms a write to the admin code slot like any other user code (ZW2). An update of the + -- admin code is completed by the generic handler. + admin_code_reported(device) + return + elseif cmd.args.event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION and + command_in_progress == consts.LOCK_CREDENTIALS.ADD and + is_admin_credential(device, credential_args.credentialIndex) then + -- the "master code changed" event is sent instead of NEW_USER_CODE_ADDED by other modules (ZW3), so it + -- completes an addCredential in progress. + admin_code_reported(device) + return + end + end + zwave_handlers.notification_report(driver, device, cmd) +end + +local function add_credential(driver, device, command) + local cred_index = command.args.userIndex + if not is_admin_credential(device, cred_index) then + cap_handlers.add_credential(driver, device, command) + return + end + + -- only an admin code credential may be written to the admin code slot, and it is not limited by the lock's + -- user slots + if command.args.userType ~= consts.ADMIN.USER_TYPE then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + -- writing the slot would replace the admin code on the lock, which is what updateCredential is for + if tables.find_entry(device, "credentials", cred_index) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.OCCUPIED) + return + end + + cap_handlers.send_add_credential(device, cred_index, cred_index, command) +end + +local function add_user(driver, device, command) + -- the admin code user is added with the admin code credential, never on its own in a user slot + if command.args.userType == consts.ADMIN.USER_TYPE then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + cap_handlers.add_user(driver, device, command) +end + +local function update_user(driver, device, command) + -- the admin code user cannot be changed to another type, and no other user can be made an admin code user + if (command.args.userType == consts.ADMIN.USER_TYPE) ~= is_admin_credential(device, command.args.userIndex) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + cap_handlers.update_user(driver, device, command) +end + +local yale_lock = { + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report + }, + }, + capability_handlers = { + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = update_user + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential + } + }, + NAME = "Yale Lock", + can_handle = require("yale-lock.can_handle"), +} + +return yale_lock From 7204fe2dcac7d8ebcd70abc9b107233476ff85d8 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 00:53:07 -0600 Subject: [PATCH 04/16] Z-Wave Lock: Migrate the Yale admin code to a programmingUser user The lockCodes migration moves to legacy-handlers/migrate.lua and takes the lock's admin code slot, if it has one. That slot becomes a programmingUser user with the slot as its index, and the other codes get sequential user indexes that skip it. A new legacy yale-lock sub-driver migrates Yale locks with slot 251 as the admin code slot. lockCodes only holds the admin code if SmartThings set it and the lock confirmed it as a user code, so otherwise the slot is read from the lock after migration. Co-Authored-By: Claude Opus 5.5 --- .../zwave-lock/src/legacy-handlers/init.lua | 59 +------------- .../src/legacy-handlers/migrate.lua | 81 +++++++++++++++++++ .../src/legacy-handlers/sub_drivers.lua | 1 + .../legacy-handlers/yale-lock/can_handle.lua | 10 +++ .../src/legacy-handlers/yale-lock/init.lua | 34 ++++++++ .../test/test_lock_code_slga_migration.lua | 51 ++++++++++++ 6 files changed, 179 insertions(+), 57 deletions(-) create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 95a8283714..4b2aba8e84 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -7,6 +7,7 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local LockDefaults = require "st.zwave.defaults.lock" local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local migrate_codes = require "legacy-handlers.migrate" local init_handler = function(driver, device, event) local constants = require "st.zwave.constants" @@ -49,64 +50,8 @@ local function update_codes(driver, device, cmd) end end ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @param cmd table local function migrate(driver, device, cmd) - local LockCodesDefaults = require "st.zwave.defaults.lockCodes" - local table_utils = require "lock_utils.tables" - local get_lock_codes = LockCodesDefaults.get_lock_codes - local lock_users = {} - local lock_credentials = {} - local lock_codes = get_lock_codes(device) - local ordered_codes = {} - - -- Slots that are not valid credential indices (e.g. 0, which means "all users" in Z-Wave, or non-numeric) - -- are dropped rather than migrated. - local min_credential = table_utils.get_minimum(device, "credentials") - for code_slot in pairs(lock_codes) do - local credential_index = tonumber(code_slot) - if math.type(credential_index) == "integer" and credential_index >= min_credential then - table.insert(ordered_codes, {slot = code_slot, index = credential_index}) - else - device.log.warn(string.format("Dropping lock code slot %q during migration: not a valid credential index", tostring(code_slot))) - end - end - - table.sort(ordered_codes, function(a, b) return a.index < b.index end) - 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"}) - end - 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) - 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 - device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) - device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) - 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 = {} - }) + migrate_codes(driver, device) end local legacy_capabilities = { diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua new file mode 100644 index 0000000000..e2b76d2180 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua @@ -0,0 +1,81 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local consts = require "lock_utils.constants" +local table_utils = require "lock_utils.tables" + +-- Migrate the lock's lockCodes state to lockUsers and lockCredentials. This is shared by the lockCodes.migrate +-- handlers of the legacy sub-drivers, which pass the admin code slot for a lock that has one. +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param admin_index number|nil the credentialIndex of the lock's admin code slot, or nil if it has none +local function migrate_codes(driver, device, admin_index) + local get_lock_codes = LockCodesDefaults.get_lock_codes + local lock_users = {} + local lock_credentials = {} + local lock_codes = get_lock_codes(device) + local ordered_codes = {} + + -- Slots that are not valid credential indices (e.g. 0, which means "all users" in Z-Wave, or non-numeric) + -- are dropped rather than migrated. + local min_credential = table_utils.get_minimum(device, "credentials") + for code_slot in pairs(lock_codes) do + local credential_index = tonumber(code_slot) + if math.type(credential_index) == "integer" and credential_index >= min_credential then + table.insert(ordered_codes, {slot = code_slot, index = credential_index}) + else + device.log.warn(string.format("Dropping lock code slot %q during migration: not a valid credential index", tostring(code_slot))) + end + end + + table.sort(ordered_codes, function(a, b) return a.index < b.index end) + -- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the admin code's + -- user takes the admin code slot as its index. + local user_index = 0 + for _, code in ipairs(ordered_codes) do + local is_admin = admin_index ~= nil and code.index == admin_index + if not is_admin then + user_index = user_index + 1 + if admin_index ~= nil and user_index == admin_index then user_index = user_index + 1 end + end + local owner_index = is_admin and code.index or user_index + table.insert(lock_users, { + userIndex = owner_index, + userType = is_admin and consts.ADMIN.USER_TYPE or "guest", + userName = lock_codes[code.slot], + }) + table.insert(lock_credentials, {userIndex = owner_index, credentialIndex = code.index, credentialType = "pin"}) + end + device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) + + -- 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) + 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 + device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + 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 + +return migrate_codes diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua index 4c94ca5559..2f8c59664f 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua @@ -7,5 +7,6 @@ local sub_drivers = { lazy_load_if_possible("legacy-handlers.schlage-lock"), lazy_load_if_possible("legacy-handlers.samsung-lock"), lazy_load_if_possible("legacy-handlers.keywe-lock"), + lazy_load_if_possible("legacy-handlers.yale-lock"), } return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua new file mode 100644 index 0000000000..2913bb7d31 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local yale_admin = require "yale-lock.admin" + if device.zwave_manufacturer_id == yale_admin.MANUFACTURER_ID then + return true, require("legacy-handlers.yale-lock") + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua new file mode 100644 index 0000000000..ac9528c5c7 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua @@ -0,0 +1,34 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local migrate_codes = require "legacy-handlers.migrate" +local yale_admin = require "yale-lock.admin" + +-- Migrate a Yale lock, mapping its admin code slot to the admin code user. +local function yale_migrate(driver, device, cmd) + migrate_codes(driver, device, yale_admin.SLOT) + + -- lockCodes only holds the admin code if SmartThings set it and the lock confirmed it as a user code. A factory + -- admin code, or one a Yale ZW3 confirmed with the "master code changed" notification, is read back instead. + -- This is because the legacy handlers never read the admin code explicitly. Furthermore, if the lock was + -- running the newer ZW3 module, the legacy driver did not have the corresponding handling for saving + -- an admin code after a "Master Code Changed" response. + if LockCodesDefaults.get_lock_codes(device)[tostring(yale_admin.SLOT)] == nil then + yale_admin.read_admin_code(device) + end +end + +local legacy_yale_lock = { + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.migrate.NAME] = yale_migrate + }, + }, + NAME = "Legacy Yale Lock", + can_handle = require("legacy-handlers.yale-lock.can_handle"), +} + +return legacy_yale_lock 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..a4f4ea0e33 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 @@ -47,6 +47,16 @@ local schlage_mock_device = test.mock_device.build_test_zwave_device( } ) +local yale_mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x0129, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000 + } +) + local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} local function init_code_slot(slot_number, name, device) @@ -61,6 +71,7 @@ end local function test_init() test.mock_device.add_test_device(mock_device) test.mock_device.add_test_device(schlage_mock_device) + test.mock_device.add_test_device(yale_mock_device) end test.set_test_init_function(test_init) @@ -177,4 +188,44 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Migrate maps the Yale admin code slot 251 to a programmingUser user with userIndex 251", + function() + init_code_slot(1, "Zach", yale_mock_device) + init_code_slot(251, "Master", yale_mock_device) + test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Master", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Migrate adds the Yale admin code at slot 251 when the lock answers the slot read", + function() + init_code_slot(1, "Zach", yale_mock_device) + test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "199111" }) }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Master Code", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251, credentialName="Master Code"}}, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + test.run_registered_tests() \ No newline at end of file From 261bb53cc2a0567d04f38481ac5482fded8dba1d Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 15:30:26 -0600 Subject: [PATCH 05/16] Z-Wave Lock: Rename the admin/master code to programming code Use the same programming user naming as the Zigbee lock driver: - consts.ADMIN becomes consts.PROGRAMMING_USER, and its default name is now "Programming Code". - get_admin_credential, delete_all_non_admin_entries and read_admin_code become get_programming_credential, delete_all_non_programming_entries and read_programming_code. - yale-lock/admin.lua becomes yale-lock/programming_code.lua, and the Yale handlers, lockCodes migration, tests and comments follow. Co-Authored-By: Claude Sonnet 5.5 --- .../src/legacy-handlers/migrate.lua | 20 +- .../legacy-handlers/yale-lock/can_handle.lua | 4 +- .../src/legacy-handlers/yale-lock/init.lua | 18 +- .../src/lock_handlers/capabilities.lua | 8 +- .../src/lock_handlers/zwave_responses.lua | 8 +- .../zwave-lock/src/lock_utils/constants.lua | 6 +- .../zwave-lock/src/lock_utils/tables.lua | 18 +- .../zwave-lock/src/lock_utils/utils.lua | 12 +- .../test/test_lock_code_slga_migration.lua | 8 +- ...ode.lua => test_lock_programming_code.lua} | 174 +++++++++--------- .../src/test/test_zwave_responses.lua | 8 +- .../zwave-lock/src/yale-lock/admin.lua | 29 --- .../zwave-lock/src/yale-lock/can_handle.lua | 2 +- .../zwave-lock/src/yale-lock/init.lua | 84 ++++----- .../src/yale-lock/programming_code.lua | 29 +++ .../src/zwave-alarm-v1-lock/init.lua | 6 +- 16 files changed, 217 insertions(+), 217 deletions(-) rename drivers/SmartThings/zwave-lock/src/test/{test_lock_admin_code.lua => test_lock_programming_code.lua} (82%) delete mode 100644 drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua create mode 100644 drivers/SmartThings/zwave-lock/src/yale-lock/programming_code.lua diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua index e2b76d2180..25cce9af40 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua @@ -8,11 +8,11 @@ local consts = require "lock_utils.constants" local table_utils = require "lock_utils.tables" -- Migrate the lock's lockCodes state to lockUsers and lockCredentials. This is shared by the lockCodes.migrate --- handlers of the legacy sub-drivers, which pass the admin code slot for a lock that has one. +-- handlers of the legacy sub-drivers, which pass the programming code slot for a lock that has one. --- @param driver st.zwave.Driver --- @param device st.zwave.Device ---- @param admin_index number|nil the credentialIndex of the lock's admin code slot, or nil if it has none -local function migrate_codes(driver, device, admin_index) +--- @param programming_code_slot number|nil the credentialIndex of the lock's programming code slot, or nil if it has none +local function migrate_codes(driver, device, programming_code_slot) local get_lock_codes = LockCodesDefaults.get_lock_codes local lock_users = {} local lock_credentials = {} @@ -32,19 +32,19 @@ local function migrate_codes(driver, device, admin_index) end table.sort(ordered_codes, function(a, b) return a.index < b.index end) - -- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the admin code's - -- user takes the admin code slot as its index. + -- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the programming code's + -- user takes the programming code slot as its index. local user_index = 0 for _, code in ipairs(ordered_codes) do - local is_admin = admin_index ~= nil and code.index == admin_index - if not is_admin then + local is_programming = programming_code_slot ~= nil and code.index == programming_code_slot + if not is_programming then user_index = user_index + 1 - if admin_index ~= nil and user_index == admin_index then user_index = user_index + 1 end + if programming_code_slot ~= nil and user_index == programming_code_slot then user_index = user_index + 1 end end - local owner_index = is_admin and code.index or user_index + local owner_index = is_programming and code.index or user_index table.insert(lock_users, { userIndex = owner_index, - userType = is_admin and consts.ADMIN.USER_TYPE or "guest", + userType = is_programming and consts.PROGRAMMING_USER.USER_TYPE or "guest", userName = lock_codes[code.slot], }) table.insert(lock_credentials, {userIndex = owner_index, credentialIndex = code.index, credentialType = "pin"}) diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua index 2913bb7d31..c4b6b5e207 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua @@ -2,8 +2,8 @@ -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device, ...) - local yale_admin = require "yale-lock.admin" - if device.zwave_manufacturer_id == yale_admin.MANUFACTURER_ID then + local yale_programming_code = require "yale-lock.programming_code" + if device.zwave_manufacturer_id == yale_programming_code.MANUFACTURER_ID then return true, require("legacy-handlers.yale-lock") end return false diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua index ac9528c5c7..515010a336 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua @@ -5,19 +5,19 @@ local capabilities = require "st.capabilities" local LockCodesDefaults = require "st.zwave.defaults.lockCodes" local migrate_codes = require "legacy-handlers.migrate" -local yale_admin = require "yale-lock.admin" +local yale_programming_code = require "yale-lock.programming_code" --- Migrate a Yale lock, mapping its admin code slot to the admin code user. +-- Migrate a Yale lock, mapping its programming code slot to the programming code user. local function yale_migrate(driver, device, cmd) - migrate_codes(driver, device, yale_admin.SLOT) + migrate_codes(driver, device, yale_programming_code.SLOT) - -- lockCodes only holds the admin code if SmartThings set it and the lock confirmed it as a user code. A factory - -- admin code, or one a Yale ZW3 confirmed with the "master code changed" notification, is read back instead. - -- This is because the legacy handlers never read the admin code explicitly. Furthermore, if the lock was + -- lockCodes only holds the programming code if SmartThings set it and the lock confirmed it as a user code. A factory + -- programming code, or one a Yale ZW3 confirmed with the "programming code changed" notification, is read back instead. + -- This is because the legacy handlers never read the programming code explicitly. Furthermore, if the lock was -- running the newer ZW3 module, the legacy driver did not have the corresponding handling for saving - -- an admin code after a "Master Code Changed" response. - if LockCodesDefaults.get_lock_codes(device)[tostring(yale_admin.SLOT)] == nil then - yale_admin.read_admin_code(device) + -- a programming code after a "Programming Code Changed" response. + if LockCodesDefaults.get_lock_codes(device)[tostring(yale_programming_code.SLOT)] == nil then + yale_programming_code.read_programming_code(device) end end diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 9f25c5e999..d761be3ad0 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -204,7 +204,7 @@ local function send_credential_delete(device, credential_index) user_identifier = credential_index, user_id_status = UserCode.user_id_status.AVAILABLE })) - -- A lock that refuses the delete (e.g. a Yale admin code) ignores the Set and sends nothing, so read the + -- A lock that refuses the delete (e.g. a Yale programming code) ignores the Set and sends nothing, so read the -- slot back if the delete has not completed. The report tells us whether the code is still there. A timer -- left from an earlier delete is cancelled so it cannot read back the slot during this one. local previous_timer = device:get_field(consts.DELETE_CONFIRM_TIMER) @@ -252,7 +252,7 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) local send_clear_commands = function() -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with an - -- admin code (Yale) keeps it, so the admin entries are kept in state below. + -- programming code (Yale) keeps it, so the programming entries are kept in state below. device:send(UserCode:Set({ user_identifier = 0, user_id_status = UserCode.user_id_status.AVAILABLE @@ -260,10 +260,10 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) -- Delayed cleanup: ensure all tables are cleared after the lock has processed the command device.thread:call_with_delay(4, function() if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then - lock_utils.delete_all_non_admin_entries(device, "users") + lock_utils.delete_all_non_programming_entries(device, "users") lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) end - lock_utils.delete_all_non_admin_entries(device, "credentials") + lock_utils.delete_all_non_programming_entries(device, "credentials") lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) lock_utils.clear_busy_state(device) 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 2d55c185d0..428c0d8ae4 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -89,9 +89,9 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd) local access_control_event = Notification.event.access_control if event == access_control_event.ALL_USER_CODES_DELETED then - -- Deleting all user codes does not affect the admin code. - lock_utils.delete_all_non_admin_entries(device, "credentials") - lock_utils.delete_all_non_admin_entries(device, "users") + -- Deleting all user codes does not affect the programming code. + lock_utils.delete_all_non_programming_entries(device, "credentials") + lock_utils.delete_all_non_programming_entries(device, "users") elseif event == access_control_event.SINGLE_USER_CODE_DELETED then lock_utils.delete_credential_report_helper(device, credential_index) @@ -100,7 +100,7 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd) lock_utils.set_credential_report_helper(device, credential_index) elseif event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then - -- aka "master code" changed + -- aka "programming code" changed if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.SUCCESS) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index 7d5f06da7f..64db16c881 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -19,10 +19,10 @@ 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 the first index) } -lock_constants.ADMIN = { - -- The lockUsers userType for the admin code. +lock_constants.PROGRAMMING_USER = { + -- The lockUsers userType for the programming code. USER_TYPE = "programmingUser", - NAME = "Master Code", + DEFAULT_NAME = "Programming Code", } lock_constants.COMMAND_RESULT = { diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua index 413f8e6122..f59c465bfc 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -157,17 +157,17 @@ function table_utils.get_max_entries(device, table_name) return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing end --- Returns the admin code credential, or nil if there is none. A lock may keep an admin code in a credential --- outside its user slots, owned by a user with the admin userType (consts.ADMIN.USER_TYPE); where that --- credential lives is manufacturer specific and handled by the sub-driver for the lock. A user given the admin +-- Returns the programming code credential, or nil if there is none. A lock may keep a programming code in a credential +-- outside its user slots, owned by a user with the programmingUser userType (consts.PROGRAMMING_USER.USER_TYPE); where that +-- credential lives is manufacturer specific and handled by the sub-driver for the lock. A user given the programmingUser -- userType in one of the lock's user slots is an ordinary user. -function table_utils.get_admin_credential(device) +function table_utils.get_programming_credential(device) local user_slots = table_utils.get_max_entries(device, "credentials") local users = table_utils.get_state(device, "users") or {} for _, credential in ipairs(table_utils.get_state(device, "credentials") or {}) do if credential.credentialIndex > user_slots then for _, user in ipairs(users) do - if user.userIndex == credential.userIndex and user.userType == consts.ADMIN.USER_TYPE then + if user.userIndex == credential.userIndex and user.userType == consts.PROGRAMMING_USER.USER_TYPE then return credential end end @@ -175,15 +175,15 @@ function table_utils.get_admin_credential(device) end end --- Return true if the named table has reached its max_entries limit. The admin code credential, and the +-- Return true if the named table has reached its max_entries limit. The programming code credential, and the -- user that owns it, are not one of the lock's user slots, so they do not count toward the limit. function table_utils.is_full(device, table_name) local def = resolve_table_def(device, table_name) if not def then return true end - local admin = table_utils.get_admin_credential(device) + local programming = table_utils.get_programming_credential(device) local count = 0 for _, entry in ipairs(table_utils.get_state(device, def) or {}) do - if not (admin and entry.userIndex == admin.userIndex) then count = count + 1 end + if not (programming and entry.userIndex == programming.userIndex) then count = count + 1 end end return count >= table_utils.get_max_entries(device, table_name) end @@ -191,7 +191,7 @@ end -- Add an entry to a named table. The entry must satisfy all required_keys for -- that table. An entry whose match_key value already exists in the -- table is skipped to prevent duplicates. If the table has a max_entries limit, --- entries that exceed the limit are not added, unless exempt_from_limit is true (used for the admin code). +-- entries that exceed the limit are not added, unless exempt_from_limit is true (used for the programming code). function table_utils.add_entry(device, table_name, entry, exempt_from_limit) device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) local def = resolve_table_def(device, table_name) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 0abdb62078..5b401cb756 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -133,16 +133,16 @@ function lock_utils.continue_code_sync(device, occupied, available) end --- [[ ADMIN CODE MANAGEMENT ]] -- +-- [[ PROGRAMMING CODE MANAGEMENT ]] -- -- --- The admin code credential is found with tables.get_admin_credential. +-- The programming code credential is found with tables.get_programming_credential. --- Clear every entry of the "users" or "credentials" table except the admin code credential and its user. -function lock_utils.delete_all_non_admin_entries(device, table_name) - local admin = tables.get_admin_credential(device) +-- Clear every entry of the "users" or "credentials" table except the programming code credential and its user. +function lock_utils.delete_all_non_programming_entries(device, table_name) + local programming = tables.get_programming_credential(device) local match_key = table_name == "users" and "userIndex" or "credentialIndex" tables.delete_all_entries(device, table_name, function(entry) - return admin ~= nil and entry[match_key] == admin[match_key] + return programming ~= nil and entry[match_key] == programming[match_key] end) end 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 a4f4ea0e33..508cbc6d54 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 @@ -189,7 +189,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Migrate maps the Yale admin code slot 251 to a programmingUser user with userIndex 251", + "Migrate maps the Yale programming code slot 251 to a programmingUser user with userIndex 251", function() init_code_slot(1, "Zach", yale_mock_device) init_code_slot(251, "Master", yale_mock_device) @@ -206,7 +206,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Migrate adds the Yale admin code at slot 251 when the lock answers the slot read", + "Migrate adds the Yale programming code at slot 251 when the lock answers the slot read", function() init_code_slot(1, "Zach", yale_mock_device) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) @@ -222,8 +222,8 @@ test.register_coroutine_test( test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ user_identifier = 251, user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "199111" }) }) - test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Master Code", userType="programmingUser"}}, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251, credentialName="Master Code"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Programming Code", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251, credentialName="Programming Code"}}, { visibility = { displayed = false } }))) test.wait_for_events() end ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua similarity index 82% rename from drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua rename to drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index bf64725104..2089f86fe4 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -1,8 +1,8 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -- --- Integration tests for the Yale admin code, which is kept in user code slot 251: --- it is set with a plain User Code Set and confirmed by the "master code changed" notification, +-- Integration tests for the Yale programming code, which is kept in user code slot 251: +-- it is set with a plain User Code Set and confirmed by the "programming code changed" notification, -- it is exposed as a programmingUser user whose index matches the slot, a delete is left to the lock and -- reported as it confirms or refuses it, and delete-all keeps it. User identifier 0 (all users) is never -- sent for a single credential. @@ -98,24 +98,24 @@ local function expect_credential_result(device, result) capabilities.lockCredentials.commandResult(result, { state_change = true, visibility = { displayed = false } }))) end -local function queue_admin_code_changed(device) +local function queue_programming_code_changed(device) test.socket.zwave:__queue_receive({ device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, }) }) end -local ADMIN_USER = { userIndex = 251, userName = "Master Code", userType = "programmingUser" } -local ADMIN_CREDENTIAL = { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } +local PROGRAMMING_USER_ENTRY = { userIndex = 251, userName = "Programming Code", userType = "programmingUser" } +local PROGRAMMING_CREDENTIAL = { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Programming Code" } local GUEST_USER = { userIndex = 1, userName = "Guest 1", userType = "guest" } local GUEST_CREDENTIAL = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } -- ============================================================================ --- setting the admin code +-- setting the programming code -- ============================================================================ test.register_coroutine_test( - "addCredential: Yale admin code is written to slot 251 and added when the lock reports it changed", + "addCredential: Yale programming code is written to slot 251 and added when the lock reports it changed", function() test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", @@ -128,9 +128,9 @@ test.register_coroutine_test( test.wait_for_events() -- The lock reports the change with a notification, not a User Code Report. - queue_admin_code_changed(yale_mock_device) - expect_users(yale_mock_device, { ADMIN_USER }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + queue_programming_code_changed(yale_mock_device) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) test.wait_for_events() @@ -139,7 +139,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "addCredential: a guest credential cannot be written to the Yale admin code slot", + "addCredential: a guest credential cannot be written to the Yale programming code slot", function() test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) @@ -150,9 +150,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "addCredential: an existing Yale admin code is not overwritten and the add is reported as occupied", + "addCredential: an existing Yale programming code is not overwritten and the add is reported as occupied", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "programmingUser", "pin", "9999" } } }) @@ -165,7 +165,7 @@ test.register_coroutine_test( test.register_coroutine_test( "addCredential: user 251 is an ordinary user on a lock that is not a Yale", function() - -- the credential takes the next free slot, not the admin code slot + -- the credential takes the next free slot, not the programming code slot seed(mock_device, { { userIndex = 251, userName = "Guest 251", userType = "guest" } }, {}) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) @@ -180,7 +180,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "addCredential: the Yale admin code can be added when the credentials table is full", + "addCredential: the Yale programming code can be added when the credentials table is full", function() local function guests() local users, credentials = {}, {} @@ -202,10 +202,10 @@ test.register_coroutine_test( }):build_test_tx(yale_mock_device.id)) test.wait_for_events() - queue_admin_code_changed(yale_mock_device) + queue_programming_code_changed(yale_mock_device) local expected_users, expected_credentials = guests() - table.insert(expected_users, ADMIN_USER) - table.insert(expected_credentials, ADMIN_CREDENTIAL) + table.insert(expected_users, PROGRAMMING_USER_ENTRY) + table.insert(expected_credentials, PROGRAMMING_CREDENTIAL) expect_users(yale_mock_device, expected_users) expect_credentials(yale_mock_device, expected_credentials) expect_credential_result(yale_mock_device, @@ -216,9 +216,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateCredential: Yale admin code is written to slot 251 and succeeds when the lock reports it changed", + "updateCredential: Yale programming code is written to slot 251 and succeeds when the lock reports it changed", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 251, 251, "pin", "5678" } } }) test.socket.zwave:__expect_send(UserCode:Set({ @@ -228,7 +228,7 @@ test.register_coroutine_test( }):build_test_tx(yale_mock_device.id)) test.wait_for_events() - queue_admin_code_changed(yale_mock_device) + queue_programming_code_changed(yale_mock_device) expect_credential_result(yale_mock_device, { commandName = "updateCredential", statusCode = "success" }) test.wait_for_events() end, @@ -236,9 +236,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateCredential: Yale admin code update succeeds when the lock confirms it with a new user code event", + "updateCredential: Yale programming code update succeeds when the lock confirms it with a new user code event", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 251, 251, "pin", "5678" } } }) test.socket.zwave:__expect_send(UserCode:Set({ @@ -261,7 +261,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateCredential: an admin code report during a guest update records the admin code and leaves the update alone", + "updateCredential: a programming code report during a guest update records the programming code and leaves the update alone", function() seed(yale_mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, @@ -278,8 +278,8 @@ test.register_coroutine_test( user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "1234", }) }) - expect_users(yale_mock_device, { GUEST_USER, ADMIN_USER }) - expect_credentials(yale_mock_device, { GUEST_CREDENTIAL, ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { GUEST_USER, PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { GUEST_CREDENTIAL, PROGRAMMING_CREDENTIAL }) test.wait_for_events() test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ @@ -295,10 +295,10 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "the User Code v2 Master Code Report that follows an admin code change is ignored", + "the User Code v2 Master Code Report that follows a programming code change is ignored", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) - queue_admin_code_changed(yale_mock_device) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) + queue_programming_code_changed(yale_mock_device) test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "199111" }) }) test.wait_for_events() end, @@ -306,7 +306,7 @@ test.register_coroutine_test( ) -- ============================================================================ --- deleting the admin code: the lock decides, and the driver reports what it did +-- deleting the programming code: the lock decides, and the driver reports what it did -- ============================================================================ local DELETE_CONFIRM_DELAY = constants.DELETE_CONFIRM_DELAY @@ -323,7 +323,7 @@ local function queue_delete_credential(device, slot) { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { slot, "pin" } } }) end -local function queue_delete_admin_credential(device) +local function queue_delete_programming_credential(device) queue_delete_credential(device, 251) end @@ -334,11 +334,11 @@ local function read_back_after_delay(device, slot) end test.register_coroutine_test( - "deleteCredential: the Yale admin code is deleted when the lock confirms it", + "deleteCredential: the Yale programming code is deleted when the lock confirms it", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_admin_credential(yale_mock_device) + queue_delete_programming_credential(yale_mock_device) expect_delete_set(yale_mock_device, 251) test.wait_for_events() @@ -362,9 +362,9 @@ test.register_coroutine_test( test.register_coroutine_test( "deleteCredential: an unconfirmed delete is read back and succeeds when the slot is empty", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_admin_credential(yale_mock_device) + queue_delete_programming_credential(yale_mock_device) expect_delete_set(yale_mock_device, 251) test.wait_for_events() read_back_after_delay(yale_mock_device, 251) @@ -382,11 +382,11 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteCredential: a delete the lock refuses is reported as a failure and the admin code is kept", + "deleteCredential: a delete the lock refuses is reported as a failure and the programming code is kept", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_admin_credential(yale_mock_device) + queue_delete_programming_credential(yale_mock_device) expect_delete_set(yale_mock_device, 251) test.wait_for_events() read_back_after_delay(yale_mock_device, 251) @@ -444,9 +444,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteUser: a delete of the admin code user the lock refuses fails for both capabilities", + "deleteUser: a delete of the programming code user the lock refuses fails for both capabilities", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 251 } } }) @@ -469,44 +469,44 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "all user codes deleted at the lock keeps the Yale admin code", + "all user codes deleted at the lock keeps the Yale programming code", function() - seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.ALL_USER_CODES_DELETED, }) }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) - expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) test.wait_for_events() end, { test_init = make_test_init(yale_mock_device) } ) test.register_coroutine_test( - "an Alarm v1 all codes deleted report keeps the Yale admin code", + "an Alarm v1 all codes deleted report keeps the Yale programming code", function() - seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) test.socket.zwave:__queue_receive({ yale_mock_device.id, Alarm:Report({ alarm_type = 32 }) }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) - expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) test.wait_for_events() end, { test_init = make_test_init(yale_mock_device) } ) test.register_coroutine_test( - "all user codes deleted at a Yale keeps only the admin code, not a programmingUser in a user slot", + "all user codes deleted at a Yale keeps only the programming code, not a programmingUser in a user slot", function() local slot_user = { userIndex = 3, userName = "Slot 3", userType = "programmingUser" } local slot_credential = { userIndex = 3, credentialIndex = 3, credentialType = "pin" } - seed(yale_mock_device, { slot_user, ADMIN_USER }, { slot_credential, ADMIN_CREDENTIAL }) + seed(yale_mock_device, { slot_user, PROGRAMMING_USER_ENTRY }, { slot_credential, PROGRAMMING_CREDENTIAL }) test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.ALL_USER_CODES_DELETED, }) }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) - expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) test.wait_for_events() end, { test_init = make_test_init(yale_mock_device) } @@ -593,9 +593,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteAllCredentials: sends a single user identifier 0 clear and keeps the admin code in state", + "deleteAllCredentials: sends a single user identifier 0 clear and keeps the programming code in state", function() - seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} } }) test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") @@ -606,7 +606,7 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(4) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) expect_credential_result(yale_mock_device, { commandName = "deleteAllCredentials", statusCode = "success" }) test.wait_for_events() end, @@ -614,7 +614,7 @@ test.register_coroutine_test( ) -- ============================================================================ --- admin code confirmed or reported in other ways +-- programming code confirmed or reported in other ways -- ============================================================================ local function queue_new_user_code_added(device, slot) @@ -626,7 +626,7 @@ local function queue_new_user_code_added(device, slot) end test.register_coroutine_test( - "addCredential: Yale admin code is added when the lock confirms the write with a new user code event", + "addCredential: Yale programming code is added when the lock confirms the write with a new user code event", function() test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", @@ -639,8 +639,8 @@ test.register_coroutine_test( test.wait_for_events() queue_new_user_code_added(yale_mock_device, 251) - expect_users(yale_mock_device, { ADMIN_USER }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) test.wait_for_events() @@ -649,18 +649,18 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "new user code event at the Yale admin slot with no command in progress adds the admin code", + "new user code event at the Yale programming code slot with no command in progress adds the programming code", function() queue_new_user_code_added(yale_mock_device, 251) - expect_users(yale_mock_device, { ADMIN_USER }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) test.wait_for_events() end, { test_init = make_test_init(yale_mock_device) } ) test.register_coroutine_test( - "admin code report during an addCredential for another slot records the admin code and leaves the add alone", + "programming code report during an addCredential for another slot records the programming code and leaves the add alone", function() test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", @@ -677,15 +677,15 @@ test.register_coroutine_test( user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "1234", }) }) - expect_users(yale_mock_device, { ADMIN_USER }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) test.wait_for_events() -- the guest add still completes when the lock confirms it queue_new_user_code_added(yale_mock_device, 1) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL, + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL, { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }) - expect_users(yale_mock_device, { ADMIN_USER, { userIndex = 1, userName = "Guest 1", userType = "guest" } }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY, { userIndex = 1, userName = "Guest 1", userType = "guest" } }) expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) test.wait_for_events() @@ -700,7 +700,7 @@ local function start_sync(device, code_index) end test.register_coroutine_test( - "an admin code report during a sync records the admin code and the sync waits for the slot it asked for", + "a programming code report during a sync records the programming code and the sync waits for the slot it asked for", function() start_sync(yale_mock_device, 2) test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ @@ -708,8 +708,8 @@ test.register_coroutine_test( user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "199111", }) }) - expect_users(yale_mock_device, { ADMIN_USER }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) test.wait_for_events() test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ @@ -723,9 +723,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "a sync that reaches the admin code slot records the admin code and moves on", + "a sync that reaches the programming code slot records the programming code and moves on", function() - -- a lock with more user slots than the admin code slot + -- a lock with more user slots than the programming code slot test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:UsersNumberReport({ supported_users = 255 }) }) test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(255, { state_change = true, visibility = { displayed = false } }))) @@ -739,8 +739,8 @@ test.register_coroutine_test( user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "199111", }) }) - expect_users(yale_mock_device, { ADMIN_USER }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 252 }):build_test_tx(yale_mock_device.id)) test.wait_for_events() end, @@ -748,19 +748,19 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "a new user code event at the admin slot during a sync records the admin code, not a guest", + "a new user code event at the programming code slot during a sync records the programming code, not a guest", function() start_sync(yale_mock_device, 2) queue_new_user_code_added(yale_mock_device, 251) - expect_users(yale_mock_device, { ADMIN_USER }) - expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) test.wait_for_events() end, { test_init = make_test_init(yale_mock_device) } ) -- ============================================================================ --- updating the admin code user +-- updating the programming code user -- ============================================================================ local function expect_user_result(device, result) @@ -769,7 +769,7 @@ local function expect_user_result(device, result) end test.register_coroutine_test( - "addUser: a Yale admin code user cannot be added in a user slot", + "addUser: a Yale programming code user cannot be added in a user slot", function() test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Owner", "programmingUser" } } }) @@ -792,9 +792,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateUser: a guest cannot be made the admin code user", + "updateUser: a guest cannot be made the programming code user", function() - seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Guest", "programmingUser" } } }) @@ -805,9 +805,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateUser: the admin code user cannot be changed to another type", + "updateUser: the programming code user cannot be changed to another type", function() - seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 251, "Master Code", "guest" } } }) @@ -818,9 +818,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateUser: the admin code user can be renamed and keeps its type", + "updateUser: the programming code user can be renamed and keeps its type", function() - seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 251, "Renamed Master", "programmingUser" } } }) @@ -832,7 +832,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateUser: other locks are not restricted by the admin code user type", + "updateUser: other locks are not restricted by the programming code user type", function() seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ mock_device.id, 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 0758c1357e..ad735f7927 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -255,7 +255,7 @@ test.register_coroutine_test( }) test.register_coroutine_test( - "user_code_report: sync on a Yale reads the admin code slot when it completes", + "user_code_report: sync on a Yale reads the programming code slot when it completes", function() yale_mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) yale_mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) @@ -269,18 +269,18 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) test.wait_for_events() - -- a ZW2 module answers the slot read; the admin code is added at 251 + -- a ZW2 module answers the slot read; the programming code is added at 251 test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ user_identifier = 251, user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "199111", }) }) test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", - capabilities.lockUsers.users({ { userIndex = 251, userName = "Master Code", userType = "programmingUser" } }, + capabilities.lockUsers.users({ { userIndex = 251, userName = "Programming Code", userType = "programmingUser" } }, { visibility = { displayed = false } }))) test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( - { { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } }, + { { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Programming Code" } }, { visibility = { displayed = false } }))) test.wait_for_events() end, diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua deleted file mode 100644 index 31336b050a..0000000000 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/admin.lua +++ /dev/null @@ -1,29 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Yale locks keep their admin code in a user code slot that is not one of the lock's user slots. It is exposed --- like any other credential, owned by a user with the same index and the admin code userType. --- Everything that is specific to where Yale puts that slot lives here, so generic lock code only has to know --- about the admin userType. - -local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) - -local yale_admin = { - MANUFACTURER_ID = 0x0129, - SLOT = 251, -} - --- Returns the credentialIndex of the admin code slot, or nil if the device is not a Yale lock. -function yale_admin.credential_index(device) - if device.zwave_manufacturer_id == yale_admin.MANUFACTURER_ID then - return yale_admin.SLOT - end -end - --- Read the admin code slot back from the lock, so the admin code is recorded even if it was set outside --- SmartThings. The report is handled like any other user code report. -function yale_admin.read_admin_code(device) - device:send(UserCode:Get({ user_identifier = yale_admin.SLOT })) -end - -return yale_admin diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua index 9e9849145a..d658ff3f27 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua @@ -4,7 +4,7 @@ return function(opts, driver, device, cmd) local consts = require("lock_utils.constants") local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) - if slga_migrated and device.zwave_manufacturer_id == require("yale-lock.admin").MANUFACTURER_ID then + if slga_migrated and device.zwave_manufacturer_id == require("yale-lock.programming_code").MANUFACTURER_ID then local subdriver = require("yale-lock") return true, subdriver end diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua index 85d472cad7..4b215930b0 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -9,24 +9,24 @@ local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local consts = require "lock_utils.constants" local lock_utils = require "lock_utils.utils" local tables = require "lock_utils.tables" -local yale_admin = require "yale-lock.admin" +local yale_programming_code = require "yale-lock.programming_code" local zwave_handlers = require "lock_handlers.zwave_responses" local cap_handlers = require "lock_handlers.capabilities" --- Yale locks keep their admin code in a user code slot that is not one of the lock's user slots. It is exposed --- like any other credential, owned by a user that has the same index and the admin code userType. -local function is_admin_credential(device, credential_index) - return credential_index ~= nil and credential_index == yale_admin.credential_index(device) +-- Yale locks keep their programming code in a user code slot that is not one of the lock's user slots. It is exposed +-- like any other credential, owned by a user that has the same index and the programming code userType. +local function is_programming_credential(device, credential_index) + return credential_index ~= nil and credential_index == yale_programming_code.credential_index(device) end --- Add the admin code credential and the user that owns it. Both use the admin code slot as their index, +-- Add the programming code credential and the user that owns it. Both use the programming code slot as their index, -- since the user index only has meaning inside the capability state. -local function add_admin_entries(device, credential_index, name) - name = name or consts.ADMIN.NAME +local function add_programming_entries(device, credential_index, name) + name = name or consts.PROGRAMMING_USER.DEFAULT_NAME local status = tables.add_entry(device, "users", { userIndex = credential_index, userName = name, - userType = consts.ADMIN.USER_TYPE, + userType = consts.PROGRAMMING_USER.USER_TYPE, }, true) if status ~= consts.COMMAND_RESULT.SUCCESS and status ~= consts.COMMAND_RESULT.OCCUPIED then return status end return tables.add_entry(device, "credentials", { @@ -37,16 +37,16 @@ local function add_admin_entries(device, credential_index, name) }, true) end --- Record the admin code and, if an addCredential for the admin code is in progress, report the result. A +-- Record the programming code and, if an addCredential for the programming code is in progress, report the result. A -- command in progress for any other credential is left alone. -local function admin_code_reported(device) - local admin_index = yale_admin.credential_index(device) +local function programming_code_reported(device) + local programming_code_slot = yale_programming_code.credential_index(device) 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 adding_admin = command_in_progress == consts.LOCK_CREDENTIALS.ADD and - credential_args.credentialIndex == admin_index - local status = add_admin_entries(device, admin_index, adding_admin and credential_args.credentialName or nil) - if adding_admin then + local adding_programming = command_in_progress == consts.LOCK_CREDENTIALS.ADD and + credential_args.credentialIndex == programming_code_slot + local status = add_programming_entries(device, programming_code_slot, adding_programming and credential_args.credentialName or nil) + if adding_programming then local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args.userIndex, credentialIndex = credential_args.credentialIndex, @@ -56,13 +56,13 @@ local function admin_code_reported(device) end end --- True if an update or delete of the admin code is waiting on the lock. The lock confirms or refuses it like +-- True if an update or delete of the programming code is waiting on the lock. The lock confirms or refuses it like -- any other credential, so its reports go to the generic handlers. -local function is_admin_change_in_progress(device) +local function is_programming_change_in_progress(device) 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 {} return command_in_progress ~= consts.LOCK_CREDENTIALS.ADD and - is_admin_credential(device, credential_args.credentialIndex) + is_programming_credential(device, credential_args.credentialIndex) end local function user_code_report(driver, device, cmd) @@ -70,11 +70,11 @@ local function user_code_report(driver, device, cmd) local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local occupied = lock_utils.is_code_occupied(cmd) - -- a report of the admin code slot records the admin code, unless it answers an update or delete of the - -- admin code (handled like any other credential). - if is_admin_credential(device, credential_index) and occupied and not is_admin_change_in_progress(device) then - admin_code_reported(device) - -- a sync only moves on when the slot it asked for is reported. The admin code is already recorded, so + -- a report of the programming code slot records the programming code, unless it answers an update or delete of the + -- programming code (handled like any other credential). + if is_programming_credential(device, credential_index) and occupied and not is_programming_change_in_progress(device) then + programming_code_reported(device) + -- a sync only moves on when the slot it asked for is reported. The programming code is already recorded, so -- the sync does not add it again. if command_in_progress ~= consts.SYNC.CODES_FROM_LOCK or credential_index ~= device:get_field(consts.SYNC.CODE_INDEX) then @@ -85,8 +85,8 @@ local function user_code_report(driver, device, cmd) local was_syncing = command_in_progress == consts.SYNC.CODES_FROM_LOCK zwave_handlers.user_code_report(driver, device, cmd) if was_syncing and device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then - -- the sync is finished. The admin code slot is outside the lock's user code slots, so it is read separately. - yale_admin.read_admin_code(device) + -- the sync is finished. The programming code slot is outside the lock's user code slots, so it is read separately. + yale_programming_code.read_programming_code(device) end end @@ -96,18 +96,18 @@ local function notification_report(driver, device, cmd) 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 {} if cmd.args.event == access_control_event.NEW_USER_CODE_ADDED and - is_admin_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( - cmd.args.event_parameter, cmd.args.v1_alarm_level))) and not is_admin_change_in_progress(device) then - -- the lock confirms a write to the admin code slot like any other user code (ZW2). An update of the - -- admin code is completed by the generic handler. - admin_code_reported(device) + is_programming_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level))) and not is_programming_change_in_progress(device) then + -- the lock confirms a write to the programming code slot like any other user code (ZW2). An update of the + -- programming code is completed by the generic handler. + programming_code_reported(device) return elseif cmd.args.event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION and command_in_progress == consts.LOCK_CREDENTIALS.ADD and - is_admin_credential(device, credential_args.credentialIndex) then - -- the "master code changed" event is sent instead of NEW_USER_CODE_ADDED by other modules (ZW3), so it + is_programming_credential(device, credential_args.credentialIndex) then + -- the "programming code changed" event is sent instead of NEW_USER_CODE_ADDED by other modules (ZW3), so it -- completes an addCredential in progress. - admin_code_reported(device) + programming_code_reported(device) return end end @@ -116,14 +116,14 @@ end local function add_credential(driver, device, command) local cred_index = command.args.userIndex - if not is_admin_credential(device, cred_index) then + if not is_programming_credential(device, cred_index) then cap_handlers.add_credential(driver, device, command) return end - -- only an admin code credential may be written to the admin code slot, and it is not limited by the lock's + -- only a programming code credential may be written to the programming code slot, and it is not limited by the lock's -- user slots - if command.args.userType ~= consts.ADMIN.USER_TYPE then + if command.args.userType ~= consts.PROGRAMMING_USER.USER_TYPE then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) return end @@ -131,7 +131,7 @@ local function add_credential(driver, device, command) lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) return end - -- writing the slot would replace the admin code on the lock, which is what updateCredential is for + -- writing the slot would replace the programming code on the lock, which is what updateCredential is for if tables.find_entry(device, "credentials", cred_index) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.OCCUPIED) return @@ -141,8 +141,8 @@ local function add_credential(driver, device, command) end local function add_user(driver, device, command) - -- the admin code user is added with the admin code credential, never on its own in a user slot - if command.args.userType == consts.ADMIN.USER_TYPE then + -- the programming code user is added with the programming code credential, never on its own in a user slot + 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 @@ -150,8 +150,8 @@ local function add_user(driver, device, command) end local function update_user(driver, device, command) - -- the admin code user cannot be changed to another type, and no other user can be made an admin code user - if (command.args.userType == consts.ADMIN.USER_TYPE) ~= is_admin_credential(device, command.args.userIndex) then + -- the programming code user cannot be changed to another type, and no other user can be made a programming code user + if (command.args.userType == consts.PROGRAMMING_USER.USER_TYPE) ~= is_programming_credential(device, command.args.userIndex) then lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) return end diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/programming_code.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/programming_code.lua new file mode 100644 index 0000000000..d2a9b8bd32 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/programming_code.lua @@ -0,0 +1,29 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Yale locks keep their programming code in a user code slot that is not one of the lock's user slots. It is exposed +-- like any other credential, owned by a user with the same index and the programming code userType. +-- Everything that is specific to where Yale puts that slot lives here, so generic lock code only has to know +-- about the programmingUser userType. + +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local yale_programming_code = { + MANUFACTURER_ID = 0x0129, + SLOT = 251, +} + +-- Returns the credentialIndex of the programming code slot, or nil if the device is not a Yale lock. +function yale_programming_code.credential_index(device) + if device.zwave_manufacturer_id == yale_programming_code.MANUFACTURER_ID then + return yale_programming_code.SLOT + end +end + +-- Read the programming code slot back from the lock, so the programming code is recorded even if it was set outside +-- SmartThings. The report is handled like any other user code report. +function yale_programming_code.read_programming_code(device) + device:send(UserCode:Get({ user_identifier = yale_programming_code.SLOT })) +end + +return yale_programming_code diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua index ec5736979d..dd9c25e2b8 100644 --- a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua @@ -69,9 +69,9 @@ local function alarm_report_handler(driver, device, cmd) event = capabilities.lock.lock.locked() event.data = {method = METHOD.AUTO} elseif (alarm_type == 32) then - -- all user codes have been deleted, which does not affect the admin code - lock_utils.delete_all_non_admin_entries(device, "credentials") - lock_utils.delete_all_non_admin_entries(device, "users") + -- all user codes have been deleted, which does not affect the programming code + lock_utils.delete_all_non_programming_entries(device, "credentials") + lock_utils.delete_all_non_programming_entries(device, "users") elseif (alarm_type == 33) then -- credential has been deleted lock_utils.delete_credential_report_helper(device, credential_index) From 5c6c5de4b60eaa11000193ce57a3e1ed333ea032 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 15:39:48 -0600 Subject: [PATCH 06/16] Z-Wave Lock: Do not allow addUser, addCredential or updateUser to create programming users Match the Zigbee lock driver and move the programming user guards into the generic handlers, so they apply to every lock rather than only Yale: - addUser and addCredential reject the programming user type. - addCredential rejects a userIndex that names an existing programming user, so it cannot be given another credential. - updateUser cannot make a user a programming user, or change a programming user to another type. Add lock_utils.is_programming_user and drop the Yale addUser and updateUser wrappers, which the generic handlers now cover. Yale keeps its addCredential, which owns the programming code slot. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lock_handlers/capabilities.lua | 17 +++++++- .../zwave-lock/src/lock_utils/utils.lua | 6 +++ .../src/test/test_lock_programming_code.lua | 41 +++++++++++++++++-- .../zwave-lock/src/yale-lock/init.lua | 22 ---------- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index d761be3ad0..de398b6f58 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -25,6 +25,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", { @@ -42,6 +48,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", { @@ -135,8 +147,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 diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 5b401cb756..fe104787e1 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -137,6 +137,12 @@ end -- -- The programming code credential is found with tables.get_programming_credential. +-- 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 + -- Clear every entry of the "users" or "credentials" table except the programming code credential and its user. function lock_utils.delete_all_non_programming_entries(device, table_name) local programming = tables.get_programming_credential(device) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 2089f86fe4..6f076c7205 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -832,14 +832,49 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateUser: other locks are not restricted by the programming code user type", + "updateUser: no lock can make a guest a programming user", function() seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Guest", "programmingUser" } } }) - expect_users(mock_device, { { userIndex = 1, userName = "Guest", userType = "programmingUser" } }) - expect_user_result(mock_device, { commandName = "updateUser", statusCode = "success", userIndex = 1 }) + expect_user_result(mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "addUser: no lock can add a programming user", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Owner", "programmingUser" } } }) + expect_user_result(mock_device, { commandName = "addUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "addCredential: no lock can add a credential of the programming user type", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "programmingUser", "pin", "123456" } } }) + expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "addCredential: an existing programming user cannot be given another credential", + function() + seed(mock_device, { PROGRAMMING_USER_ENTRY }, {}) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "guest", "pin", "123456" } } }) + expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) test.wait_for_events() end, { test_init = make_test_init(mock_device) } diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua index 4b215930b0..681e152475 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -140,24 +140,6 @@ local function add_credential(driver, device, command) cap_handlers.send_add_credential(device, cred_index, cred_index, command) end -local function add_user(driver, device, command) - -- the programming code user is added with the programming code credential, never on its own in a user slot - 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 - cap_handlers.add_user(driver, device, command) -end - -local function update_user(driver, device, command) - -- the programming code user cannot be changed to another type, and no other user can be made a programming code user - if (command.args.userType == consts.PROGRAMMING_USER.USER_TYPE) ~= is_programming_credential(device, command.args.userIndex) then - lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) - return - end - cap_handlers.update_user(driver, device, command) -end - local yale_lock = { zwave_handlers = { [cc.USER_CODE] = { @@ -168,10 +150,6 @@ local yale_lock = { }, }, capability_handlers = { - [capabilities.lockUsers.ID] = { - [capabilities.lockUsers.commands.addUser.NAME] = add_user, - [capabilities.lockUsers.commands.updateUser.NAME] = update_user - }, [capabilities.lockCredentials.ID] = { [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential } From 987a575fb7f5151282b563f8ab9ca3f4357a757e Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 16:00:34 -0600 Subject: [PATCH 07/16] Z-Wave Lock: Simplify deleteCredential and ignore reported credentials at index 0 Index 0 is the Z-Wave "all users" wildcard and never a credential, so deleteCredential does not need to handle a stale entry there: - Inline send_credential_delete into delete_credential, which now picks the credential index per case and has a single send and read-back. - Restore the up-front rejection of credentialIndex 0. - Ignore reported credentials below the table minimum in add_reported_credential, the one way an index 0 entry could be added. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lock_handlers/capabilities.lua | 60 +++++++++---------- .../zwave-lock/src/lock_utils/utils.lua | 2 + .../src/test/test_lock_programming_code.lua | 34 ++++------- 3 files changed, 41 insertions(+), 55 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index de398b6f58..674a477b36 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -209,43 +209,21 @@ function CapabilityHandlers.update_credential(driver, device, command) })) end --- Remove a credential from the lock. The busy state must already be set. -local function send_credential_delete(device, credential_index) - if credential_index == 0 then - -- user identifier 0 addresses every user code on the lock, so a stale entry is only removed locally - lock_utils.delete_credential_report_helper(device, credential_index) - else - device:send(UserCode:Set({ - user_identifier = credential_index, - user_id_status = UserCode.user_id_status.AVAILABLE - })) - -- A lock that refuses the delete (e.g. a Yale programming code) ignores the Set and sends nothing, so read the - -- slot back if the delete has not completed. The report tells us whether the code is still there. A timer - -- left from an earlier delete is cancelled so it cannot read back the slot during this one. - local previous_timer = device:get_field(consts.DELETE_CONFIRM_TIMER) - if previous_timer ~= nil then device.thread:cancel_timer(previous_timer) end - device:set_field(consts.DELETE_CONFIRM_TIMER, device.thread:call_with_delay(consts.DELETE_CONFIRM_DELAY, function() - device:set_field(consts.DELETE_CONFIRM_TIMER, nil) - if lock_utils.is_delete_in_progress(device, credential_index) then - device:send(UserCode:Get({ user_identifier = credential_index })) - end - end)) - end -end - function CapabilityHandlers.delete_credential(driver, device, command) - local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials. + if command.args.credentialIndex == 0 then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end - if cmd_in_progress == consts.LOCK_USERS.DELETE then - -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. A stale - -- credential at index 0 is removed locally by send_credential_delete. + local credential_index + if device:get_field(consts.DRIVER_STATE.COMMAND_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 {} - send_credential_delete(device, credential_args.credentialIndex) - elseif command.args.credentialIndex == 0 then - -- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials. - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) + credential_index = credential_args.credentialIndex elseif lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) + return else -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) @@ -258,8 +236,24 @@ function CapabilityHandlers.delete_credential(driver, device, command) credentialType = command.args.credentialType, userIndex = found_cred.userIndex, }) - send_credential_delete(device, command.args.credentialIndex) + credential_index = command.args.credentialIndex end + + device:send(UserCode:Set({ + user_identifier = credential_index, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + -- A lock that refuses the delete (e.g. a Yale programming code) ignores the Set and sends nothing, so read the + -- slot back if the delete has not completed. The report tells us whether the code is still there. A timer + -- left from an earlier delete is cancelled so it cannot read back the slot during this one. + local previous_timer = device:get_field(consts.DELETE_CONFIRM_TIMER) + if previous_timer ~= nil then device.thread:cancel_timer(previous_timer) end + device:set_field(consts.DELETE_CONFIRM_TIMER, device.thread:call_with_delay(consts.DELETE_CONFIRM_DELAY, function() + device:set_field(consts.DELETE_CONFIRM_TIMER, nil) + if lock_utils.is_delete_in_progress(device, credential_index) then + device:send(UserCode:Get({ user_identifier = credential_index })) + end + end)) end function CapabilityHandlers.delete_all_credentials(driver, device, command) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index fe104787e1..903d430aa7 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -248,6 +248,8 @@ end -- 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) + -- Index 0 means "all users" in Z-Wave, so a report for it is not a credential. + if credential_index < tables.get_minimum(device, "credentials") then return end if tables.find_entry(device, "credentials", credential_index) then return end return lock_utils.add_credential_and_user(device, { userIndex = tables.next_index(device, "users"), diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 6f076c7205..82e0c7564c 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -551,8 +551,7 @@ test.register_coroutine_test( test.register_coroutine_test( "updateCredential: credentialIndex 0 is rejected without sending user identifier 0", function() - seed(mock_device, { { userIndex = 1, userName = "Master Code", userType = "guest" } }, - { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 1, 0, "pin", "5678" } } }) expect_credential_result(mock_device, { commandName = "updateCredential", statusCode = "invalidCommand" }) @@ -564,8 +563,7 @@ test.register_coroutine_test( test.register_coroutine_test( "deleteCredential: credentialIndex 0 is rejected without sending user identifier 0", function() - seed(mock_device, { { userIndex = 1, userName = "Master Code", userType = "guest" } }, - { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 0, "pin" } } }) expect_credential_result(mock_device, { commandName = "deleteCredential", statusCode = "invalidCommand" }) @@ -574,24 +572,6 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device) } ) -test.register_coroutine_test( - "deleteUser: a stale credential at index 0 is removed locally without sending user identifier 0", - function() - seed(mock_device, { GUEST_USER }, { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) - test.socket.capability:__queue_receive({ mock_device.id, - { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } } }) - expect_users(mock_device, {}) - expect_credentials(mock_device, {}) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockUsers.commandResult({ commandName = "deleteUser", statusCode = "success", userIndex = 1 }, - { state_change = true, visibility = { displayed = false } }))) - expect_credential_result(mock_device, - { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 0 }) - test.wait_for_events() - end, - { test_init = make_test_init(mock_device) } -) - test.register_coroutine_test( "deleteAllCredentials: sends a single user identifier 0 clear and keeps the programming code in state", function() @@ -625,6 +605,16 @@ local function queue_new_user_code_added(device, slot) }) }) end +test.register_coroutine_test( + "an out-of-band user code added event for index 0 is ignored", + function() + -- nothing is added or emitted + queue_new_user_code_added(mock_device, 0) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + test.register_coroutine_test( "addCredential: Yale programming code is added when the lock confirms the write with a new user code event", function() From 9a15c12eb9c434ebc971e0cab5815f8c1c80324c Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 16:18:00 -0600 Subject: [PATCH 08/16] Z-Wave Lock: Do not allow the programming user to be deleted Match the Zigbee lock driver and refuse to delete a programming user's credential instead of sending the delete to the lock. A standalone deleteCredential fails, and a deleteUser fails for both lockUsers and lockCredentials. Nothing is sent to the lock. This removes the read-back that checked whether the lock had refused a delete: the delete confirm timer and its constants, is_delete_in_progress, emit_delete_failure and the matching branch in the user code report. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lock_handlers/capabilities.lua | 29 ++-- .../src/lock_handlers/zwave_responses.lua | 3 - .../zwave-lock/src/lock_utils/constants.lua | 2 - .../zwave-lock/src/lock_utils/utils.lua | 20 --- .../src/test/test_lock_programming_code.lua | 142 +----------------- .../zwave-lock/src/yale-lock/init.lua | 6 +- 6 files changed, 26 insertions(+), 176 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 674a477b36..710f6f3d37 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -209,6 +209,16 @@ function CapabilityHandlers.update_credential(driver, device, command) })) end +-- The programming code cannot be deleted. Fail the command without sending anything to the lock. +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) -- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials. if command.args.credentialIndex == 0 then @@ -220,6 +230,10 @@ function CapabilityHandlers.delete_credential(driver, device, command) if device:get_field(consts.DRIVER_STATE.COMMAND_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 credential_index = credential_args.credentialIndex elseif lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) @@ -231,6 +245,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, @@ -243,17 +261,6 @@ function CapabilityHandlers.delete_credential(driver, device, command) user_identifier = credential_index, user_id_status = UserCode.user_id_status.AVAILABLE })) - -- A lock that refuses the delete (e.g. a Yale programming code) ignores the Set and sends nothing, so read the - -- slot back if the delete has not completed. The report tells us whether the code is still there. A timer - -- left from an earlier delete is cancelled so it cannot read back the slot during this one. - local previous_timer = device:get_field(consts.DELETE_CONFIRM_TIMER) - if previous_timer ~= nil then device.thread:cancel_timer(previous_timer) end - device:set_field(consts.DELETE_CONFIRM_TIMER, device.thread:call_with_delay(consts.DELETE_CONFIRM_DELAY, function() - device:set_field(consts.DELETE_CONFIRM_TIMER, nil) - if lock_utils.is_delete_in_progress(device, credential_index) then - device:send(UserCode:Get({ user_identifier = credential_index })) - end - end)) end function CapabilityHandlers.delete_all_credentials(driver, device, command) 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 428c0d8ae4..ebb5863dfa 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -47,9 +47,6 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) user_code_available = true end lock_utils.continue_code_sync(device, user_code_occupied, user_code_available) - elseif user_code_occupied and lock_utils.is_delete_in_progress(device, credential_index) then - -- the code is still on the lock after a delete, so the lock refused it - lock_utils.emit_delete_failure(device) 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 64db16c881..a7fcf78578 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -58,7 +58,5 @@ lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" lock_constants.DELAY_LOCK_EVENT_TIMER = "_delay_lock_event_timer" lock_constants.MAX_DELAY = 10 -- Seconds to wait for a delete to be confirmed before reading the slot back -lock_constants.DELETE_CONFIRM_DELAY = 4.2 -lock_constants.DELETE_CONFIRM_TIMER = "_delete_confirm_timer" return lock_constants diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 903d430aa7..22e8280cb4 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -38,26 +38,6 @@ function lock_utils.clear_busy_state(device) device:set_field(consts.DRIVER_STATE.BUSY, false) end - --- True if a deleteCredential or deleteUser of the credential at credential_index is waiting on the lock. -function lock_utils.is_delete_in_progress(device, credential_index) - 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 {} - return (command_in_progress == consts.LOCK_CREDENTIALS.DELETE or command_in_progress == consts.LOCK_USERS.DELETE) and - credential_args.credentialIndex == credential_index -end - --- Report a delete the lock refused, leaving the user and credential in place. -function lock_utils.emit_delete_failure(device) - local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) - if command_in_progress == consts.LOCK_USERS.DELETE then - lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.FAILURE) - end - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) - lock_utils.clear_busy_state(device) -end - - -- [[ SYNC STATE MANAGEMENT ]] -- function lock_utils.sync_device_state(device) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 82e0c7564c..1e7841fb74 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -306,96 +306,15 @@ test.register_coroutine_test( ) -- ============================================================================ --- deleting the programming code: the lock decides, and the driver reports what it did +-- the programming code cannot be deleted -- ============================================================================ -local DELETE_CONFIRM_DELAY = constants.DELETE_CONFIRM_DELAY - -local function expect_delete_set(device, slot) - test.socket.zwave:__expect_send(UserCode:Set({ - user_identifier = slot, - user_id_status = UserCode.user_id_status.AVAILABLE, - }):build_test_tx(device.id)) -end - -local function queue_delete_credential(device, slot) - test.socket.capability:__queue_receive({ device.id, - { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { slot, "pin" } } }) -end - -local function queue_delete_programming_credential(device) - queue_delete_credential(device, 251) -end - -local function read_back_after_delay(device, slot) - test.mock_time.advance_time(DELETE_CONFIRM_DELAY) - test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = slot }):build_test_tx(device.id)) - test.wait_for_events() -end - test.register_coroutine_test( - "deleteCredential: the Yale programming code is deleted when the lock confirms it", + "deleteCredential: the programming code cannot be deleted and nothing is sent to the lock", function() seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) - test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_programming_credential(yale_mock_device) - expect_delete_set(yale_mock_device, 251) - test.wait_for_events() - - test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = access_control_event.SINGLE_USER_CODE_DELETED, - v1_alarm_level = 251, - }) }) - expect_credentials(yale_mock_device, {}) - expect_credential_result(yale_mock_device, - { commandName = "deleteCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) - test.wait_for_events() - - -- the delete is already confirmed, so the slot is not read back - test.mock_time.advance_time(DELETE_CONFIRM_DELAY) - test.wait_for_events() - end, - { test_init = make_test_init(yale_mock_device) } -) - -test.register_coroutine_test( - "deleteCredential: an unconfirmed delete is read back and succeeds when the slot is empty", - function() - seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) - test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_programming_credential(yale_mock_device) - expect_delete_set(yale_mock_device, 251) - test.wait_for_events() - read_back_after_delay(yale_mock_device, 251) - - test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ - user_identifier = 251, - user_id_status = UserCode.user_id_status.AVAILABLE, - }) }) - expect_credentials(yale_mock_device, {}) - expect_credential_result(yale_mock_device, - { commandName = "deleteCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) - test.wait_for_events() - end, - { test_init = make_test_init(yale_mock_device) } -) - -test.register_coroutine_test( - "deleteCredential: a delete the lock refuses is reported as a failure and the programming code is kept", - function() - seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) - test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_programming_credential(yale_mock_device) - expect_delete_set(yale_mock_device, 251) - test.wait_for_events() - read_back_after_delay(yale_mock_device, 251) - - test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ - user_identifier = 251, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, - user_code = "199111", - }) }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 251, "pin" } } }) expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) test.wait_for_events() end, @@ -403,62 +322,11 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteCredential: the read-back timer of an earlier delete does not fire during a later delete of the same slot", - function() - seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) - test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_credential(mock_device, 1) - expect_delete_set(mock_device, 1) - test.wait_for_events() - - -- the first delete is confirmed right away - test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = access_control_event.SINGLE_USER_CODE_DELETED, - v1_alarm_level = 1, - }) }) - expect_credentials(mock_device, {}) - expect_credential_result(mock_device, - { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) - test.wait_for_events() - - -- the slot is set again at the lock and deleted again before the first timer would fire - test.socket.zwave:__queue_receive({ mock_device.id, UserCode:Report({ - user_identifier = 1, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, - user_code = "1234", - }) }) - expect_credentials(mock_device, - { { userIndex = 2, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 2" } }) - expect_users(mock_device, { GUEST_USER, { userIndex = 2, userName = "Guest 2", userType = "guest" } }) - test.wait_for_events() - queue_delete_credential(mock_device, 1) - expect_delete_set(mock_device, 1) - test.wait_for_events() - - -- only the second delete reads the slot back - read_back_after_delay(mock_device, 1) - end, - { test_init = make_test_init(mock_device) } -) - -test.register_coroutine_test( - "deleteUser: a delete of the programming code user the lock refuses fails for both capabilities", + "deleteUser: the programming code user cannot be deleted and fails for both capabilities", function() seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) - test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 251 } } }) - expect_delete_set(yale_mock_device, 251) - test.wait_for_events() - read_back_after_delay(yale_mock_device, 251) - - test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ - user_identifier = 251, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, - user_code = "199111", - }) }) test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", capabilities.lockUsers.commandResult({ commandName = "deleteUser", statusCode = "failure" }, { state_change = true, visibility = { displayed = false } }))) diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua index 681e152475..fe81fff4f9 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -56,8 +56,8 @@ local function programming_code_reported(device) end end --- True if an update or delete of the programming code is waiting on the lock. The lock confirms or refuses it like --- any other credential, so its reports go to the generic handlers. +-- True if an update of the programming code is waiting on the lock. The lock confirms it like any other +-- credential, so its reports go to the generic handlers. local function is_programming_change_in_progress(device) 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 {} @@ -70,7 +70,7 @@ local function user_code_report(driver, device, cmd) local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local occupied = lock_utils.is_code_occupied(cmd) - -- a report of the programming code slot records the programming code, unless it answers an update or delete of the + -- a report of the programming code slot records the programming code, unless it answers an update of the -- programming code (handled like any other credential). if is_programming_credential(device, credential_index) and occupied and not is_programming_change_in_progress(device) then programming_code_reported(device) From 135387be5bae90a8e9dc52822a9ead27c191e9e6 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 16:38:07 -0600 Subject: [PATCH 09/16] Z-Wave Lock: Do not let a programming code report move a Yale sync on The generic sync handler treats a report for an unexpected index as an empty slot and moves on, which skips an index. The Yale programming code slot is outside the sync's range and is read separately, so a report for it during a sync, such as the read after a migration, must not reach the sync unless the sync asked for that slot. An occupied report is still recorded, and an unrequested empty report is now ignored instead of advancing the sync. Co-Authored-By: Claude Sonnet 5.5 --- .../src/test/test_lock_programming_code.lua | 21 +++++++++++++++ .../zwave-lock/src/yale-lock/init.lua | 27 ++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 1e7841fb74..9fcf2d39f1 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -580,6 +580,27 @@ test.register_coroutine_test( { test_init = make_test_init(yale_mock_device) } ) +test.register_coroutine_test( + "an empty programming code slot report during a sync does not move the sync on", + function() + start_sync(yale_mock_device, 2) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.wait_for_events() + + -- the sync still waits for the index it asked for + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 3 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + test.register_coroutine_test( "a sync that reaches the programming code slot records the programming code and moves on", function() diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua index fe81fff4f9..f1e209253f 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -70,21 +70,28 @@ local function user_code_report(driver, device, cmd) local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local occupied = lock_utils.is_code_occupied(cmd) - -- a report of the programming code slot records the programming code, unless it answers an update of the - -- programming code (handled like any other credential). - if is_programming_credential(device, credential_index) and occupied and not is_programming_change_in_progress(device) then - programming_code_reported(device) - -- a sync only moves on when the slot it asked for is reported. The programming code is already recorded, so - -- the sync does not add it again. - if command_in_progress ~= consts.SYNC.CODES_FROM_LOCK or - credential_index ~= device:get_field(consts.SYNC.CODE_INDEX) then + -- The generic sync handler treats a report for any other index as an empty slot and moves on, which would skip + -- an index. The programming code slot is outside the sync's range and is read separately, so its reports must + -- not reach the sync unless the sync asked for that slot. + local syncing = command_in_progress == consts.SYNC.CODES_FROM_LOCK + local sync_asked_for_slot = syncing and credential_index == device:get_field(consts.SYNC.CODE_INDEX) + + if is_programming_credential(device, credential_index) then + if occupied and not is_programming_change_in_progress(device) then + -- a report of the programming code slot records the programming code, unless it answers an update of the + -- programming code (handled like any other credential). The programming code is already recorded, so a + -- sync does not add it again. + programming_code_reported(device) + if not sync_asked_for_slot then return end + elseif syncing and not sync_asked_for_slot then + -- an unrequested report that the programming code slot is empty must not move the sync on, or it would skip + -- the index the sync is waiting for return end end - local was_syncing = command_in_progress == consts.SYNC.CODES_FROM_LOCK zwave_handlers.user_code_report(driver, device, cmd) - if was_syncing and device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then + if syncing and device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then -- the sync is finished. The programming code slot is outside the lock's user code slots, so it is read separately. yale_programming_code.read_programming_code(device) end From 6cadd5299fe5a559dba7c9a4004b2070765ddb08 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 16:38:11 -0600 Subject: [PATCH 10/16] Z-Wave Lock: Rely on the sync to populate the Yale programming code Do not allow addCredential to create the Yale programming code. Yale locks need their programming code to onboard, so the code sync (and the read after a migration) records it, like the Zigbee lock driver, and updateCredential is the only way to change it. - Remove the Yale addCredential wrapper and the handling that only completed an add: the add result in programming_code_reported and the ZW3 "programming code changed" branch. - Check for an update in progress, not "anything but an add", when deciding whether a report belongs to the generic handlers. - Inline send_add_credential back into the generic add_credential, which is now its only caller. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lock_handlers/capabilities.lua | 27 ++-- .../src/test/test_lock_programming_code.lua | 116 +----------------- .../zwave-lock/src/yale-lock/init.lua | 62 +--------- 3 files changed, 18 insertions(+), 187 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 710f6f3d37..704be3190f 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -123,21 +123,6 @@ end -- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- --- Set the busy state and write the PIN to the lock. The caller must already have validated the command. -function CapabilityHandlers.send_add_credential(device, user_index, cred_index, command) - lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { - userIndex = user_index, - credentialIndex = cred_index, - credentialType = command.args.credentialType, - credentialName = command.args.credentialName, - }) - device:send(UserCode:Set({ - user_identifier = cred_index, - user_code = command.args.credentialData, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS - })) -end - function CapabilityHandlers.add_credential(driver, device, command) if lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) @@ -169,7 +154,17 @@ function CapabilityHandlers.add_credential(driver, device, command) local cred_index = tables.next_index(device, "credentials") -- Set busy state and attempt to set the PIN on-device. - CapabilityHandlers.send_add_credential(device, user_index, cred_index, command) + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = user_index, + credentialIndex = cred_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = cred_index, + user_code = command.args.credentialData, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) end function CapabilityHandlers.update_credential(driver, device, command) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 9fcf2d39f1..6487495890 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -2,10 +2,9 @@ -- Licensed under the Apache License, Version 2.0 -- -- Integration tests for the Yale programming code, which is kept in user code slot 251: --- it is set with a plain User Code Set and confirmed by the "programming code changed" notification, --- it is exposed as a programmingUser user whose index matches the slot, a delete is left to the lock and --- reported as it confirms or refuses it, and delete-all keeps it. User identifier 0 (all users) is never --- sent for a single credential. +-- it is recorded by the code sync (or when the lock reports it), it is exposed as a programmingUser user whose +-- index matches the slot, it can be updated but not added or deleted, and delete-all keeps it. User identifier +-- 0 (all users) is never sent for a single credential. local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -111,57 +110,9 @@ local GUEST_USER = { userIndex = 1, userName = "Guest 1", userType = "guest" } local GUEST_CREDENTIAL = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } -- ============================================================================ --- setting the programming code +-- updating the programming code -- ============================================================================ -test.register_coroutine_test( - "addCredential: Yale programming code is written to slot 251 and added when the lock reports it changed", - function() - test.socket.capability:__queue_receive({ yale_mock_device.id, - { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 251, "programmingUser", "pin", "199111" } } }) - test.socket.zwave:__expect_send(UserCode:Set({ - user_identifier = 251, - user_code = "199111", - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, - }):build_test_tx(yale_mock_device.id)) - test.wait_for_events() - - -- The lock reports the change with a notification, not a User Code Report. - queue_programming_code_changed(yale_mock_device) - expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) - expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) - expect_credential_result(yale_mock_device, - { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) - test.wait_for_events() - end, - { test_init = make_test_init(yale_mock_device) } -) - -test.register_coroutine_test( - "addCredential: a guest credential cannot be written to the Yale programming code slot", - function() - test.socket.capability:__queue_receive({ yale_mock_device.id, - { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) - expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) - test.wait_for_events() - end, - { test_init = make_test_init(yale_mock_device) } -) - -test.register_coroutine_test( - "addCredential: an existing Yale programming code is not overwritten and the add is reported as occupied", - function() - seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) - test.socket.capability:__queue_receive({ yale_mock_device.id, - { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 251, "programmingUser", "pin", "9999" } } }) - expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "occupied" }) - test.wait_for_events() - end, - { test_init = make_test_init(yale_mock_device) } -) - test.register_coroutine_test( "addCredential: user 251 is an ordinary user on a lock that is not a Yale", function() @@ -179,42 +130,6 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device) } ) -test.register_coroutine_test( - "addCredential: the Yale programming code can be added when the credentials table is full", - function() - local function guests() - local users, credentials = {}, {} - for i = 1, 20 do - table.insert(users, { userIndex = i, userName = "Guest " .. i, userType = "guest" }) - table.insert(credentials, { userIndex = i, credentialIndex = i, credentialType = "pin" }) - end - return users, credentials - end - seed(yale_mock_device, guests()) - - test.socket.capability:__queue_receive({ yale_mock_device.id, - { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 251, "programmingUser", "pin", "199111" } } }) - test.socket.zwave:__expect_send(UserCode:Set({ - user_identifier = 251, - user_code = "199111", - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, - }):build_test_tx(yale_mock_device.id)) - test.wait_for_events() - - queue_programming_code_changed(yale_mock_device) - local expected_users, expected_credentials = guests() - table.insert(expected_users, PROGRAMMING_USER_ENTRY) - table.insert(expected_credentials, PROGRAMMING_CREDENTIAL) - expect_users(yale_mock_device, expected_users) - expect_credentials(yale_mock_device, expected_credentials) - expect_credential_result(yale_mock_device, - { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) - test.wait_for_events() - end, - { test_init = make_test_init(yale_mock_device) } -) - test.register_coroutine_test( "updateCredential: Yale programming code is written to slot 251 and succeeds when the lock reports it changed", function() @@ -483,29 +398,6 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device) } ) -test.register_coroutine_test( - "addCredential: Yale programming code is added when the lock confirms the write with a new user code event", - function() - test.socket.capability:__queue_receive({ yale_mock_device.id, - { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 251, "programmingUser", "pin", "199111" } } }) - test.socket.zwave:__expect_send(UserCode:Set({ - user_identifier = 251, - user_code = "199111", - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, - }):build_test_tx(yale_mock_device.id)) - test.wait_for_events() - - queue_new_user_code_added(yale_mock_device, 251) - expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) - expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) - expect_credential_result(yale_mock_device, - { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) - test.wait_for_events() - end, - { test_init = make_test_init(yale_mock_device) } -) - test.register_coroutine_test( "new user code event at the Yale programming code slot with no command in progress adds the programming code", function() diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua index f1e209253f..d057e17244 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -1,7 +1,6 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) @@ -11,7 +10,6 @@ local lock_utils = require "lock_utils.utils" local tables = require "lock_utils.tables" local yale_programming_code = require "yale-lock.programming_code" local zwave_handlers = require "lock_handlers.zwave_responses" -local cap_handlers = require "lock_handlers.capabilities" -- Yale locks keep their programming code in a user code slot that is not one of the lock's user slots. It is exposed -- like any other credential, owned by a user that has the same index and the programming code userType. @@ -37,23 +35,9 @@ local function add_programming_entries(device, credential_index, name) }, true) end --- Record the programming code and, if an addCredential for the programming code is in progress, report the result. A --- command in progress for any other credential is left alone. +-- Record the programming code the lock reported: the programming code credential and the user that owns it. local function programming_code_reported(device) - local programming_code_slot = yale_programming_code.credential_index(device) - 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 adding_programming = command_in_progress == consts.LOCK_CREDENTIALS.ADD and - credential_args.credentialIndex == programming_code_slot - local status = add_programming_entries(device, programming_code_slot, adding_programming and credential_args.credentialName or nil) - if adding_programming then - local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { - userIndex = credential_args.userIndex, - credentialIndex = credential_args.credentialIndex, - } or nil - lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, status, additional_info) - lock_utils.clear_busy_state(device) - end + add_programming_entries(device, yale_programming_code.credential_index(device)) end -- True if an update of the programming code is waiting on the lock. The lock confirms it like any other @@ -61,7 +45,7 @@ end local function is_programming_change_in_progress(device) 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 {} - return command_in_progress ~= consts.LOCK_CREDENTIALS.ADD and + return command_in_progress == consts.LOCK_CREDENTIALS.UPDATE and is_programming_credential(device, credential_args.credentialIndex) end @@ -100,8 +84,6 @@ end local function notification_report(driver, device, cmd) if cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL then local access_control_event = Notification.event.access_control - 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 {} if cmd.args.event == access_control_event.NEW_USER_CODE_ADDED and is_programming_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( cmd.args.event_parameter, cmd.args.v1_alarm_level))) and not is_programming_change_in_progress(device) then @@ -109,44 +91,11 @@ local function notification_report(driver, device, cmd) -- programming code is completed by the generic handler. programming_code_reported(device) return - elseif cmd.args.event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION and - command_in_progress == consts.LOCK_CREDENTIALS.ADD and - is_programming_credential(device, credential_args.credentialIndex) then - -- the "programming code changed" event is sent instead of NEW_USER_CODE_ADDED by other modules (ZW3), so it - -- completes an addCredential in progress. - programming_code_reported(device) - return end end zwave_handlers.notification_report(driver, device, cmd) end -local function add_credential(driver, device, command) - local cred_index = command.args.userIndex - if not is_programming_credential(device, cred_index) then - cap_handlers.add_credential(driver, device, command) - return - end - - -- only a programming code credential may be written to the programming code slot, and it is not limited by the lock's - -- user slots - if command.args.userType ~= consts.PROGRAMMING_USER.USER_TYPE then - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) - return - end - if lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) - return - end - -- writing the slot would replace the programming code on the lock, which is what updateCredential is for - if tables.find_entry(device, "credentials", cred_index) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.OCCUPIED) - return - end - - cap_handlers.send_add_credential(device, cred_index, cred_index, command) -end - local yale_lock = { zwave_handlers = { [cc.USER_CODE] = { @@ -156,11 +105,6 @@ local yale_lock = { [Notification.REPORT] = notification_report }, }, - capability_handlers = { - [capabilities.lockCredentials.ID] = { - [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential - } - }, NAME = "Yale Lock", can_handle = require("yale-lock.can_handle"), } From edd04fc9a8bf7ae55703472dcebedc2b6337190e Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 12 Nov 2026 17:06:11 -0600 Subject: [PATCH 11/16] Fix typo --- .../SmartThings/zwave-lock/src/lock_handlers/capabilities.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 704be3190f..73a9c6c40e 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -262,7 +262,7 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local send_clear_commands = function() - -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with an + -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with a -- programming code (Yale) keeps it, so the programming entries are kept in state below. device:send(UserCode:Set({ user_identifier = 0, From 23aa5b5de7160777d8f80ff2f40b841acb8bfd96 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 13 Nov 2026 00:13:49 -0600 Subject: [PATCH 12/16] Gate new tests at min api 15 to match existing tests --- .../src/test/test_lock_programming_code.lua | 155 ++++++++++++++---- 1 file changed, 124 insertions(+), 31 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 6487495890..00446f3264 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -127,7 +127,10 @@ test.register_coroutine_test( }):build_test_tx(mock_device.id)) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -147,7 +150,10 @@ test.register_coroutine_test( expect_credential_result(yale_mock_device, { commandName = "updateCredential", statusCode = "success" }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -172,7 +178,10 @@ test.register_coroutine_test( { commandName = "updateCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -206,7 +215,10 @@ test.register_coroutine_test( { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -217,7 +229,10 @@ test.register_coroutine_test( test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "199111" }) }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) -- ============================================================================ @@ -233,7 +248,10 @@ test.register_coroutine_test( expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -248,7 +266,10 @@ test.register_coroutine_test( expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -263,7 +284,10 @@ test.register_coroutine_test( expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -275,7 +299,10 @@ test.register_coroutine_test( expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -292,7 +319,10 @@ test.register_coroutine_test( expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -307,7 +337,10 @@ test.register_coroutine_test( expect_users(mock_device, {}) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -324,7 +357,10 @@ test.register_coroutine_test( expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "resourceExhausted" }) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) -- ============================================================================ @@ -340,7 +376,10 @@ test.register_coroutine_test( expect_credential_result(mock_device, { commandName = "updateCredential", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -352,7 +391,10 @@ test.register_coroutine_test( expect_credential_result(mock_device, { commandName = "deleteCredential", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -373,7 +415,10 @@ test.register_coroutine_test( expect_credential_result(yale_mock_device, { commandName = "deleteAllCredentials", statusCode = "success" }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) -- ============================================================================ @@ -395,7 +440,10 @@ test.register_coroutine_test( queue_new_user_code_added(mock_device, 0) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -406,7 +454,10 @@ test.register_coroutine_test( expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -440,7 +491,10 @@ test.register_coroutine_test( { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) local function start_sync(device, code_index) @@ -469,7 +523,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 3 }):build_test_tx(yale_mock_device.id)) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -490,7 +547,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 3 }):build_test_tx(yale_mock_device.id)) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -515,7 +575,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 252 }):build_test_tx(yale_mock_device.id)) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -527,7 +590,10 @@ test.register_coroutine_test( expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) -- ============================================================================ @@ -547,7 +613,10 @@ test.register_coroutine_test( expect_user_result(yale_mock_device, { commandName = "addUser", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -559,7 +628,10 @@ test.register_coroutine_test( expect_user_result(yale_mock_device, { commandName = "addUser", statusCode = "success", userIndex = 1 }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -572,7 +644,10 @@ test.register_coroutine_test( expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -585,7 +660,10 @@ test.register_coroutine_test( expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -599,7 +677,10 @@ test.register_coroutine_test( expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "success", userIndex = 251 }) test.wait_for_events() end, - { test_init = make_test_init(yale_mock_device) } + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -612,7 +693,10 @@ test.register_coroutine_test( expect_user_result(mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -623,7 +707,10 @@ test.register_coroutine_test( expect_user_result(mock_device, { commandName = "addUser", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -635,7 +722,10 @@ test.register_coroutine_test( expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.register_coroutine_test( @@ -648,7 +738,10 @@ test.register_coroutine_test( expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) test.wait_for_events() end, - { test_init = make_test_init(mock_device) } + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } ) test.run_registered_tests() From ca8521e8b26a453e068c90c522b063997da0e80e Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 13 Nov 2026 13:26:54 -0600 Subject: [PATCH 13/16] Z-Wave Lock: Expect default pin lengths in the Yale migration tests Migration now always emits the min/max pin code length, so the Yale migration tests must expect them. Co-Authored-By: Claude Sonnet 5.5 --- .../zwave-lock/src/test/test_lock_code_slga_migration.lua | 4 ++++ 1 file changed, 4 insertions(+) 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 508cbc6d54..fd03e5520a 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 @@ -196,6 +196,8 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Master", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(yale_mock_device.id)) @@ -212,6 +214,8 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(yale_mock_device.id)) From 672f044386eee3b649a5b46131bff4662af36052 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 13 Nov 2026 13:32:06 -0600 Subject: [PATCH 14/16] Z-Wave Lock: Remove a stale delete timer comment Co-Authored-By: Claude Sonnet 5.5 --- drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index a7fcf78578..645c22e372 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -57,6 +57,5 @@ 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 --- Seconds to wait for a delete to be confirmed before reading the slot back return lock_constants From bf2942122e9dedc46cf42b1775a241a6636774e3 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 13 Nov 2026 13:32:06 -0600 Subject: [PATCH 15/16] Z-Wave Lock: Simplify updateUser and deleting all non-programming entries updateUser no longer creates a user that does not exist, since users are only added through addUser and addCredential; it fails for them instead, and reads the user once to check the programming type. The delete-all helper keeps entries by their owning user's type. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lock_handlers/capabilities.lua | 22 +- .../zwave-lock/src/lock_utils/utils.lua | 4 +- .../test_init_lifecycle_handlers.lua.orig | 211 +++++++++++ .../src/test/test_lock_pre_configured.lua | 14 +- .../src/test/test_lock_programming_code.lua | 38 -- .../src/test/test_lock_users_commands.lua | 14 +- .../src/test/test_schlage_lock.lua.orig | 336 ++++++++++++++++++ 7 files changed, 559 insertions(+), 80 deletions(-) create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua.orig create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua.orig diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 73a9c6c40e..feaf8c62e6 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -49,24 +49,18 @@ function CapabilityHandlers.update_user(driver, device, command) 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 + local found_user = tables.find_entry(device, "users", command.args.userIndex) + local is_programming_user = found_user ~= nil and found_user.userType == consts.PROGRAMMING_USER.USER_TYPE + if (command.args.userType == consts.PROGRAMMING_USER.USER_TYPE) ~= is_programming_user 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", { - userIndex = command.args.userIndex, - userName = command.args.userName, - userType = command.args.userType, - }) - else - result_status = tables.update_entry(device, "users", - command.args.userIndex, - { userName = command.args.userName, userType = command.args.userType } - ) - end + -- a user that does not exist is not created here, so this fails for it + local result_status = tables.update_entry(device, "users", + command.args.userIndex, + { userName = command.args.userName, userType = command.args.userType } + ) local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, result_status, additional_info) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 22e8280cb4..aff0aa55ed 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -125,10 +125,8 @@ end -- Clear every entry of the "users" or "credentials" table except the programming code credential and its user. function lock_utils.delete_all_non_programming_entries(device, table_name) - local programming = tables.get_programming_credential(device) - local match_key = table_name == "users" and "userIndex" or "credentialIndex" tables.delete_all_entries(device, table_name, function(entry) - return programming ~= nil and entry[match_key] == programming[match_key] + return lock_utils.is_programming_user(device, entry.userIndex) end) end diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua.orig b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua.orig new file mode 100644 index 0000000000..8074abdae5 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua.orig @@ -0,0 +1,211 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lifecycle handlers defined in init.lua: +-- added (device_added), infoChanged (info_changed), init (LockLifecycle.init) +-- +-- Removed: doConfigure tests — z-wave drivers have no doConfigure lifecycle event. +-- Removed: init SLGA_MIGRATED without lockCodes test — z-wave init sends no protocol +-- messages; there is no NumberOfPINUsersSupported read on init. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +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 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +-- base-lock profile: lock + lockCodes + lockCredentials + lockUsers + battery +local mock_device_base = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + 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", +}) + +-- 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"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- same profile but 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", +}) + +local function make_test_init(device) + return function() + test.disable_startup_messages() + test.mock_device.add_test_device(device) + end +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" }) + +<<<<<<< HEAD + -- Migrated event emitted for TYPED+lockCodes device +======= +>>>>>>> 1ee62e94 (Set devices to migrated on add) + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) +<<<<<<< HEAD + -- inject_capability_command calls the refresh handler: + -- DoorLock:OperationGet, Battery:Get, UserCode:UsersNumberGet (no cached values) +======= +>>>>>>> 1ee62e94 (Set devices to migrated on add) + 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() + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "added" }) + + -- No migrated capability event expected + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_base.id)) + test.wait_for_events() + + assert( + mock_device_base:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) ~= true, + "SLGA_MIGRATED must NOT be set for a non-TYPED device" + ) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "added: device without lockCodes does NOT emit migrated event but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + + -- No migrated event; lock-battery has no lockCredentials so no UsersNumberGet + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_battery.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_battery.id)) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- init (LockLifecycle.init) +-- ============================================================================ + +test.register_coroutine_test( + "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", + 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" }) + 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: device with lockCodes but SLGA_MIGRATED not set does nothing", + function() + -- SLGA_MIGRATED is not set; no events or z-wave sends expected + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: lock-battery device (no lockCodes) does nothing regardless of SLGA_MIGRATED", + function() + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + -- lock-battery has no lockCodes → if branch is false → no events, no z-wave sends + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.wait_for_events() + end, + { 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_lock_pre_configured.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua index 5e65dcfa27..98663147be 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 @@ -250,22 +250,10 @@ test.register_coroutine_test( { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 10, "Ghost", "guest" } }, }) - 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 = 10, userName = "Ghost", userType = "guest" }, - }, - { visibility = { displayed = false } } - )) - ) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "success", userIndex = 10 }, + { commandName = "updateUser", statusCode = "failure" }, { state_change = true, visibility = { displayed = false } } )) ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 00446f3264..628328c5bc 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -305,44 +305,6 @@ test.register_coroutine_test( } ) -test.register_coroutine_test( - "all user codes deleted at a Yale keeps only the programming code, not a programmingUser in a user slot", - function() - local slot_user = { userIndex = 3, userName = "Slot 3", userType = "programmingUser" } - local slot_credential = { userIndex = 3, credentialIndex = 3, credentialType = "pin" } - seed(yale_mock_device, { slot_user, PROGRAMMING_USER_ENTRY }, { slot_credential, PROGRAMMING_CREDENTIAL }) - test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = access_control_event.ALL_USER_CODES_DELETED, - }) }) - expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) - expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) - test.wait_for_events() - end, - { - test_init = make_test_init(yale_mock_device), - min_api_version = 15 - } -) - -test.register_coroutine_test( - "all user codes deleted at a lock that is not a Yale clears a programmingUser user", - function() - seed(mock_device, { { userIndex = 1, userName = "Owner", userType = "programmingUser" } }, { GUEST_CREDENTIAL }) - test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = access_control_event.ALL_USER_CODES_DELETED, - }) }) - expect_credentials(mock_device, {}) - expect_users(mock_device, {}) - test.wait_for_events() - end, - { - test_init = make_test_init(mock_device), - min_api_version = 15 - } -) - test.register_coroutine_test( "addCredential: a programmingUser credential in a user slot counts toward the credential limit", function() 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 310fae786f..7327fd27ce 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 @@ -371,7 +371,7 @@ test.register_coroutine_test( }) test.register_coroutine_test( - "updateUser: creates user when userIndex does not exist", + "updateUser: fails for a userIndex that does not exist and does not create it", function() -- empty table — nothing to update test.socket.capability:__queue_receive({ @@ -379,20 +379,10 @@ test.register_coroutine_test( { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 99, "Ghost", "guest" } }, }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { - { userIndex = 99, userName = "Ghost", userType = "guest" }, - }, - { visibility = { displayed = false } } - )) - ) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "success", userIndex = 99 }, + { commandName = "updateUser", statusCode = "failure" }, { state_change = true, visibility = { displayed = false } } )) ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua.orig b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua.orig new file mode 100644 index 0000000000..2b4df74dac --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua.orig @@ -0,0 +1,336 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +<<<<<<< HEAD +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, +======= +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" +local lock_utils = require "lock_utils.constants".DRIVER_STATE + +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 }) + +test.disable_startup_messages() + +local SCHLAGE_MANUFACTURER_ID = 0x003B +local SCHLAGE_PRODUCT_TYPE = 0x0002 +local SCHLAGE_PRODUCT_ID = 0x0469 + +-- supported comand classes +local zwave_lock_endpoints = { + { + command_classes = { + {value = zw.BATTERY}, + {value = zw.DOOR_LOCK}, + {value = zw.USER_CODE}, + {value = zw.NOTIFICATION} +>>>>>>> parent of b6e09b30 (Zwave Lock: Remove Migration on Added (#3127)) + } + } +} + +<<<<<<< HEAD +local mock_device = 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, + zwave_product_type = 0x0002, + zwave_product_id = 0x0469, +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Masked occupied USER_CODE_REPORT during code sync advances to the next slot", + 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, {}) + + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "**********", + }), + }) + + 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.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + end, + { min_api_version = 15 } +======= +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 = SCHLAGE_MANUFACTURER_ID, + zwave_product_type = SCHLAGE_PRODUCT_TYPE, + zwave_product_id = SCHLAGE_PRODUCT_ID, + } +) + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +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( + "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" }}, + { 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( + "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 +) + +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 +) + +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 +) + +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 +) + +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 +>>>>>>> parent of b6e09b30 (Zwave Lock: Remove Migration on Added (#3127)) +) + +test.run_registered_tests() From 8aba1191085aac6d1ad38782319aa933b5d3b509 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 13 Nov 2026 16:58:53 -0600 Subject: [PATCH 16/16] Z-Wave Lock: Do not count programming users toward the entry limit by index A programming user, and the credential it owns, is outside the lock's user slots on Z-Wave, so is_full excludes entries owned by a programmingUser by user type instead of looking for a credential above the slot count. Remove get_programming_credential and test the limit with and without a programming code. Co-Authored-By: Claude Sonnet 5.5 --- .../zwave-lock/src/lock_utils/tables.lua | 29 +++--------- .../zwave-lock/src/lock_utils/utils.lua | 2 - .../src/test/test_lock_programming_code.lua | 44 ++++++++++++++----- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua index f59c465bfc..a482ea46a9 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -157,33 +157,18 @@ function table_utils.get_max_entries(device, table_name) return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing end --- Returns the programming code credential, or nil if there is none. A lock may keep a programming code in a credential --- outside its user slots, owned by a user with the programmingUser userType (consts.PROGRAMMING_USER.USER_TYPE); where that --- credential lives is manufacturer specific and handled by the sub-driver for the lock. A user given the programmingUser --- userType in one of the lock's user slots is an ordinary user. -function table_utils.get_programming_credential(device) - local user_slots = table_utils.get_max_entries(device, "credentials") - local users = table_utils.get_state(device, "users") or {} - for _, credential in ipairs(table_utils.get_state(device, "credentials") or {}) do - if credential.credentialIndex > user_slots then - for _, user in ipairs(users) do - if user.userIndex == credential.userIndex and user.userType == consts.PROGRAMMING_USER.USER_TYPE then - return credential - end - end - end - end -end - --- Return true if the named table has reached its max_entries limit. The programming code credential, and the --- user that owns it, are not one of the lock's user slots, so they do not count toward the limit. +-- Return true if the named table has reached its max_entries limit. A programming user, and the credential it owns, +-- is not one of the lock's user slots, so it does not count toward the limit. function table_utils.is_full(device, table_name) local def = resolve_table_def(device, table_name) if not def then return true end - local programming = table_utils.get_programming_credential(device) + local programming_users = {} + for _, user in ipairs(table_utils.get_state(device, "users") or {}) do + if user.userType == consts.PROGRAMMING_USER.USER_TYPE then programming_users[user.userIndex] = true end + end local count = 0 for _, entry in ipairs(table_utils.get_state(device, def) or {}) do - if not (programming and entry.userIndex == programming.userIndex) then count = count + 1 end + if not programming_users[entry.userIndex] then count = count + 1 end end return count >= table_utils.get_max_entries(device, table_name) end diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index aff0aa55ed..2643cc85e7 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -114,8 +114,6 @@ end -- [[ PROGRAMMING CODE MANAGEMENT ]] -- --- --- The programming code credential is found with tables.get_programming_credential. -- Whether the user at user_index is a programming user. function lock_utils.is_programming_user(device, user_index) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua index 628328c5bc..32529699e3 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -305,22 +305,46 @@ test.register_coroutine_test( } ) +-- the default limit is 20 when the lock has not reported one +local function seed_guests_and_programming_code(device, guest_count) + local users, credentials = { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL } + for i = 1, guest_count do + table.insert(users, { userIndex = i, userName = "User " .. i, userType = "guest" }) + table.insert(credentials, { userIndex = i, credentialIndex = i, credentialType = "pin" }) + end + seed(device, users, credentials) +end + test.register_coroutine_test( - "addCredential: a programmingUser credential in a user slot counts toward the credential limit", + "addCredential: the programming code does not count toward the credential limit", function() - local users, credentials = {}, {} - for i = 1, 20 do - table.insert(users, { userIndex = i, userName = "User " .. i, userType = i == 1 and "programmingUser" or "guest" }) - table.insert(credentials, { userIndex = i, credentialIndex = i, credentialType = "pin" }) - end - seed(mock_device, users, credentials) - test.socket.capability:__queue_receive({ mock_device.id, + seed_guests_and_programming_code(yale_mock_device, 19) + test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) - expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "resourceExhausted" }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 20, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) test.wait_for_events() end, { - test_init = make_test_init(mock_device), + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: a full lock is still full with a programming code", + function() + seed_guests_and_programming_code(yale_mock_device, 20) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "resourceExhausted" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), min_api_version = 15 } )