diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index b49489a008..29e29855d8 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -23,6 +23,13 @@ local capability_handlers = require "lock_handlers.capabilities" local LockLifecycle = {} function LockLifecycle.device_added(driver, device) + if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then + -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. + -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, + -- and should be migrated manually by the user. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + end if device:supports_capability(capabilities.tamperAlert) then device:emit_event(capabilities.tamperAlert.tamper.clear()) end diff --git a/drivers/SmartThings/zwave-lock/src/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 13d45bc4dd..103983d422 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 = {} @@ -121,7 +124,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 @@ -146,34 +157,56 @@ 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 + })) + -- 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 + function CapabilityHandlers.delete_credential(driver, device, command) local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) if cmd_in_progress == consts.LOCK_USERS.DELETE then -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} - 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 @@ -188,10 +221,7 @@ function CapabilityHandlers.delete_credential(driver, device, command) credentialType = command.args.credentialType, userIndex = found_cred.userIndex, }) - device:send(UserCode:Set({ - user_identifier = command.args.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + send_credential_delete(device, command.args.credentialIndex) end end @@ -202,21 +232,25 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) local credentials = tables.get_state(device, "credentials") or {} local delay = 0 for _, credential in ipairs(credentials) do - 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 + -- Delete all does not affect the master code, and user identifier 0 would clear every user code. + 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 -- Delayed cleanup: ensure all tables are cleared after individual responses complete device.thread:call_with_delay(delay + 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..0e69cf204a 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 @@ -69,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) @@ -108,8 +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 - 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 +127,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..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) @@ -50,6 +69,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 +141,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 +154,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_init_lifecycle_handlers.lua b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua index d521e2d3a0..775f541446 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 @@ -44,6 +44,13 @@ local mock_device_battery = test.mock_device.build_test_zwave_device({ zwave_endpoints = zwave_lock_endpoints, }) +-- same profile but freshly fingerprinted +local mock_device_typed = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + _provisioning_state = "TYPED", +}) + local function make_test_init(device) return function() test.disable_startup_messages() @@ -55,6 +62,28 @@ end -- added (device_added) -- ============================================================================ +test.register_coroutine_test( + "added: TYPED device with lockCodes emits migrated event, persists SLGA_MIGRATED, and injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + + 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() + + assert( + mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be true after added fires for a TYPED device" + ) + end, + { test_init = make_test_init(mock_device_typed) } +) + test.register_coroutine_test( "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", function() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua index 2b9d2d3d72..0ff4726e65 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua @@ -45,6 +45,7 @@ test.set_test_init_function(test_init) local function added() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.socket.zwave:__expect_send( DoorLock:OperationGet({}):build_test_tx(mock_device.id) ) 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..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 @@ -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,19 @@ test.register_coroutine_test( 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 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..f0b70a1a66 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_master_code.lua @@ -0,0 +1,398 @@ +-- 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, a delete is left to the lock and +-- reported as it confirms or refuses it, and delete-all keeps it. User identifier 0 (all users) is never +-- sent for a single credential. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) +local 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) } +) + +-- ============================================================================ +-- 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 is deleted when the lock confirms it", + 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() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.SINGLE_USER_CODE_DELETED, + v1_alarm_level = 251, + }) }) + expect_credentials(yale_mock_device, {}) + expect_credential_result(yale_mock_device, + { commandName = "deleteCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + + -- the delete is already confirmed, so the slot is not read back + test.mock_time.advance_time(DELETE_CONFIRM_DELAY) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: an unconfirmed delete is read back and succeeds when the slot is empty", + function() + seed(yale_mock_device, { 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 = "failure" }, + { state_change = true, visibility = { displayed = false } }))) + expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "all user codes deleted at the lock keeps the Yale 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()