From 93352dbecd07d2ac5ee56e03df5ddbf915dc1447 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Tue, 27 Oct 2026 10:32:47 -0500 Subject: [PATCH 01/12] Add support for admin code for Yale Z-Wave lock --- .../zwave-lock/src/legacy-handlers/init.lua | 21 ++++- .../src/lock_handlers/capabilities.lua | 91 +++++++++++++++---- .../src/lock_handlers/zwave_responses.lua | 43 +++++---- .../zwave-lock/src/lock_utils/constants.lua | 9 ++ .../zwave-lock/src/lock_utils/tables.lua | 52 +++++++++-- .../zwave-lock/src/lock_utils/utils.lua | 52 ++++++++++- .../test/test_lock_code_slga_migration.lua | 42 +++++++++ 7 files changed, 261 insertions(+), 49 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 65cd6cca4b..95ac9248b2 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -55,6 +55,7 @@ end local function migrate(driver, device, cmd) local LockCodesDefaults = require "st.zwave.defaults.lockCodes" local table_utils = require "lock_utils.tables" + local consts = require("lock_utils.constants") local get_lock_codes = LockCodesDefaults.get_lock_codes local lock_users = {} local lock_credentials = {} @@ -74,10 +75,23 @@ local function migrate(driver, device, cmd) end table.sort(ordered_codes, function(a, b) return a.index < b.index end) + local master_index = table_utils.master_credential_index(device) + -- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the master code's + -- user takes the master code slot as its index. + local user_index = 0 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"}) + local is_master = code.index == master_index + if not is_master then + user_index = user_index + 1 + if user_index == master_index then user_index = user_index + 1 end + end + local owner_index = is_master and code.index or user_index + table.insert(lock_users, { + userIndex = owner_index, + userType = is_master 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 } })) @@ -99,7 +113,6 @@ local function migrate(driver, device, cmd) device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - local consts = require("lock_utils.constants") device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore end diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 7ebd38d191..7b3bcdcd2d 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -67,6 +67,13 @@ function CapabilityHandlers.delete_user(driver, device, command) end local associated_credentials = tables.find_all_entries_by(device, "credentials", "userIndex", command.args.userIndex) + for _, associated_credential in ipairs(associated_credentials) do + if lock_utils.is_master_credential(device, associated_credential.credentialIndex) then + -- this user holds the master code, which cannot be removed + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + end for _, associated_credential in ipairs(associated_credentials) do -- Set busy state with the full user+credential context BEFORE injecting. -- Injected capability commands are schema-validated, so extra args like userIndex @@ -121,7 +128,15 @@ function CapabilityHandlers.add_credential(driver, device, command) local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex - if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + -- The master code is added with its own slot as the userIndex and the master code userType. + local is_master = lock_utils.is_master_credential(device, cred_index) + if is_master and command.args.userType ~= consts.ADMIN.USER_TYPE then + -- only a master code credential may be written to the master code slot + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + + if not is_master and tables.is_full(device, "credentials") then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) return end @@ -152,24 +167,42 @@ function CapabilityHandlers.update_credential(driver, device, command) return end - if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + local credential_index = command.args.credentialIndex + if not tables.find_entry(device, "credentials", credential_index) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) return + elseif credential_index == 0 then + -- user identifier 0 addresses every user code on the lock, never a single credential + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return end lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { userIndex = command.args.userIndex, - credentialIndex = command.args.credentialIndex, + credentialIndex = credential_index, credentialType = command.args.credentialType, credentialName = command.args.credentialName, }) device:send(UserCode:Set({ - user_identifier = command.args.credentialIndex, + user_identifier = credential_index, user_code = command.args.credentialData, user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS })) 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 + })) + end +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 @@ -182,10 +215,7 @@ function CapabilityHandlers.delete_credential(driver, device, command) if cmd_in_progress == consts.LOCK_USERS.DELETE then -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} - 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 lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) else @@ -194,16 +224,17 @@ function CapabilityHandlers.delete_credential(driver, device, command) if not found_cred then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) return + elseif lock_utils.is_master_credential(device, command.args.credentialIndex) then + -- the master code cannot be removed + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) + return end lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { credentialIndex = command.args.credentialIndex, 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 @@ -211,18 +242,38 @@ 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 - device:send(UserCode:Set({ - user_identifier = 0, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + local delay_total = 0 + if tables.master_credential_index(device) == nil then + -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot + device:send(UserCode:Set({ + user_identifier = 0, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + else + -- User Identifier 0 would also clear the master code, so clear each non-master slot individually. + local credentials = tables.get_state(device, "credentials") or {} + local delay = 0 + for _, credential in ipairs(credentials) do + if not lock_utils.is_master_credential(device, credential.credentialIndex) and + credential.credentialIndex ~= 0 then + device.thread:call_with_delay(delay, function() + device:send(UserCode:Set({ + user_identifier = credential.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + end) + delay = delay + 2 + end + end + delay_total = delay + end -- Delayed cleanup: ensure all tables are cleared after the lock has processed the command - device.thread:call_with_delay(4, function() + device.thread:call_with_delay(delay_total + 4, function() if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then - tables.delete_all_entries(device, "users") + lock_utils.delete_all_non_master_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_master_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 7822ab2bfe..8cdc51258d 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -37,18 +37,23 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) if command_in_progress == consts.SYNC.CODES_FROM_LOCK then if user_code_occupied then - -- If an entry already exists at this index, this will be a no-op. - tables.add_entry(device, "users", { - userIndex = credential_index, - userName = "Guest " .. credential_index, - userType = "guest", - }) - tables.add_entry(device, "credentials", { - userIndex = credential_index, - credentialIndex = credential_index, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "Guest " .. credential_index, - }) + -- If a credential already exists at this index, this will be a no-op. + if not tables.find_entry(device, "credentials", credential_index) then + -- Prefer the matching userIndex, but never attach this credential to a different, existing user. + local user_index = tables.find_entry(device, "users", credential_index) and + tables.next_index(device, "users") or credential_index + tables.add_entry(device, "users", { + userIndex = user_index, + userName = "Guest " .. user_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = user_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. user_index, + }) + end -- reset the consecutive unoccupied codes counter since we found an occupied code device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) elseif user_code_available then @@ -108,8 +113,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 master code. + lock_utils.delete_all_non_master_entries(device, "credentials") + lock_utils.delete_all_non_master_entries(device, "users") elseif event == access_control_event.SINGLE_USER_CODE_DELETED then lock_utils.delete_credential_report_helper(device, credential_index) @@ -118,8 +124,13 @@ 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 - if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + -- aka "master code" changed. Locks that keep it in a user code slot (Yale) send this instead of + -- NEW_USER_CODE_ADDED when the slot is written, so it completes the command in progress. + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and + lock_utils.is_master_credential(device, credential_args.credentialIndex) then + lock_utils.set_credential_report_helper(device, credential_args.credentialIndex) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.SUCCESS) lock_utils.clear_busy_state(device) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index f66c452beb..a4225ea194 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -16,6 +16,15 @@ lock_constants.SYNC = { CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", } +lock_constants.ADMIN = { + -- Yale locks keep their master code in this user code slot. + YALE_SLOT = 251, + YALE_MANUFACTURER_ID = 0x0129, + -- Stopgap userType for the master code until the lockUsers capability has one for it. + USER_TYPE = "controlOnlyMember", + 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..48af741f12 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 = {} @@ -41,6 +42,14 @@ local DEFS = { } } +-- Returns the credentialIndex of the device's master code slot, or nil if the device does not have one. +-- Only Yale locks have one. +function table_utils.master_credential_index(device) + if device.zwave_manufacturer_id == consts.ADMIN.YALE_MANUFACTURER_ID then + return consts.ADMIN.YALE_SLOT + end +end + -- Resolve a table name to its definition. Logs an error and returns nil if unknown. local function resolve_table_def(device, table_name) local def = DEFS[table_name] @@ -156,11 +165,31 @@ 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 +-- Return true if the named table has reached its max_entries limit. The master 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 master_index = table_utils.master_credential_index(device) + local master_cred = master_index and table_utils.find_entry(device, "credentials", master_index) + local count = 0 + for _, entry in ipairs(table_utils.get_state(device, def) or {}) do + local is_master + if table_name == "credentials" then + is_master = master_index ~= nil and entry.credentialIndex == master_index + else + is_master = master_cred ~= nil and entry.userIndex == master_cred.userIndex + end + if not is_master 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 master 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 +197,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 +276,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 64851e485a..225950b719 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -50,6 +50,49 @@ function lock_utils.sync_device_state(device) end +-- [[ MASTER CODE MANAGEMENT ]] -- +-- +-- Yale locks keep their master code in a user code slot (see consts.ADMIN.YALE_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. + +-- True if the credential at credential_index is the lock's master code. +function lock_utils.is_master_credential(device, credential_index) + return credential_index ~= nil and credential_index == tables.master_credential_index(device) +end + +function lock_utils.get_master_credential(device) + local master_index = tables.master_credential_index(device) + return master_index ~= nil and tables.find_entry(device, "credentials", master_index) or nil +end + +-- Add the master code credential and the user that owns it. Both use the lock's master code slot as their +-- index, since the user index only has meaning inside the capability state. +function lock_utils.add_master_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 + +-- Clear every entry of the "users" or "credentials" table except the master code credential and its user. +function lock_utils.delete_all_non_master_entries(device, table_name) + local master = lock_utils.get_master_credential(device) + local match_key = table_name == "users" and "userIndex" or "credentialIndex" + tables.delete_all_entries(device, table_name, function(entry) + return master ~= nil and entry[match_key] == master[match_key] + end) +end + + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) @@ -79,7 +122,11 @@ function lock_utils.set_credential_report_helper(device, credential_index) local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} local result_status - if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and + lock_utils.is_master_credential(device, credential_args.credentialIndex) then + result_status = lock_utils.add_master_entries(device, + credential_args.credentialIndex, credential_args.credentialName) + elseif command_in_progress == consts.LOCK_CREDENTIALS.ADD then result_status = tables.add_entry(device, "credentials", { userIndex = credential_args.userIndex, credentialIndex = credential_args.credentialIndex, @@ -88,6 +135,9 @@ function lock_utils.set_credential_report_helper(device, credential_index) }) elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then result_status = consts.COMMAND_RESULT.SUCCESS + elseif lock_utils.is_master_credential(device, credential_index) then + -- master code reported without a command in progress + lock_utils.add_master_entries(device, credential_index) elseif not tables.find_entry(device, "credentials", credential_index) then -- credential does not exist and no add/update command in progress, therefore it was added out-of-band -- check what user slot we have to associate this with, and add a default user diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua index a486f943e8..db8d619deb 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 @@ -45,6 +45,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) @@ -59,6 +69,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) @@ -157,4 +168,35 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Migrate drops the legacy slot 0 Master Code and orders slots numerically", + function() + init_code_slot(0, "Master Code", mock_device) + init_code_slot(10, "Ten", mock_device) + init_code_slot(2, "Two", mock_device) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=2, credentialType="pin", userIndex=1}, {credentialIndex=10, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Two", userType="guest"}, {userIndex=2, userName="Ten", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Migrate maps the Yale master code slot 251 to a controlOnlyMember 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="controlOnlyMember"}}, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + 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.wait_for_events() + end +) + test.run_registered_tests() \ No newline at end of file From c672102d7ce97979dd91cdc2aa228aafc2153944 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 30 Oct 2026 15:11:50 -0500 Subject: [PATCH 02/12] Add Yale Z-Wave master code tests and drop redundant migration test The slot 0 / numeric ordering migration test is now covered by the upstream tests added in #3313. Co-Authored-By: Claude Opus 5.5 --- .../test/test_lock_code_slga_migration.lua | 16 - .../src/test/test_lock_master_code.lua | 309 ++++++++++++++++++ 2 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua 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 db8d619deb..e714f2e11b 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 @@ -168,22 +168,6 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "Migrate drops the legacy slot 0 Master Code and orders slots numerically", - function() - init_code_slot(0, "Master Code", mock_device) - init_code_slot(10, "Ten", mock_device) - init_code_slot(2, "Two", mock_device) - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=2, credentialType="pin", userIndex=1}, {credentialIndex=10, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Two", userType="guest"}, {userIndex=2, userName="Ten", userType="guest"}}, { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) - test.wait_for_events() - end -) - test.register_coroutine_test( "Migrate maps the Yale master code slot 251 to a controlOnlyMember user with userIndex 251", function() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua new file mode 100644 index 0000000000..79a8b7f05c --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua @@ -0,0 +1,309 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the Yale master 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 controlOnlyMember user whose index matches the slot, and it is protected from +-- deletion and from delete-all. 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 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 = constants.ADMIN.YALE_MANUFACTURER_ID, + 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_master_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 MASTER_USER = { userIndex = 251, userName = "Master Code", userType = "controlOnlyMember" } +local MASTER_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 master code +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: Yale master 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, "controlOnlyMember", "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_master_code_changed(yale_mock_device) + expect_users(yale_mock_device, { MASTER_USER }) + expect_credentials(yale_mock_device, { MASTER_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 master 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: slot 251 is an ordinary slot on a lock that is not a Yale", + function() + 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 = 251, + 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 master 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, "controlOnlyMember", "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_master_code_changed(yale_mock_device) + local expected_users, expected_credentials = guests() + table.insert(expected_users, MASTER_USER) + table.insert(expected_credentials, MASTER_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 master code is written to slot 251 and succeeds when the lock reports it changed", + function() + seed(yale_mock_device, { MASTER_USER }, { MASTER_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_master_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( + "the User Code v2 Master Code Report that follows a master code change is ignored", + function() + seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + queue_master_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) } +) + +-- ============================================================================ +-- protection of the master code +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: the Yale master code in slot 251 is rejected", + function() + seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + 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 = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteUser: the user holding the Yale master code is rejected", + function() + seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 251 } } }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult({ commandName = "deleteUser", statusCode = "invalidCommand" }, + { state_change = true, visibility = { displayed = false } }))) + 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 master code", + function() + seed(yale_mock_device, { MASTER_USER, GUEST_USER }, { MASTER_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, { MASTER_CREDENTIAL }) + expect_users(yale_mock_device, { MASTER_USER }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_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: a stale credentialIndex 0 is removed locally 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_credentials(mock_device, {}) + 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.run_registered_tests() From 975d1c29578e969b60ba8177a35620be23b9865a Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Fri, 30 Oct 2026 15:28:27 -0500 Subject: [PATCH 03/12] Let the lock decide whether a credential can be deleted Remove the driver-side block on deleting the Yale master code. As in the legacy lockCodes deleteCode, an unconfirmed delete is read back with a User Code Get after 4.2 seconds, and a slot that is still occupied is reported as a failure instead of leaving the command to time out. Co-Authored-By: Claude Opus 5.5 --- .../src/lock_handlers/capabilities.lua | 21 ++-- .../src/lock_handlers/zwave_responses.lua | 3 + .../zwave-lock/src/lock_utils/utils.lua | 19 ++++ .../src/test/test_lock_master_code.lua | 107 ++++++++++++++++-- 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 7b3bcdcd2d..d259f743b0 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -14,6 +14,9 @@ local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +-- Seconds to wait for a delete to be confirmed before reading the slot back +local DELETE_CONFIRM_DELAY = 4.2 + local CapabilityHandlers = {} @@ -67,13 +70,6 @@ function CapabilityHandlers.delete_user(driver, device, command) end local associated_credentials = tables.find_all_entries_by(device, "credentials", "userIndex", command.args.userIndex) - for _, associated_credential in ipairs(associated_credentials) do - if lock_utils.is_master_credential(device, associated_credential.credentialIndex) then - -- this user holds the master code, which cannot be removed - lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) - return - end - end for _, associated_credential in ipairs(associated_credentials) do -- Set busy state with the full user+credential context BEFORE injecting. -- Injected capability commands are schema-validated, so extra args like userIndex @@ -200,6 +196,13 @@ 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 master 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. + device.thread:call_with_delay(DELETE_CONFIRM_DELAY, function() + if lock_utils.is_delete_in_progress(device, credential_index) then + device:send(UserCode:Get({ user_identifier = credential_index })) + end + end) end end @@ -224,10 +227,6 @@ function CapabilityHandlers.delete_credential(driver, device, command) if not found_cred then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) return - elseif lock_utils.is_master_credential(device, command.args.credentialIndex) then - -- the master code cannot be removed - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND) - return end lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { credentialIndex = command.args.credentialIndex, diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua index 8cdc51258d..0e69cf204a 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -74,6 +74,9 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) device:send(UserCode:Get({user_identifier = synced_code_index})) end return + 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/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 225950b719..a04374104a 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) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua index 79a8b7f05c..f0b70a1a66 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua @@ -3,8 +3,9 @@ -- -- Integration tests for the Yale master 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 controlOnlyMember user whose index matches the slot, and it is protected from --- deletion and from delete-all. User identifier 0 (all users) is never sent for a single credential. +-- it is exposed as a controlOnlyMember 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" @@ -230,30 +231,118 @@ test.register_coroutine_test( ) -- ============================================================================ --- protection of the master code +-- deleting the master code: the lock decides, and the driver reports what it did -- ============================================================================ +local DELETE_CONFIRM_DELAY = 4.2 + +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_master_credential(device) + test.socket.capability:__queue_receive({ device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 251, "pin" } } }) +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 master code in slot 251 is rejected", + "deleteCredential: the Yale master code is deleted when the lock confirms it", function() seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) - 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 = "invalidCommand" }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_master_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( - "deleteUser: the user holding the Yale master code is rejected", + "deleteCredential: an unconfirmed delete is read back and succeeds when the slot is empty", function() seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_master_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 master code is kept", + function() + seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_master_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( + "deleteUser: a delete of the master code user the lock refuses fails for both capabilities", + function() + seed(yale_mock_device, { MASTER_USER }, { MASTER_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 = "invalidCommand" }, + 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) } From 6c5042dc6e1b72485341406a58583ef5fc742ac2 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Mon, 2 Nov 2026 15:35:50 -0600 Subject: [PATCH 04/12] Read the Yale Z-Wave master code on migration and after sync Legacy lockCodes never stored the master code for locks that confirm it with the "master code changed" notification, nor for a factory master code. Read it back at the end of migration and of the on-join sync, using both the slot read and the User Code v2 Admin Code read, and record it as the master user and credential. Restore the on-join code sync for migrated devices. Co-Authored-By: Claude Sonnet 5.5 --- drivers/SmartThings/zwave-lock/src/init.lua | 1 + .../zwave-lock/src/legacy-handlers/init.lua | 10 +++ .../src/lock_handlers/zwave_responses.lua | 15 +++++ .../zwave-lock/src/lock_utils/constants.lua | 1 + .../zwave-lock/src/lock_utils/utils.lua | 13 ++++ .../src/test/test_init_lifecycle_handlers.lua | 21 ++++++ .../test/test_lock_code_slga_migration.lua | 64 +++++++++++++++++++ .../src/test/test_zwave_responses.lua | 54 ++++++++++++++++ 8 files changed, 179 insertions(+) diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index 6c85639625..c2a183f3af 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -86,6 +86,7 @@ local driver_template = { [cc.USER_CODE] = { [UserCode.REPORT] = zwave_handlers.user_code_report, [UserCode.USERS_NUMBER_REPORT] = zwave_handlers.users_number_report, + [UserCode.MASTER_CODE_REPORT] = zwave_handlers.master_code_report, } }, capability_handlers = { diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 95ac9248b2..24105435c1 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -115,6 +115,16 @@ local function migrate(driver, device, cmd) 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 + + -- lockCodes only holds the master code if SmartThings set it and the lock confirmed it as a user code. A factory + -- master code, or one a Yale ZW3 confirmed with the "master code changed" notification, is read back instead. + local master_migrated = false + for _, code in ipairs(ordered_codes) do + if code.index == master_index then master_migrated = true end + end + if not master_migrated then + require("lock_utils.utils").read_master_code(device) + end end local legacy_capabilities = { 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 0e69cf204a..6a5afb037b 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -67,6 +67,8 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. device:set_field(consts.SYNC.CODE_INDEX, nil) lock_utils.clear_busy_state(device) + -- the master code slot is outside the lock's user code slots, so it is read separately + lock_utils.read_master_code(device) else local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) @@ -91,6 +93,19 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) end end +-- Only the Admin Code read sent at migration is handled. The unrequested report that follows every master +-- code change is ignored, since the notification for the change already updated the master code entries. +function ZwaveHandlers.master_code_report(driver, device, cmd) + if not device:get_field(consts.DRIVER_STATE.MASTER_CODE_READ) then return end + device:set_field(consts.DRIVER_STATE.MASTER_CODE_READ, nil) + + local master_index = tables.master_credential_index(device) + local master_code = cmd.args.master_code or "" + if master_index ~= nil and master_code ~= "" and not tables.find_entry(device, "credentials", master_index) then + lock_utils.add_master_entries(device, master_index) + end +end + function ZwaveHandlers.users_number_report(driver, device, cmd) device:emit_event(capabilities.lockUsers.totalUsersSupported( cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index a4225ea194..58b4b79fba 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -8,6 +8,7 @@ lock_constants.DRIVER_STATE = { COMMAND_IN_PROGRESS = "commandInProgress", CREDENTIAL_ARGS_IN_USE = "currentCredential", SLGA_MIGRATED = "slgaMigrated", + MASTER_CODE_READ = "masterCodeRead", } lock_constants.SYNC = { diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index a04374104a..8f9f59a5e9 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -102,6 +102,19 @@ function lock_utils.add_master_entries(device, credential_index, name) }, true) end +-- Read the master code back from the lock, so it is recorded even if it was set outside SmartThings. ZW2 modules +-- answer the read of the master code slot and ZW3 modules answer the User Code v2 Admin Code read; a lock ignores +-- the one it does not support. The slot is read first, so its report cannot remove an entry the Admin Code adds. +function lock_utils.read_master_code(device) + local master_index = tables.master_credential_index(device) + if master_index == nil then return end + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) + device:send(UserCode:Get({ user_identifier = master_index })) + device:set_field(consts.DRIVER_STATE.MASTER_CODE_READ, true) + device:send(UserCodeV2:MasterCodeGet({})) +end + -- Clear every entry of the "users" or "credentials" table except the master code credential and its user. function lock_utils.delete_all_non_master_entries(device, table_name) local master = lock_utils.get_master_credential(device) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua index ac127f234a..909adde2fe 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -87,6 +87,27 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device_typed) } ) +test.register_coroutine_test( + "added: TYPED device syncs the codes already on the lock 5 seconds after joining", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + 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() + + test.mock_time.advance_time(5) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device_typed.id)) + test.wait_for_events() + 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() 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 e714f2e11b..3eb1e4e57d 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -7,6 +7,7 @@ local capabilities = require "st.capabilities" local zw = require "st.zwave" --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) local t_utils = require "integration_test.utils" --- @type st.zwave.constants @@ -183,4 +184,67 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Migrate reads the Yale master code when lockCodes has none, and adds it at slot 251 from the Admin Code report", + 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.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + 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(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_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="controlOnlyMember"}}, { 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.register_coroutine_test( + "Migrate adds no Yale master code when the Admin Code read reports no code", + 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.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + 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(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + -- an empty Admin Code means the lock has no master code set + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "" }) }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Migrate adds the Yale master 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.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + 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(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):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="controlOnlyMember"}}, { 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 diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua index 24db26a3ba..91a75a56c3 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -32,6 +32,8 @@ local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ 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 UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) local zwave_lock_endpoints = { { @@ -49,6 +51,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 = constants.ADMIN.YALE_MANUFACTURER_ID, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000, +}) + local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) @@ -148,6 +158,50 @@ test.register_coroutine_test( min_api_version = 15 }) +test.register_coroutine_test( + "user_code_report: sync on a Yale reads the master code slot and Admin Code 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.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + -- a ZW2 module answers the slot read; the master 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 = "controlOnlyMember" } }, + { 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() + + -- the Admin Code report that follows finds the entry already there and adds nothing + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "199111" }) }) + 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() From 7a2c6f7f6468099fbe050d68705fac9768bbd7a5 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Mon, 2 Nov 2026 16:04:47 -0600 Subject: [PATCH 05/12] Read only the master code slot, drop the Admin Code read Legacy never used User Code v2 and worked for Yale ZW2 and ZW3 locks, so the v2 Admin Code read is not needed. Migration and the end of the on-join sync send a single Get of the master code slot, and its report is handled like any other user code report. Co-Authored-By: Claude Sonnet 5.5 --- drivers/SmartThings/zwave-lock/src/init.lua | 1 - .../src/lock_handlers/zwave_responses.lua | 13 ------ .../zwave-lock/src/lock_utils/constants.lua | 1 - .../zwave-lock/src/lock_utils/utils.lua | 8 +--- .../test/test_lock_code_slga_migration.lua | 43 ------------------- .../src/test/test_zwave_responses.lua | 8 +--- 6 files changed, 3 insertions(+), 71 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index c2a183f3af..6c85639625 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -86,7 +86,6 @@ local driver_template = { [cc.USER_CODE] = { [UserCode.REPORT] = zwave_handlers.user_code_report, [UserCode.USERS_NUMBER_REPORT] = zwave_handlers.users_number_report, - [UserCode.MASTER_CODE_REPORT] = zwave_handlers.master_code_report, } }, capability_handlers = { 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 6a5afb037b..df82193cdb 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -93,19 +93,6 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) end end --- Only the Admin Code read sent at migration is handled. The unrequested report that follows every master --- code change is ignored, since the notification for the change already updated the master code entries. -function ZwaveHandlers.master_code_report(driver, device, cmd) - if not device:get_field(consts.DRIVER_STATE.MASTER_CODE_READ) then return end - device:set_field(consts.DRIVER_STATE.MASTER_CODE_READ, nil) - - local master_index = tables.master_credential_index(device) - local master_code = cmd.args.master_code or "" - if master_index ~= nil and master_code ~= "" and not tables.find_entry(device, "credentials", master_index) then - lock_utils.add_master_entries(device, master_index) - end -end - function ZwaveHandlers.users_number_report(driver, device, cmd) device:emit_event(capabilities.lockUsers.totalUsersSupported( cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index 58b4b79fba..a4225ea194 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -8,7 +8,6 @@ lock_constants.DRIVER_STATE = { COMMAND_IN_PROGRESS = "commandInProgress", CREDENTIAL_ARGS_IN_USE = "currentCredential", SLGA_MIGRATED = "slgaMigrated", - MASTER_CODE_READ = "masterCodeRead", } lock_constants.SYNC = { diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 8f9f59a5e9..4040d160fd 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -102,17 +102,13 @@ function lock_utils.add_master_entries(device, credential_index, name) }, true) end --- Read the master code back from the lock, so it is recorded even if it was set outside SmartThings. ZW2 modules --- answer the read of the master code slot and ZW3 modules answer the User Code v2 Admin Code read; a lock ignores --- the one it does not support. The slot is read first, so its report cannot remove an entry the Admin Code adds. +-- Read the master code slot back from the lock, so the master code is recorded even if it was set outside +-- SmartThings. The report is handled like any other user code report. function lock_utils.read_master_code(device) local master_index = tables.master_credential_index(device) if master_index == nil then return end local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) - local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) device:send(UserCode:Get({ user_identifier = master_index })) - device:set_field(consts.DRIVER_STATE.MASTER_CODE_READ, true) - device:send(UserCodeV2:MasterCodeGet({})) end -- Clear every entry of the "users" or "credentials" table except the master code credential and its user. 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 3eb1e4e57d..3ee79765b5 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -7,7 +7,6 @@ local capabilities = require "st.capabilities" local zw = require "st.zwave" --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) -local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) local t_utils = require "integration_test.utils" --- @type st.zwave.constants @@ -184,47 +183,6 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "Migrate reads the Yale master code when lockCodes has none, and adds it at slot 251 from the Admin Code report", - 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.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) - 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(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) - test.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):build_test_tx(yale_mock_device.id)) - test.wait_for_events() - - test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_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="controlOnlyMember"}}, { 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.register_coroutine_test( - "Migrate adds no Yale master code when the Admin Code read reports no code", - 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.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) - 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(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) - test.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):build_test_tx(yale_mock_device.id)) - test.wait_for_events() - - -- an empty Admin Code means the lock has no master code set - test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "" }) }) - test.wait_for_events() - end -) - test.register_coroutine_test( "Migrate adds the Yale master code at slot 251 when the lock answers the slot read", function() @@ -236,7 +194,6 @@ test.register_coroutine_test( 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(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) - test.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):build_test_tx(yale_mock_device.id)) test.wait_for_events() test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ 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 91a75a56c3..5944e17d66 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -33,7 +33,6 @@ 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 UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) local zwave_lock_endpoints = { { @@ -159,7 +158,7 @@ test.register_coroutine_test( }) test.register_coroutine_test( - "user_code_report: sync on a Yale reads the master code slot and Admin Code when it completes", + "user_code_report: sync on a Yale reads the master 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(), {}) @@ -171,7 +170,6 @@ test.register_coroutine_test( 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.socket.zwave:__expect_send(UserCodeV2:MasterCodeGet({}):build_test_tx(yale_mock_device.id)) test.wait_for_events() -- a ZW2 module answers the slot read; the master code is added at 251 @@ -188,10 +186,6 @@ test.register_coroutine_test( { { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } }, { visibility = { displayed = false } }))) test.wait_for_events() - - -- the Admin Code report that follows finds the entry already there and adds nothing - test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "199111" }) }) - test.wait_for_events() end, { test_init = function() From ecdb02753919049ea18765ccf6d97a6d78751d8b Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Tue, 3 Nov 2026 13:05:47 -0600 Subject: [PATCH 06/12] Use the wildcard clear for delete-all on Yale locks Delete-all sends user identifier 0 for every lock, as on main, and assumes Yale leaves the master code slot alone. The master entries are still kept in lockUsers and lockCredentials. The deleteCredential index 0 test now expects the invalidCommand result main returns. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lock_handlers/capabilities.lua | 33 ++++--------------- .../src/test/test_lock_master_code.lua | 27 ++++++++++++--- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index d259f743b0..9c8d0f4699 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -241,33 +241,14 @@ 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() - local delay_total = 0 - if tables.master_credential_index(device) == nil then - -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot - device:send(UserCode:Set({ - user_identifier = 0, - user_id_status = UserCode.user_id_status.AVAILABLE - })) - else - -- User Identifier 0 would also clear the master code, so clear each non-master slot individually. - local credentials = tables.get_state(device, "credentials") or {} - local delay = 0 - for _, credential in ipairs(credentials) do - if not lock_utils.is_master_credential(device, credential.credentialIndex) and - credential.credentialIndex ~= 0 then - device.thread:call_with_delay(delay, function() - device:send(UserCode:Set({ - user_identifier = credential.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) - end) - delay = delay + 2 - end - end - delay_total = delay - end + -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with a + -- master code (Yale) keeps it, so the master entries are kept in state below. + device:send(UserCode:Set({ + user_identifier = 0, + user_id_status = UserCode.user_id_status.AVAILABLE + })) -- Delayed cleanup: ensure all tables are cleared after the lock has processed the command - device.thread:call_with_delay(delay_total + 4, function() + device.thread:call_with_delay(4, function() if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then lock_utils.delete_all_non_master_entries(device, "users") lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua index f0b70a1a66..16096abcee 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua @@ -381,18 +381,37 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteCredential: a stale credentialIndex 0 is removed locally without sending user identifier 0", + "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_credentials(mock_device, {}) - expect_credential_result(mock_device, - { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 0 }) + 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( + "deleteAllCredentials: sends a single user identifier 0 clear and keeps the master code in state", + function() + seed(yale_mock_device, { MASTER_USER, GUEST_USER }, { MASTER_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, { MASTER_CREDENTIAL }) + expect_credential_result(yale_mock_device, { commandName = "deleteAllCredentials", statusCode = "success" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + test.run_registered_tests() From 500b881bc23fab5d6baa3d99dba869b426c782fd Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Wed, 4 Nov 2026 10:25:13 -0600 Subject: [PATCH 07/12] Move admin code handling for Yale to a sub driver --- .../src/lock_handlers/capabilities.lua | 10 +- .../src/lock_handlers/zwave_responses.lua | 11 +- .../zwave-lock/src/lock_utils/utils.lua | 35 +--- .../zwave-lock/src/sub_drivers.lua | 1 + .../src/test/test_lock_master_code.lua | 79 +++++++++ .../zwave-lock/src/yale-lock/can_handle.lua | 12 ++ .../zwave-lock/src/yale-lock/init.lua | 154 ++++++++++++++++++ 7 files changed, 252 insertions(+), 50 deletions(-) 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/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 9c8d0f4699..10d0867299 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -124,15 +124,7 @@ function CapabilityHandlers.add_credential(driver, device, command) local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex - -- The master code is added with its own slot as the userIndex and the master code userType. - local is_master = lock_utils.is_master_credential(device, cred_index) - if is_master and command.args.userType ~= consts.ADMIN.USER_TYPE then - -- only a master code credential may be written to the master code slot - lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) - return - end - - if not is_master and tables.is_full(device, "credentials") then + if tables.is_full(device, "credentials") then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) return 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 df82193cdb..2afcd1f449 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -67,8 +67,6 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. device:set_field(consts.SYNC.CODE_INDEX, nil) lock_utils.clear_busy_state(device) - -- the master code slot is outside the lock's user code slots, so it is read separately - lock_utils.read_master_code(device) else local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) @@ -129,13 +127,8 @@ 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. Locks that keep it in a user code slot (Yale) send this instead of - -- NEW_USER_CODE_ADDED when the slot is written, so it completes the command in progress. - local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} - if command_in_progress == consts.LOCK_CREDENTIALS.ADD and - lock_utils.is_master_credential(device, credential_args.credentialIndex) then - lock_utils.set_credential_report_helper(device, credential_args.credentialIndex) - elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + -- aka "master 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) lock_utils.clear_busy_state(device) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 4040d160fd..432402f6ec 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -72,36 +72,14 @@ end -- [[ MASTER CODE MANAGEMENT ]] -- -- -- Yale locks keep their master code in a user code slot (see consts.ADMIN.YALE_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. - --- True if the credential at credential_index is the lock's master code. -function lock_utils.is_master_credential(device, credential_index) - return credential_index ~= nil and credential_index == tables.master_credential_index(device) -end +-- lock's user slots. It is exposed like any other credential, owned by a user with the same index. The +-- runtime handling lives in the yale-lock sub-driver; these helpers are shared with the lockCodes migration. function lock_utils.get_master_credential(device) local master_index = tables.master_credential_index(device) return master_index ~= nil and tables.find_entry(device, "credentials", master_index) or nil end --- Add the master code credential and the user that owns it. Both use the lock's master code slot as their --- index, since the user index only has meaning inside the capability state. -function lock_utils.add_master_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 - -- Read the master code slot back from the lock, so the master code is recorded even if it was set outside -- SmartThings. The report is handled like any other user code report. function lock_utils.read_master_code(device) @@ -150,11 +128,7 @@ function lock_utils.set_credential_report_helper(device, credential_index) local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} local result_status - if command_in_progress == consts.LOCK_CREDENTIALS.ADD and - lock_utils.is_master_credential(device, credential_args.credentialIndex) then - result_status = lock_utils.add_master_entries(device, - credential_args.credentialIndex, credential_args.credentialName) - elseif command_in_progress == consts.LOCK_CREDENTIALS.ADD then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then result_status = tables.add_entry(device, "credentials", { userIndex = credential_args.userIndex, credentialIndex = credential_args.credentialIndex, @@ -163,9 +137,6 @@ function lock_utils.set_credential_report_helper(device, credential_index) }) elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then result_status = consts.COMMAND_RESULT.SUCCESS - elseif lock_utils.is_master_credential(device, credential_index) then - -- master code reported without a command in progress - lock_utils.add_master_entries(device, credential_index) elseif not tables.find_entry(device, "credentials", credential_index) then -- credential does not exist and no add/update command in progress, therefore it was added out-of-band -- check what user slot we have to associate this with, and add a default user 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_master_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua index 16096abcee..0191180b83 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua @@ -414,4 +414,83 @@ test.register_coroutine_test( { test_init = make_test_init(yale_mock_device) } ) +-- ============================================================================ +-- master 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 master 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, "controlOnlyMember", "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, { MASTER_USER }) + expect_credentials(yale_mock_device, { MASTER_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 master slot with no command in progress adds the master code", + function() + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { MASTER_USER }) + expect_credentials(yale_mock_device, { MASTER_CREDENTIAL }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "master code report during an addCredential for another slot records the master code and leaves the add alone", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 5, "guest", "pin", "5555" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 5, + 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, { MASTER_USER }) + expect_credentials(yale_mock_device, { MASTER_CREDENTIAL }) + test.wait_for_events() + + -- the guest add still completes when the lock confirms it + queue_new_user_code_added(yale_mock_device, 5) + expect_credentials(yale_mock_device, { MASTER_CREDENTIAL, + { userIndex = 5, credentialIndex = 5, credentialType = "pin" } }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 5, credentialIndex = 5 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + test.run_registered_tests() 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..b901b4b5d6 --- /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 == consts.ADMIN.YALE_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..a38447f2b9 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -0,0 +1,154 @@ +-- 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 user_id_status = UserCode.user_id_status + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local cap_handlers = require "lock_handlers.capabilities" + +-- Yale locks keep their master 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 master code userType. + +local function is_master_credential(device, credential_index) + return credential_index ~= nil and credential_index == tables.master_credential_index(device) +end + +-- Add the master code credential and the user that owns it. Both use the master code slot as their index, +-- since the user index only has meaning inside the capability state. +local function add_master_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 master code and, if an addCredential for the master code is in progress, report the result. A +-- command in progress for any other credential is left alone. +local function master_code_reported(device) + local master_index = tables.master_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 {} + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and credential_args.credentialIndex == master_index then + local status = add_master_entries(device, master_index, credential_args.credentialName) + 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) + else + add_master_entries(device, master_index) + end +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 = cmd.args.user_id_status == user_id_status.ENABLED_GRANT_ACCESS or + (cmd.args.user_id_status == user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code) + + -- a report of the master code slot while it is added, or with no command in progress, records the master code. + -- Anything else (a delete the lock refused, an update) is handled like any other credential. + if is_master_credential(device, credential_index) and occupied and + (command_in_progress == nil or command_in_progress == consts.LOCK_CREDENTIALS.ADD) then + master_code_reported(device) + return + 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 master code slot is outside the lock's user code slots, so it is read separately. + lock_utils.read_master_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_master_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level))) then + -- the lock confirms a write to the master code slot like any other user code (ZW2) + master_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_master_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. + master_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_master_credential(device, cred_index) then + cap_handlers.add_credential(driver, device, command) + return + end + + -- only a master code credential may be written to the master 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 + + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = cred_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 = user_id_status.ENABLED_GRANT_ACCESS + })) +end + +local yale_lock = { + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report + }, + [cc.NOTIFICATION] = { + [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"), +} + +return yale_lock From 00d8eaf3fab30f2a4cb7d48ae55259c7efb92761 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Wed, 4 Nov 2026 16:01:55 -0600 Subject: [PATCH 08/12] Move the delete confirmation delay to the lock constants Co-Authored-By: Claude Sonnet 5.5 --- .../zwave-lock/src/lock_handlers/capabilities.lua | 5 +---- drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua | 2 ++ .../zwave-lock/src/test/test_lock_master_code.lua | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 10d0867299..7467ea9587 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -14,9 +14,6 @@ local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) --- Seconds to wait for a delete to be confirmed before reading the slot back -local DELETE_CONFIRM_DELAY = 4.2 - local CapabilityHandlers = {} @@ -190,7 +187,7 @@ local function send_credential_delete(device, credential_index) })) -- A lock that refuses the delete (e.g. a Yale master 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. - device.thread:call_with_delay(DELETE_CONFIRM_DELAY, function() + device.thread:call_with_delay(consts.DELETE_CONFIRM_DELAY, function() if lock_utils.is_delete_in_progress(device, credential_index) then device:send(UserCode:Get({ user_identifier = credential_index })) end diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index a4225ea194..876ddb8652 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -53,5 +53,7 @@ lock_constants.CRED_TYPE_PIN = "pin" 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 return lock_constants diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua index 0191180b83..f138741baa 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua @@ -234,7 +234,7 @@ test.register_coroutine_test( -- deleting the master code: the lock decides, and the driver reports what it did -- ============================================================================ -local DELETE_CONFIRM_DELAY = 4.2 +local DELETE_CONFIRM_DELAY = constants.DELETE_CONFIRM_DELAY local function expect_delete_set(device, slot) test.socket.zwave:__expect_send(UserCode:Set({ From cc5171793764e03f71dd31ba8f58793769aab0ff Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Wed, 4 Nov 2026 16:02:01 -0600 Subject: [PATCH 09/12] Rename the Yale master code handling to admin code Z-Wave calls the Yale slot 251 code the admin code. The user-facing "Master Code" name is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../zwave-lock/src/legacy-handlers/init.lua | 28 ++--- .../src/lock_handlers/capabilities.lua | 8 +- .../src/lock_handlers/zwave_responses.lua | 6 +- .../zwave-lock/src/lock_utils/constants.lua | 4 +- .../zwave-lock/src/lock_utils/tables.lua | 20 ++-- .../zwave-lock/src/lock_utils/utils.lua | 28 ++--- ...ster_code.lua => test_lock_admin_code.lua} | 100 +++++++++--------- .../test/test_lock_code_slga_migration.lua | 4 +- .../src/test/test_zwave_responses.lua | 4 +- .../zwave-lock/src/yale-lock/init.lua | 48 ++++----- 10 files changed, 125 insertions(+), 125 deletions(-) rename drivers/SmartThings/zwave-lock/src/test/{test_lock_master_code.lua => test_lock_admin_code.lua} (84%) diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 24105435c1..e512233902 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -75,20 +75,20 @@ local function migrate(driver, device, cmd) end table.sort(ordered_codes, function(a, b) return a.index < b.index end) - local master_index = table_utils.master_credential_index(device) - -- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the master code's - -- user takes the master code slot as its index. + local admin_index = table_utils.admin_credential_index(device) + -- 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_master = code.index == master_index - if not is_master then + local is_admin = code.index == admin_index + if not is_admin then user_index = user_index + 1 - if user_index == master_index then user_index = user_index + 1 end + if user_index == admin_index then user_index = user_index + 1 end end - local owner_index = is_master and code.index or user_index + local owner_index = is_admin and code.index or user_index table.insert(lock_users, { userIndex = owner_index, - userType = is_master and consts.ADMIN.USER_TYPE or "guest", + 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"}) @@ -116,14 +116,14 @@ local function migrate(driver, device, cmd) device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore - -- lockCodes only holds the master code if SmartThings set it and the lock confirmed it as a user code. A factory - -- master code, or one a Yale ZW3 confirmed with the "master code changed" notification, is read back instead. - local master_migrated = false + -- 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. + local admin_migrated = false for _, code in ipairs(ordered_codes) do - if code.index == master_index then master_migrated = true end + if code.index == admin_index then admin_migrated = true end end - if not master_migrated then - require("lock_utils.utils").read_master_code(device) + if not admin_migrated then + require("lock_utils.utils").read_admin_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 7467ea9587..a7d3e50228 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -185,7 +185,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 master code) ignores the Set and sends nothing, so read the + -- 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. device.thread:call_with_delay(consts.DELETE_CONFIRM_DELAY, function() if lock_utils.is_delete_in_progress(device, credential_index) then @@ -231,7 +231,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 a - -- master code (Yale) keeps it, so the master entries are kept in state below. + -- 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 @@ -239,10 +239,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_master_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 - lock_utils.delete_all_non_master_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 2afcd1f449..59ef456427 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -116,9 +116,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 master code. - lock_utils.delete_all_non_master_entries(device, "credentials") - lock_utils.delete_all_non_master_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 876ddb8652..ef5ed44217 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -17,10 +17,10 @@ lock_constants.SYNC = { } lock_constants.ADMIN = { - -- Yale locks keep their master code in this user code slot. + -- Yale locks keep their admin code in this user code slot. YALE_SLOT = 251, YALE_MANUFACTURER_ID = 0x0129, - -- Stopgap userType for the master code until the lockUsers capability has one for it. + -- Stopgap userType for the admin code until the lockUsers capability has one for it. USER_TYPE = "controlOnlyMember", NAME = "Master Code", } diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua index 48af741f12..da44a27607 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -42,9 +42,9 @@ local DEFS = { } } --- Returns the credentialIndex of the device's master code slot, or nil if the device does not have one. +-- Returns the credentialIndex of the device's admin code slot, or nil if the device does not have one. -- Only Yale locks have one. -function table_utils.master_credential_index(device) +function table_utils.admin_credential_index(device) if device.zwave_manufacturer_id == consts.ADMIN.YALE_MANUFACTURER_ID then return consts.ADMIN.YALE_SLOT end @@ -165,22 +165,22 @@ 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 --- Return true if the named table has reached its max_entries limit. The master code credential, and the +-- 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 master_index = table_utils.master_credential_index(device) - local master_cred = master_index and table_utils.find_entry(device, "credentials", master_index) + local admin_index = table_utils.admin_credential_index(device) + local admin_cred = admin_index and table_utils.find_entry(device, "credentials", admin_index) local count = 0 for _, entry in ipairs(table_utils.get_state(device, def) or {}) do - local is_master + local is_admin if table_name == "credentials" then - is_master = master_index ~= nil and entry.credentialIndex == master_index + is_admin = admin_index ~= nil and entry.credentialIndex == admin_index else - is_master = master_cred ~= nil and entry.userIndex == master_cred.userIndex + is_admin = admin_cred ~= nil and entry.userIndex == admin_cred.userIndex end - if not is_master then count = count + 1 end + if not is_admin then count = count + 1 end end return count >= table_utils.get_max_entries(device, table_name) end @@ -188,7 +188,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 master code). +-- 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) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 432402f6ec..0cfd90a0dd 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -69,32 +69,32 @@ function lock_utils.sync_device_state(device) end --- [[ MASTER CODE MANAGEMENT ]] -- +-- [[ ADMIN CODE MANAGEMENT ]] -- -- --- Yale locks keep their master code in a user code slot (see consts.ADMIN.YALE_SLOT) that is not one of the +-- Yale locks keep their admin code in a user code slot (see consts.ADMIN.YALE_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. The -- runtime handling lives in the yale-lock sub-driver; these helpers are shared with the lockCodes migration. -function lock_utils.get_master_credential(device) - local master_index = tables.master_credential_index(device) - return master_index ~= nil and tables.find_entry(device, "credentials", master_index) or nil +function lock_utils.get_admin_credential(device) + local admin_index = tables.admin_credential_index(device) + return admin_index ~= nil and tables.find_entry(device, "credentials", admin_index) or nil end --- Read the master code slot back from the lock, so the master code is recorded even if it was set outside +-- 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 lock_utils.read_master_code(device) - local master_index = tables.master_credential_index(device) - if master_index == nil then return end +function lock_utils.read_admin_code(device) + local admin_index = tables.admin_credential_index(device) + if admin_index == nil then return end local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) - device:send(UserCode:Get({ user_identifier = master_index })) + device:send(UserCode:Get({ user_identifier = admin_index })) end --- Clear every entry of the "users" or "credentials" table except the master code credential and its user. -function lock_utils.delete_all_non_master_entries(device, table_name) - local master = lock_utils.get_master_credential(device) +-- 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 = lock_utils.get_admin_credential(device) local match_key = table_name == "users" and "userIndex" or "credentialIndex" tables.delete_all_entries(device, table_name, function(entry) - return master ~= nil and entry[match_key] == master[match_key] + return admin ~= nil and entry[match_key] == admin[match_key] end) end diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua similarity index 84% rename from drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua rename to drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua index f138741baa..ba9c95fdcb 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua @@ -1,7 +1,7 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -- --- Integration tests for the Yale master code, which is kept in user code slot 251: +-- 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 controlOnlyMember 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 @@ -97,24 +97,24 @@ local function expect_credential_result(device, result) capabilities.lockCredentials.commandResult(result, { state_change = true, visibility = { displayed = false } }))) end -local function queue_master_code_changed(device) +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 MASTER_USER = { userIndex = 251, userName = "Master Code", userType = "controlOnlyMember" } -local MASTER_CREDENTIAL = { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } +local ADMIN_USER = { userIndex = 251, userName = "Master Code", userType = "controlOnlyMember" } +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 master code +-- setting the admin code -- ============================================================================ test.register_coroutine_test( - "addCredential: Yale master code is written to slot 251 and added when the lock reports it changed", + "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", @@ -127,9 +127,9 @@ test.register_coroutine_test( test.wait_for_events() -- The lock reports the change with a notification, not a User Code Report. - queue_master_code_changed(yale_mock_device) - expect_users(yale_mock_device, { MASTER_USER }) - expect_credentials(yale_mock_device, { MASTER_CREDENTIAL }) + 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() @@ -138,7 +138,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "addCredential: a guest credential cannot be written to the Yale master code slot", + "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" } } }) @@ -164,7 +164,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "addCredential: the Yale master code can be added when the credentials table is full", + "addCredential: the Yale admin code can be added when the credentials table is full", function() local function guests() local users, credentials = {}, {} @@ -186,10 +186,10 @@ test.register_coroutine_test( }):build_test_tx(yale_mock_device.id)) test.wait_for_events() - queue_master_code_changed(yale_mock_device) + queue_admin_code_changed(yale_mock_device) local expected_users, expected_credentials = guests() - table.insert(expected_users, MASTER_USER) - table.insert(expected_credentials, MASTER_CREDENTIAL) + 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, @@ -200,9 +200,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "updateCredential: Yale master code is written to slot 251 and succeeds when the lock reports it changed", + "updateCredential: Yale admin code is written to slot 251 and succeeds when the lock reports it changed", function() - seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + 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({ @@ -212,7 +212,7 @@ test.register_coroutine_test( }):build_test_tx(yale_mock_device.id)) test.wait_for_events() - queue_master_code_changed(yale_mock_device) + queue_admin_code_changed(yale_mock_device) expect_credential_result(yale_mock_device, { commandName = "updateCredential", statusCode = "success" }) test.wait_for_events() end, @@ -220,10 +220,10 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "the User Code v2 Master Code Report that follows a master code change is ignored", + "the User Code v2 Master Code Report that follows an admin code change is ignored", function() - seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) - queue_master_code_changed(yale_mock_device) + 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, @@ -231,7 +231,7 @@ test.register_coroutine_test( ) -- ============================================================================ --- deleting the master code: the lock decides, and the driver reports what it did +-- deleting the admin code: the lock decides, and the driver reports what it did -- ============================================================================ local DELETE_CONFIRM_DELAY = constants.DELETE_CONFIRM_DELAY @@ -243,7 +243,7 @@ local function expect_delete_set(device, slot) }):build_test_tx(device.id)) end -local function queue_delete_master_credential(device) +local function queue_delete_admin_credential(device) test.socket.capability:__queue_receive({ device.id, { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 251, "pin" } } }) end @@ -255,11 +255,11 @@ local function read_back_after_delay(device, slot) end test.register_coroutine_test( - "deleteCredential: the Yale master code is deleted when the lock confirms it", + "deleteCredential: the Yale admin code is deleted when the lock confirms it", function() - seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_master_credential(yale_mock_device) + queue_delete_admin_credential(yale_mock_device) expect_delete_set(yale_mock_device, 251) test.wait_for_events() @@ -283,9 +283,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, { MASTER_USER }, { MASTER_CREDENTIAL }) + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_master_credential(yale_mock_device) + 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) @@ -303,11 +303,11 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteCredential: a delete the lock refuses is reported as a failure and the master code is kept", + "deleteCredential: a delete the lock refuses is reported as a failure and the admin code is kept", function() - seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") - queue_delete_master_credential(yale_mock_device) + 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) @@ -324,9 +324,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteUser: a delete of the master code user the lock refuses fails for both capabilities", + "deleteUser: a delete of the admin code user the lock refuses fails for both capabilities", function() - seed(yale_mock_device, { MASTER_USER }, { MASTER_CREDENTIAL }) + 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 } } }) @@ -349,15 +349,15 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "all user codes deleted at the lock keeps the Yale master code", + "all user codes deleted at the lock keeps the Yale admin code", function() - seed(yale_mock_device, { MASTER_USER, GUEST_USER }, { MASTER_CREDENTIAL, GUEST_CREDENTIAL }) + 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, { MASTER_CREDENTIAL }) - expect_users(yale_mock_device, { MASTER_USER }) + 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) } @@ -394,9 +394,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "deleteAllCredentials: sends a single user identifier 0 clear and keeps the master code in state", + "deleteAllCredentials: sends a single user identifier 0 clear and keeps the admin code in state", function() - seed(yale_mock_device, { MASTER_USER, GUEST_USER }, { MASTER_CREDENTIAL, GUEST_CREDENTIAL }) + 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") @@ -407,7 +407,7 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(4) - expect_credentials(yale_mock_device, { MASTER_CREDENTIAL }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) expect_credential_result(yale_mock_device, { commandName = "deleteAllCredentials", statusCode = "success" }) test.wait_for_events() end, @@ -415,7 +415,7 @@ test.register_coroutine_test( ) -- ============================================================================ --- master code confirmed or reported in other ways +-- admin code confirmed or reported in other ways -- ============================================================================ local function queue_new_user_code_added(device, slot) @@ -427,7 +427,7 @@ local function queue_new_user_code_added(device, slot) end test.register_coroutine_test( - "addCredential: Yale master code is added when the lock confirms the write with a new user code event", + "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", @@ -440,8 +440,8 @@ test.register_coroutine_test( test.wait_for_events() queue_new_user_code_added(yale_mock_device, 251) - expect_users(yale_mock_device, { MASTER_USER }) - expect_credentials(yale_mock_device, { MASTER_CREDENTIAL }) + 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() @@ -450,18 +450,18 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "new user code event at the Yale master slot with no command in progress adds the master code", + "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, { MASTER_USER }) - expect_credentials(yale_mock_device, { MASTER_CREDENTIAL }) + 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( - "master code report during an addCredential for another slot records the master code and leaves the add alone", + "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", @@ -478,13 +478,13 @@ test.register_coroutine_test( user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "1234", }) }) - expect_users(yale_mock_device, { MASTER_USER }) - expect_credentials(yale_mock_device, { MASTER_CREDENTIAL }) + 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, 5) - expect_credentials(yale_mock_device, { MASTER_CREDENTIAL, + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL, { userIndex = 5, credentialIndex = 5, credentialType = "pin" } }) expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "success", userIndex = 5, credentialIndex = 5 }) 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 3ee79765b5..81df9358c8 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 @@ -169,7 +169,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Migrate maps the Yale master code slot 251 to a controlOnlyMember user with userIndex 251", + "Migrate maps the Yale admin code slot 251 to a controlOnlyMember user with userIndex 251", function() init_code_slot(1, "Zach", yale_mock_device) init_code_slot(251, "Master", yale_mock_device) @@ -184,7 +184,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Migrate adds the Yale master code at slot 251 when the lock answers the slot read", + "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 = {} } }) 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 5944e17d66..6ade8cf3d9 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -158,7 +158,7 @@ test.register_coroutine_test( }) test.register_coroutine_test( - "user_code_report: sync on a Yale reads the master code slot when it completes", + "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(), {}) @@ -172,7 +172,7 @@ 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 master code is added at 251 + -- 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, diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua index a38447f2b9..071bcf60de 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -13,16 +13,16 @@ local tables = require "lock_utils.tables" local zwave_handlers = require "lock_handlers.zwave_responses" local cap_handlers = require "lock_handlers.capabilities" --- Yale locks keep their master 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 master code userType. +-- 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_master_credential(device, credential_index) - return credential_index ~= nil and credential_index == tables.master_credential_index(device) +local function is_admin_credential(device, credential_index) + return credential_index ~= nil and credential_index == tables.admin_credential_index(device) end --- Add the master code credential and the user that owns it. Both use the master code slot as their index, +-- 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_master_entries(device, credential_index, name) +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, @@ -38,14 +38,14 @@ local function add_master_entries(device, credential_index, name) }, true) end --- Record the master code and, if an addCredential for the master code is in progress, report the result. A +-- 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 master_code_reported(device) - local master_index = tables.master_credential_index(device) +local function admin_code_reported(device) + local admin_index = tables.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 {} - if command_in_progress == consts.LOCK_CREDENTIALS.ADD and credential_args.credentialIndex == master_index then - local status = add_master_entries(device, master_index, credential_args.credentialName) + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and credential_args.credentialIndex == admin_index then + local status = add_admin_entries(device, admin_index, credential_args.credentialName) local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args.userIndex, credentialIndex = credential_args.credentialIndex, @@ -53,7 +53,7 @@ local function master_code_reported(device) lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, status, additional_info) lock_utils.clear_busy_state(device) else - add_master_entries(device, master_index) + add_admin_entries(device, admin_index) end end @@ -63,19 +63,19 @@ local function user_code_report(driver, device, cmd) local occupied = cmd.args.user_id_status == user_id_status.ENABLED_GRANT_ACCESS or (cmd.args.user_id_status == user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code) - -- a report of the master code slot while it is added, or with no command in progress, records the master code. + -- a report of the admin code slot while it is added, or with no command in progress, records the admin code. -- Anything else (a delete the lock refused, an update) is handled like any other credential. - if is_master_credential(device, credential_index) and occupied and + if is_admin_credential(device, credential_index) and occupied and (command_in_progress == nil or command_in_progress == consts.LOCK_CREDENTIALS.ADD) then - master_code_reported(device) + admin_code_reported(device) return 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 master code slot is outside the lock's user code slots, so it is read separately. - lock_utils.read_master_code(device) + -- the sync is finished. The admin code slot is outside the lock's user code slots, so it is read separately. + lock_utils.read_admin_code(device) end end @@ -85,17 +85,17 @@ 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_master_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( + is_admin_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( cmd.args.event_parameter, cmd.args.v1_alarm_level))) then - -- the lock confirms a write to the master code slot like any other user code (ZW2) - master_code_reported(device) + -- the lock confirms a write to the admin code slot like any other user code (ZW2) + 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_master_credential(device, credential_args.credentialIndex) then + 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. - master_code_reported(device) + admin_code_reported(device) return end end @@ -104,12 +104,12 @@ end local function add_credential(driver, device, command) local cred_index = command.args.userIndex - if not is_master_credential(device, cred_index) then + if not is_admin_credential(device, cred_index) then cap_handlers.add_credential(driver, device, command) return end - -- only a master code credential may be written to the master code slot, and it is not limited by the lock's + -- 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) From 0fe08aca52b258c42e813acc6a20ff7dbcebe060 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Wed, 4 Nov 2026 16:23:01 -0600 Subject: [PATCH 10/12] Only look for an admin code during migration on locks that have one Co-Authored-By: Claude Sonnet 5.5 --- .../zwave-lock/src/legacy-handlers/init.lua | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index e512233902..a2ae72b715 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -80,10 +80,10 @@ local function migrate(driver, device, cmd) -- user takes the admin code slot as its index. local user_index = 0 for _, code in ipairs(ordered_codes) do - local is_admin = code.index == admin_index + local is_admin = admin_index ~= nil and code.index == admin_index if not is_admin then user_index = user_index + 1 - if user_index == admin_index then user_index = user_index + 1 end + 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, { @@ -118,12 +118,14 @@ local function migrate(driver, device, cmd) -- 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. - local admin_migrated = false - for _, code in ipairs(ordered_codes) do - if code.index == admin_index then admin_migrated = true end - end - if not admin_migrated then - require("lock_utils.utils").read_admin_code(device) + if admin_index ~= nil then + local admin_migrated = false + for _, code in ipairs(ordered_codes) do + if code.index == admin_index then admin_migrated = true end + end + if not admin_migrated then + require("lock_utils.utils").read_admin_code(device) + end end end From cfd0e6ac6d72f928a474115f49e1737462d94131 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 5 Nov 2026 13:03:34 -0600 Subject: [PATCH 11/12] Update to using programmingUser user type --- .../zwave-lock/src/lock_utils/constants.lua | 4 ++-- .../zwave-lock/src/test/test_lock_admin_code.lua | 10 +++++----- .../src/test/test_lock_code_slga_migration.lua | 6 +++--- .../zwave-lock/src/test/test_zwave_responses.lua | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index ef5ed44217..8d6ee2d404 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -20,8 +20,8 @@ lock_constants.ADMIN = { -- Yale locks keep their admin code in this user code slot. YALE_SLOT = 251, YALE_MANUFACTURER_ID = 0x0129, - -- Stopgap userType for the admin code until the lockUsers capability has one for it. - USER_TYPE = "controlOnlyMember", + -- The lockUsers userType for the admin code. + USER_TYPE = "programmingUser", NAME = "Master Code", } 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 index ba9c95fdcb..95d852f2a7 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua @@ -3,7 +3,7 @@ -- -- 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 controlOnlyMember user whose index matches the slot, a delete is left to the lock and +-- 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. @@ -104,7 +104,7 @@ local function queue_admin_code_changed(device) }) }) end -local ADMIN_USER = { userIndex = 251, userName = "Master Code", userType = "controlOnlyMember" } +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" } @@ -118,7 +118,7 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 251, "controlOnlyMember", "pin", "199111" } } }) + args = { 251, "programmingUser", "pin", "199111" } } }) test.socket.zwave:__expect_send(UserCode:Set({ user_identifier = 251, user_code = "199111", @@ -178,7 +178,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 251, "controlOnlyMember", "pin", "199111" } } }) + args = { 251, "programmingUser", "pin", "199111" } } }) test.socket.zwave:__expect_send(UserCode:Set({ user_identifier = 251, user_code = "199111", @@ -431,7 +431,7 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 251, "controlOnlyMember", "pin", "199111" } } }) + args = { 251, "programmingUser", "pin", "199111" } } }) test.socket.zwave:__expect_send(UserCode:Set({ user_identifier = 251, user_code = "199111", 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 81df9358c8..00d7bce8f2 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 @@ -169,13 +169,13 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Migrate maps the Yale admin code slot 251 to a controlOnlyMember user with userIndex 251", + "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="controlOnlyMember"}}, { 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.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) 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 } }))) @@ -198,7 +198,7 @@ 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="controlOnlyMember"}}, { 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 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 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 6ade8cf3d9..b83aaad05e 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -179,7 +179,7 @@ test.register_coroutine_test( user_code = "199111", }) }) test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", - capabilities.lockUsers.users({ { userIndex = 251, userName = "Master Code", userType = "controlOnlyMember" } }, + 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( From 48190812edfdfecbeb2846ff99a9e8724b51f51a Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 5 Nov 2026 23:35:19 -0600 Subject: [PATCH 12/12] Prevent changing a programmingUser type user --- .../src/test/test_lock_admin_code.lua | 63 +++++++++++++++++++ .../zwave-lock/src/yale-lock/init.lua | 12 ++++ 2 files changed, 75 insertions(+) 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 index 95d852f2a7..ae397cceff 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua @@ -493,4 +493,67 @@ test.register_coroutine_test( { 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( + "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/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua index 071bcf60de..03dfaeae6b 100644 --- a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -133,6 +133,15 @@ local function add_credential(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] = { @@ -143,6 +152,9 @@ local yale_lock = { }, }, capability_handlers = { + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.updateUser.NAME] = update_user + }, [capabilities.lockCredentials.ID] = { [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential }