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