diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 95a8283714..4b2aba8e84 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -7,6 +7,7 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local LockDefaults = require "st.zwave.defaults.lock" local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local migrate_codes = require "legacy-handlers.migrate" local init_handler = function(driver, device, event) local constants = require "st.zwave.constants" @@ -49,64 +50,8 @@ local function update_codes(driver, device, cmd) end end ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @param cmd table local function migrate(driver, device, cmd) - local LockCodesDefaults = require "st.zwave.defaults.lockCodes" - local table_utils = require "lock_utils.tables" - local get_lock_codes = LockCodesDefaults.get_lock_codes - local lock_users = {} - local lock_credentials = {} - local lock_codes = get_lock_codes(device) - local ordered_codes = {} - - -- Slots that are not valid credential indices (e.g. 0, which means "all users" in Z-Wave, or non-numeric) - -- are dropped rather than migrated. - local min_credential = table_utils.get_minimum(device, "credentials") - for code_slot in pairs(lock_codes) do - local credential_index = tonumber(code_slot) - if math.type(credential_index) == "integer" and credential_index >= min_credential then - table.insert(ordered_codes, {slot = code_slot, index = credential_index}) - else - device.log.warn(string.format("Dropping lock code slot %q during migration: not a valid credential index", tostring(code_slot))) - end - end - - table.sort(ordered_codes, function(a, b) return a.index < b.index end) - for _, code in ipairs(ordered_codes) do - local user_index = #lock_users + 1 - table.insert(lock_users, {userIndex = user_index, userType = "guest", userName = lock_codes[code.slot]}) - table.insert(lock_credentials, {userIndex = user_index, credentialIndex = code.index, credentialType = "pin"}) - end - device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) - device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) - - local consts = require "lock_utils.constants" - - -- lockCodes tracks a single fixed code length, which only some locks (e.g. Schlage) report. - -- Without one, fall back to the Z-Wave spec default range so the bounds are always set. - local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - local min_pin_length = code_length or consts.DEFAULT_MIN_PIN_LENGTH - local max_pin_length = code_length or consts.DEFAULT_MAX_PIN_LENGTH - device:emit_event(capabilities.lockCredentials.minPinCodeLen(min_pin_length, { visibility = { displayed = false } })) - device:emit_event(capabilities.lockCredentials.maxPinCodeLen(max_pin_length, { visibility = { displayed = false } })) - - local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - if max_codes then - device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) - device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) - end - - device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) - device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore - - driver:inject_capability_command(device, { - capability = capabilities.refresh.ID, - command = capabilities.refresh.commands.refresh.NAME, - args = {} - }) + migrate_codes(driver, device) end local legacy_capabilities = { diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua new file mode 100644 index 0000000000..25cce9af40 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/migrate.lua @@ -0,0 +1,81 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local consts = require "lock_utils.constants" +local table_utils = require "lock_utils.tables" + +-- Migrate the lock's lockCodes state to lockUsers and lockCredentials. This is shared by the lockCodes.migrate +-- handlers of the legacy sub-drivers, which pass the programming code slot for a lock that has one. +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param programming_code_slot number|nil the credentialIndex of the lock's programming code slot, or nil if it has none +local function migrate_codes(driver, device, programming_code_slot) + local get_lock_codes = LockCodesDefaults.get_lock_codes + local lock_users = {} + local lock_credentials = {} + local lock_codes = get_lock_codes(device) + local ordered_codes = {} + + -- Slots that are not valid credential indices (e.g. 0, which means "all users" in Z-Wave, or non-numeric) + -- are dropped rather than migrated. + local min_credential = table_utils.get_minimum(device, "credentials") + for code_slot in pairs(lock_codes) do + local credential_index = tonumber(code_slot) + if math.type(credential_index) == "integer" and credential_index >= min_credential then + table.insert(ordered_codes, {slot = code_slot, index = credential_index}) + else + device.log.warn(string.format("Dropping lock code slot %q during migration: not a valid credential index", tostring(code_slot))) + end + end + + table.sort(ordered_codes, function(a, b) return a.index < b.index end) + -- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the programming code's + -- user takes the programming code slot as its index. + local user_index = 0 + for _, code in ipairs(ordered_codes) do + local is_programming = programming_code_slot ~= nil and code.index == programming_code_slot + if not is_programming then + user_index = user_index + 1 + if programming_code_slot ~= nil and user_index == programming_code_slot then user_index = user_index + 1 end + end + local owner_index = is_programming and code.index or user_index + table.insert(lock_users, { + userIndex = owner_index, + userType = is_programming and consts.PROGRAMMING_USER.USER_TYPE or "guest", + userName = lock_codes[code.slot], + }) + table.insert(lock_credentials, {userIndex = owner_index, credentialIndex = code.index, credentialType = "pin"}) + end + device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) + + -- lockCodes tracks a single fixed code length, which only some locks (e.g. Schlage) report. + -- Without one, fall back to the Z-Wave spec default range so the bounds are always set. + local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local min_pin_length = code_length or consts.DEFAULT_MIN_PIN_LENGTH + local max_pin_length = code_length or consts.DEFAULT_MAX_PIN_LENGTH + device:emit_event(capabilities.lockCredentials.minPinCodeLen(min_pin_length, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(max_pin_length, { visibility = { displayed = false } })) + + local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) + if max_codes then + device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + end + + device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore + + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) +end + +return migrate_codes diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua index 4c94ca5559..2f8c59664f 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua @@ -7,5 +7,6 @@ local sub_drivers = { lazy_load_if_possible("legacy-handlers.schlage-lock"), lazy_load_if_possible("legacy-handlers.samsung-lock"), lazy_load_if_possible("legacy-handlers.keywe-lock"), + lazy_load_if_possible("legacy-handlers.yale-lock"), } return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua new file mode 100644 index 0000000000..c4b6b5e207 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local yale_programming_code = require "yale-lock.programming_code" + if device.zwave_manufacturer_id == yale_programming_code.MANUFACTURER_ID then + return true, require("legacy-handlers.yale-lock") + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua new file mode 100644 index 0000000000..515010a336 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/yale-lock/init.lua @@ -0,0 +1,34 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local migrate_codes = require "legacy-handlers.migrate" +local yale_programming_code = require "yale-lock.programming_code" + +-- Migrate a Yale lock, mapping its programming code slot to the programming code user. +local function yale_migrate(driver, device, cmd) + migrate_codes(driver, device, yale_programming_code.SLOT) + + -- lockCodes only holds the programming code if SmartThings set it and the lock confirmed it as a user code. A factory + -- programming code, or one a Yale ZW3 confirmed with the "programming code changed" notification, is read back instead. + -- This is because the legacy handlers never read the programming code explicitly. Furthermore, if the lock was + -- running the newer ZW3 module, the legacy driver did not have the corresponding handling for saving + -- a programming code after a "Programming Code Changed" response. + if LockCodesDefaults.get_lock_codes(device)[tostring(yale_programming_code.SLOT)] == nil then + yale_programming_code.read_programming_code(device) + end +end + +local legacy_yale_lock = { + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.migrate.NAME] = yale_migrate + }, + }, + NAME = "Legacy Yale Lock", + can_handle = require("legacy-handlers.yale-lock.can_handle"), +} + +return legacy_yale_lock diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 441a4862b9..feaf8c62e6 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -25,6 +25,12 @@ function CapabilityHandlers.add_user(driver, device, command) return end + -- programming users cannot be created through the capability + if command.args.userType == consts.PROGRAMMING_USER.USER_TYPE then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + -- Find the smallest positive userIndex not already in the table local next_available_index = tables.next_index(device, "users") local status = tables.add_entry(device, "users", { @@ -42,20 +48,20 @@ function CapabilityHandlers.update_user(driver, device, command) return end - local result_status - if not tables.find_entry(device, "users", command.args.userIndex) then - result_status = tables.add_entry(device, "users", { - userIndex = command.args.userIndex, - userName = command.args.userName, - userType = command.args.userType, - }) - else - result_status = tables.update_entry(device, "users", - command.args.userIndex, - { userName = command.args.userName, userType = command.args.userType } - ) + -- a user cannot be made a programming user, and a programming user cannot be changed to another type + local found_user = tables.find_entry(device, "users", command.args.userIndex) + local is_programming_user = found_user ~= nil and found_user.userType == consts.PROGRAMMING_USER.USER_TYPE + if (command.args.userType == consts.PROGRAMMING_USER.USER_TYPE) ~= is_programming_user then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return end + -- a user that does not exist is not created here, so this fails for it + local result_status = tables.update_entry(device, "users", + command.args.userIndex, + { userName = command.args.userName, userType = command.args.userType } + ) + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, result_status, additional_info) end @@ -120,16 +126,19 @@ function CapabilityHandlers.add_credential(driver, device, command) -- userIndex 0 means "create a new user for this credential" (its index is chosen when the lock confirms the -- code). Any other userIndex must name an existing user that does not already have a credential (for now, -- each user may have at most one credential). + -- A programming user cannot be created or given a credential through the capability. local user_index = command.args.userIndex - if user_index ~= 0 and (not tables.find_entry(device, "users", user_index) + if command.args.userType == consts.PROGRAMMING_USER.USER_TYPE + or user_index ~= 0 and (not tables.find_entry(device, "users", user_index) + or lock_utils.is_programming_user(device, user_index) or tables.find_entry_by(device, "credentials", "userIndex", user_index)) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) return end - local credentials_full = #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") + local credentials_full = tables.is_full(device, "credentials") -- if we are being asked to create a new user (index 0), this also needs a free user index. - local users_full = (user_index == 0) and #(tables.get_state(device, "users") or {}) >= tables.get_max_entries(device, "users") + local users_full = (user_index == 0) and tables.is_full(device, "users") if credentials_full or users_full then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) return @@ -189,6 +198,16 @@ function CapabilityHandlers.update_credential(driver, device, command) })) end +-- The programming code cannot be deleted. Fail the command without sending anything to the lock. +local function refuse_programming_credential_delete(device, injected_by_delete_user) + device.log.warn("ignoring deleteCredential for a programming code, it cannot be deleted") + if injected_by_delete_user then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.FAILURE) + lock_utils.clear_busy_state(device) + end + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) +end + function CapabilityHandlers.delete_credential(driver, device, command) -- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials. if command.args.credentialIndex == 0 then @@ -196,17 +215,18 @@ function CapabilityHandlers.delete_credential(driver, device, command) return end - local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) - - if cmd_in_progress == consts.LOCK_USERS.DELETE then + local credential_index + if device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) == consts.LOCK_USERS.DELETE then -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} - device:send(UserCode:Set({ - user_identifier = credential_args.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + if lock_utils.is_programming_user(device, credential_args.userIndex) then + refuse_programming_credential_delete(device, true) + return + end + credential_index = credential_args.credentialIndex elseif lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) + return else -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) @@ -214,23 +234,30 @@ 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, userIndex = found_cred.userIndex, }) - device:send(UserCode:Set({ - user_identifier = command.args.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + credential_index = command.args.credentialIndex end + + device:send(UserCode:Set({ + user_identifier = credential_index, + user_id_status = UserCode.user_id_status.AVAILABLE + })) end function CapabilityHandlers.delete_all_credentials(driver, device, command) local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local send_clear_commands = function() - -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot + -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with a + -- programming code (Yale) keeps it, so the programming entries are kept in state below. device:send(UserCode:Set({ user_identifier = 0, user_id_status = UserCode.user_id_status.AVAILABLE @@ -238,10 +265,10 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) -- Delayed cleanup: ensure all tables are cleared after the lock has processed the command device.thread:call_with_delay(4, function() if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then - tables.delete_all_entries(device, "users") + lock_utils.delete_all_non_programming_entries(device, "users") lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) end - tables.delete_all_entries(device, "credentials") + lock_utils.delete_all_non_programming_entries(device, "credentials") lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) lock_utils.clear_busy_state(device) end) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua index 60fa0dd2e1..ebb5863dfa 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -31,8 +31,7 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) -- Determine boolean states of user code slot - local user_code_occupied = user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or - (user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code) + local user_code_occupied = lock_utils.is_code_occupied(cmd) local user_code_available = user_id_status == UserCode.user_id_status.AVAILABLE if command_in_progress == consts.SYNC.CODES_FROM_LOCK then @@ -87,8 +86,9 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd) local access_control_event = Notification.event.access_control if event == access_control_event.ALL_USER_CODES_DELETED then - tables.delete_all_entries(device, "credentials") - tables.delete_all_entries(device, "users") + -- Deleting all user codes does not affect the programming code. + lock_utils.delete_all_non_programming_entries(device, "credentials") + lock_utils.delete_all_non_programming_entries(device, "users") elseif event == access_control_event.SINGLE_USER_CODE_DELETED then lock_utils.delete_credential_report_helper(device, credential_index) @@ -97,7 +97,7 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd) lock_utils.set_credential_report_helper(device, credential_index) elseif event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then - -- aka "master code" changed + -- aka "programming code" changed if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.SUCCESS) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index f3b7689da3..645c22e372 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -19,6 +19,12 @@ lock_constants.SYNC = { CODES_SYNC_VERSION = 1, -- bump to force every device to re-sync its codes (a mismatch with codesSynced triggers it; with syncCodeIndexVersion, restarts from the first index) } +lock_constants.PROGRAMMING_USER = { + -- The lockUsers userType for the programming code. + USER_TYPE = "programmingUser", + DEFAULT_NAME = "Programming Code", +} + lock_constants.COMMAND_RESULT = { SUCCESS = "success", FAILURE = "failure", diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua index c6da0ac401..a482ea46a9 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -3,7 +3,8 @@ local capabilities = require "st.capabilities" local st_utils = require "st.utils" -local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT +local consts = require "lock_utils.constants" +local COMMAND_RESULT = consts.COMMAND_RESULT local table_utils = {} @@ -156,11 +157,27 @@ function table_utils.get_max_entries(device, table_name) return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing end +-- Return true if the named table has reached its max_entries limit. A programming user, and the credential it owns, +-- is not one of the lock's user slots, so it does not count toward the limit. +function table_utils.is_full(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return true end + local programming_users = {} + for _, user in ipairs(table_utils.get_state(device, "users") or {}) do + if user.userType == consts.PROGRAMMING_USER.USER_TYPE then programming_users[user.userIndex] = true end + end + local count = 0 + for _, entry in ipairs(table_utils.get_state(device, def) or {}) do + if not programming_users[entry.userIndex] then count = count + 1 end + end + return count >= table_utils.get_max_entries(device, table_name) +end + -- Add an entry to a named table. The entry must satisfy all required_keys for -- that table. An entry whose match_key value already exists in the -- table is skipped to prevent duplicates. If the table has a max_entries limit, --- entries that exceed the limit are not added. -function table_utils.add_entry(device, table_name, entry) +-- entries that exceed the limit are not added, unless exempt_from_limit is true (used for the programming code). +function table_utils.add_entry(device, table_name, entry, exempt_from_limit) device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) local def = resolve_table_def(device, table_name) if not def then return COMMAND_RESULT.FAILURE end @@ -168,7 +185,7 @@ function table_utils.add_entry(device, table_name, entry) local t = table_utils.get_state(device, def) if not t then return COMMAND_RESULT.FAILURE end - if #t >= table_utils.get_max_entries(device, table_name) then + if not exempt_from_limit and table_utils.is_full(device, table_name) then device.log.warn(string.format( "table_helpers: cannot add entry to %q, max entries reached", table_name )) @@ -247,13 +264,20 @@ function table_utils.delete_entry(device, table_name, matcher) return COMMAND_RESULT.FAILURE end --- Delete all entries from a table. -function table_utils.delete_all_entries(device, table_name) +-- Delete all entries from a table. If keep is given, entries for which keep(entry) +-- returns true are retained. +function table_utils.delete_all_entries(device, table_name, keep) device.log.debug("table_helpers: attempting to delete all entries from " .. table_name) local def = resolve_table_def(device, table_name) if not def then return COMMAND_RESULT.FAILURE end - device:emit_event(def.attribute({}, {visibility = {displayed = false}})) - persist_table(device, def, {}) + local kept = {} + if keep then + for _, entry in ipairs(table_utils.get_state(device, def) or {}) do + if keep(entry) then table.insert(kept, entry) end + end + end + device:emit_event(def.attribute(kept, {visibility = {displayed = false}})) + persist_table(device, def, kept) return COMMAND_RESULT.SUCCESS end diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index de7ae9bf63..2643cc85e7 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -4,6 +4,8 @@ local capabilities = require "st.capabilities" local consts = require "lock_utils.constants" local tables = require "lock_utils.tables" +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) local lock_utils = {} @@ -36,12 +38,9 @@ function lock_utils.clear_busy_state(device) device:set_field(consts.DRIVER_STATE.BUSY, false) end - -- [[ SYNC STATE MANAGEMENT ]] -- function lock_utils.sync_device_state(device) - local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) - -- skip if unsupported, already synced, or busy (including a sync already in progress). if not (device:supports_capability(capabilities.lockCredentials) and device:get_field(consts.SYNC.CODES_SYNCED) ~= consts.SYNC.CODES_SYNC_VERSION and @@ -87,7 +86,6 @@ end -- it explicitly reported the slot as available. Any other status (reserved, disabled, unknown) neither -- resets nor advances the consecutive-unoccupied counter, so it can't end the sync early. function lock_utils.continue_code_sync(device, occupied, available) - local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) local code_index = device:get_field(consts.SYNC.CODE_INDEX) if code_index == nil then return end @@ -115,6 +113,22 @@ function lock_utils.continue_code_sync(device, occupied, available) end +-- [[ PROGRAMMING CODE 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 + +-- Clear every entry of the "users" or "credentials" table except the programming code credential and its user. +function lock_utils.delete_all_non_programming_entries(device, table_name) + tables.delete_all_entries(device, table_name, function(entry) + return lock_utils.is_programming_user(device, entry.userIndex) + end) +end + + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) @@ -129,6 +143,13 @@ end -- [[ HELPERS ]] -- +-- True if a User Code Report shows a code in the slot. A lock that does not report the status of a slot +-- still holds a code if it reports one. +function lock_utils.is_code_occupied(cmd) + return cmd.args.user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or + (cmd.args.user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code ~= nil) +end + function lock_utils.get_code_id_from_notification_event(event_params, v1_alarm_level) local code_id = v1_alarm_level if event_params ~= nil and event_params ~= "" then @@ -203,6 +224,8 @@ end -- without any command from us (sync, or a user code event). If a credential already exists there it -- is left in place. function lock_utils.add_reported_credential(device, credential_index) + -- Index 0 means "all users" in Z-Wave, so a report for it is not a credential. + if credential_index < tables.get_minimum(device, "credentials") then return end if tables.find_entry(device, "credentials", credential_index) then return end return lock_utils.add_credential_and_user(device, { userIndex = tables.next_index(device, "users"), diff --git a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua index af5dc4d41f..dbb9bf779f 100644 --- a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua @@ -7,6 +7,7 @@ local sub_drivers = { lazy_load_if_possible("schlage-lock"), lazy_load_if_possible("samsung-lock"), lazy_load_if_possible("keywe-lock"), + lazy_load_if_possible("yale-lock"), lazy_load_if_possible("legacy-handlers"), } return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua.orig b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua.orig new file mode 100644 index 0000000000..8074abdae5 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua.orig @@ -0,0 +1,211 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lifecycle handlers defined in init.lua: +-- added (device_added), infoChanged (info_changed), init (LockLifecycle.init) +-- +-- Removed: doConfigure tests — z-wave drivers have no doConfigure lifecycle event. +-- Removed: init SLGA_MIGRATED without lockCodes test — z-wave init sends no protocol +-- messages; there is no NumberOfPINUsersSupported read on init. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +-- base-lock profile: lock + lockCodes + lockCredentials + lockUsers + battery +local mock_device_base = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- Same profile but provisioning_state = "TYPED" (freshly fingerprinted) +local mock_device_typed = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + _provisioning_state = "TYPED", +}) + +-- lock-battery profile: lock + battery only (no lockCodes / lockCredentials) +local mock_device_battery = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- same profile but freshly fingerprinted +local mock_device_typed = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + _provisioning_state = "TYPED", +}) + +local function make_test_init(device) + return function() + test.disable_startup_messages() + test.mock_device.add_test_device(device) + end +end + +-- ============================================================================ +-- added (device_added) +-- ============================================================================ + +test.register_coroutine_test( + "added: TYPED device with lockCodes emits migrated event, persists SLGA_MIGRATED, and injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + +<<<<<<< HEAD + -- Migrated event emitted for TYPED+lockCodes device +======= +>>>>>>> 1ee62e94 (Set devices to migrated on add) + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) +<<<<<<< HEAD + -- inject_capability_command calls the refresh handler: + -- DoorLock:OperationGet, Battery:Get, UserCode:UsersNumberGet (no cached values) +======= +>>>>>>> 1ee62e94 (Set devices to migrated on add) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_typed.id)) + test.wait_for_events() + + assert( + mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be true after added fires for a TYPED device" + ) + end, + { test_init = make_test_init(mock_device_typed) } +) + +test.register_coroutine_test( + "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "added" }) + + -- No migrated capability event expected + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_base.id)) + test.wait_for_events() + + assert( + mock_device_base:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) ~= true, + "SLGA_MIGRATED must NOT be set for a non-TYPED device" + ) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "added: device without lockCodes does NOT emit migrated event but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + + -- No migrated event; lock-battery has no lockCredentials so no UsersNumberGet + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_battery.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_battery.id)) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- init (LockLifecycle.init) +-- ============================================================================ + +test.register_coroutine_test( + "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: device with lockCodes but SLGA_MIGRATED not set does nothing", + function() + -- SLGA_MIGRATED is not set; no events or z-wave sends expected + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: lock-battery device (no lockCodes) does nothing regardless of SLGA_MIGRATED", + function() + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + -- lock-battery has no lockCodes → if branch is false → no events, no z-wave sends + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- driverSwitched (LockLifecycle.driver_switched) +-- ============================================================================ + +test.register_coroutine_test( + "driver_switched: device with lockCodes and migrated=true persists SLGA_MIGRATED and updates metadata", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + + -- Migrated event emitted for TYPED+lockCodes device + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler: + -- DoorLock:OperationGet, Battery:Get, UserCode:UsersNumberGet (no cached values) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_typed.id)) + test.wait_for_events() + + -- driverSwitched occurs after added, so migrated=true is already set in the capability state cache + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "driver_switched" }) + mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + assert(mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true) + end, + { test_init = make_test_init(mock_device_typed) } +) + +-- ============================================================================ +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua index 4417114578..fd03e5520a 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -47,6 +47,16 @@ local schlage_mock_device = test.mock_device.build_test_zwave_device( } ) +local yale_mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x0129, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000 + } +) + local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} local function init_code_slot(slot_number, name, device) @@ -61,6 +71,7 @@ end local function test_init() test.mock_device.add_test_device(mock_device) test.mock_device.add_test_device(schlage_mock_device) + test.mock_device.add_test_device(yale_mock_device) end test.set_test_init_function(test_init) @@ -177,4 +188,48 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Migrate maps the Yale programming code slot 251 to a programmingUser user with userIndex 251", + function() + init_code_slot(1, "Zach", yale_mock_device) + init_code_slot(251, "Master", yale_mock_device) + test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Master", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Migrate adds the Yale programming code at slot 251 when the lock answers the slot read", + function() + init_code_slot(1, "Zach", yale_mock_device) + test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "199111" }) }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Programming Code", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251, credentialName="Programming Code"}}, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua index 5e65dcfa27..98663147be 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua @@ -250,22 +250,10 @@ test.register_coroutine_test( { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 10, "Ghost", "guest" } }, }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { - { userIndex = 1, userName = "Alice", userType = "guest" }, - { userIndex = 2, userName = "Bob", userType = "guest" }, - { userIndex = 10, userName = "Ghost", userType = "guest" }, - }, - { visibility = { displayed = false } } - )) - ) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "success", userIndex = 10 }, + { commandName = "updateUser", statusCode = "failure" }, { state_change = true, visibility = { displayed = false } } )) ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua new file mode 100644 index 0000000000..32529699e3 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_programming_code.lua @@ -0,0 +1,733 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the Yale programming code, which is kept in user code slot 251: +-- it is recorded by the code sync (or when the lock reports it), it is exposed as a programmingUser user whose +-- index matches the slot, it can be updated but not added or deleted, and delete-all keeps it. User identifier +-- 0 (all users) is never sent for a single credential. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +local access_control_event = Notification.event.access_control + +test.disable_startup_messages() + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local yale_mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x0129, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000, +}) + +-- Capability state is reset for every test: get_latest_state only returns what the test seeded or the driver +-- emitted since, and the tables otherwise come from the persistent store. +local mock_latest_state = {} +local function mock_state_key(capability_id, attribute_name) + return capability_id .. "|" .. attribute_name +end + +local function install_state_mocks(device) + mock_latest_state = {} + device.get_latest_state = function(_, _, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(capability_id, attribute_name)] + if value == nil then return default_value end + return value + end + device.emit_event = function(_, event) + mock_latest_state[mock_state_key(event.capability.ID, event.attribute.NAME)] = event.value.value + local message = device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end +end + +local function make_test_init(device) + return function() + install_state_mocks(device) + device:set_field("persistedUsers", nil, { persist = true }) + device:set_field("persistedCredentials", nil, { persist = true }) + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.mock_device.add_test_device(device) + end +end + +local function seed(device, users, credentials) + mock_latest_state[mock_state_key(capabilities.lockUsers.ID, capabilities.lockUsers.users.NAME)] = users + mock_latest_state[mock_state_key(capabilities.lockCredentials.ID, capabilities.lockCredentials.credentials.NAME)] = credentials + device:set_field("persistedUsers", users, { persist = true }) + device:set_field("persistedCredentials", credentials, { persist = true }) +end + +local function expect_users(device, users) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockUsers.users(users, { visibility = { displayed = false } }))) +end + +local function expect_credentials(device, credentials) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCredentials.credentials(credentials, { visibility = { displayed = false } }))) +end + +local function expect_credential_result(device, result) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCredentials.commandResult(result, { state_change = true, visibility = { displayed = false } }))) +end + +local function queue_programming_code_changed(device) + test.socket.zwave:__queue_receive({ device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, + }) }) +end + +local PROGRAMMING_USER_ENTRY = { userIndex = 251, userName = "Programming Code", userType = "programmingUser" } +local PROGRAMMING_CREDENTIAL = { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Programming Code" } +local GUEST_USER = { userIndex = 1, userName = "Guest 1", userType = "guest" } +local GUEST_CREDENTIAL = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + +-- ============================================================================ +-- updating the programming code +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: user 251 is an ordinary user on a lock that is not a Yale", + function() + -- the credential takes the next free slot, not the programming code slot + seed(mock_device, { { userIndex = 251, userName = "Guest 251", userType = "guest" } }, {}) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id)) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateCredential: Yale programming code is written to slot 251 and succeeds when the lock reports it changed", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 251, 251, "pin", "5678" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "5678", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + queue_programming_code_changed(yale_mock_device) + expect_credential_result(yale_mock_device, { commandName = "updateCredential", statusCode = "success" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateCredential: Yale programming code update succeeds when the lock confirms it with a new user code event", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 251, 251, "pin", "5678" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "5678", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(251), + }) }) + expect_credential_result(yale_mock_device, + { commandName = "updateCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateCredential: a programming code report during a guest update records the programming code and leaves the update alone", + function() + seed(yale_mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 1, 1, "pin", "5678" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 1, + user_code = "5678", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) }) + expect_users(yale_mock_device, { GUEST_USER, PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { GUEST_CREDENTIAL, PROGRAMMING_CREDENTIAL }) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "5678", + }) }) + expect_credential_result(yale_mock_device, + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "the User Code v2 Master Code Report that follows a programming code change is ignored", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) + queue_programming_code_changed(yale_mock_device) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "199111" }) }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +-- ============================================================================ +-- the programming code cannot be deleted +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: the programming code cannot be deleted and nothing is sent to the lock", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 251, "pin" } } }) + expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteUser: the programming code user cannot be deleted and fails for both capabilities", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 251 } } }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult({ commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } }))) + expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "all user codes deleted at the lock keeps the Yale programming code", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED, + }) }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "an Alarm v1 all codes deleted report keeps the Yale programming code", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.zwave:__queue_receive({ yale_mock_device.id, Alarm:Report({ alarm_type = 32 }) }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +-- the default limit is 20 when the lock has not reported one +local function seed_guests_and_programming_code(device, guest_count) + local users, credentials = { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL } + for i = 1, guest_count do + table.insert(users, { userIndex = i, userName = "User " .. i, userType = "guest" }) + table.insert(credentials, { userIndex = i, credentialIndex = i, credentialType = "pin" }) + end + seed(device, users, credentials) +end + +test.register_coroutine_test( + "addCredential: the programming code does not count toward the credential limit", + function() + seed_guests_and_programming_code(yale_mock_device, 19) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 20, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: a full lock is still full with a programming code", + function() + seed_guests_and_programming_code(yale_mock_device, 20) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "resourceExhausted" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +-- ============================================================================ +-- user identifier 0 means "all users" and is never used for a single credential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: credentialIndex 0 is rejected without sending user identifier 0", + function() + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 1, 0, "pin", "5678" } } }) + expect_credential_result(mock_device, { commandName = "updateCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteCredential: credentialIndex 0 is rejected without sending user identifier 0", + function() + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 0, "pin" } } }) + expect_credential_result(mock_device, { commandName = "deleteCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteAllCredentials: sends a single user identifier 0 clear and keeps the programming code in state", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} } }) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 0, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.mock_time.advance_time(4) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + expect_credential_result(yale_mock_device, { commandName = "deleteAllCredentials", statusCode = "success" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +-- ============================================================================ +-- programming code confirmed or reported in other ways +-- ============================================================================ + +local function queue_new_user_code_added(device, slot) + test.socket.zwave:__queue_receive({ device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(slot), + }) }) +end + +test.register_coroutine_test( + "an out-of-band user code added event for index 0 is ignored", + function() + -- nothing is added or emitted + queue_new_user_code_added(mock_device, 0) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "new user code event at the Yale programming code slot with no command in progress adds the programming code", + function() + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "programming code report during an addCredential for another slot records the programming code and leaves the add alone", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "guest", "pin", "5555" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 1, + user_code = "5555", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + test.wait_for_events() + + -- the guest add still completes when the lock confirms it + queue_new_user_code_added(yale_mock_device, 1) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL, + { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY, { userIndex = 1, userName = "Guest 1", userType = "guest" } }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +local function start_sync(device, code_index) + device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + device:set_field(constants.SYNC.CODE_INDEX, code_index, {}) +end + +test.register_coroutine_test( + "a programming code report during a sync records the programming code and the sync waits for the slot it asked for", + function() + start_sync(yale_mock_device, 2) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 3 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "an empty programming code slot report during a sync does not move the sync on", + function() + start_sync(yale_mock_device, 2) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.wait_for_events() + + -- the sync still waits for the index it asked for + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 3 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "a sync that reaches the programming code slot records the programming code and moves on", + function() + -- a lock with more user slots than the programming code slot + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:UsersNumberReport({ supported_users = 255 }) }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(255, { state_change = true, visibility = { displayed = false } }))) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(255, { state_change = true, visibility = { displayed = false } }))) + test.wait_for_events() + + start_sync(yale_mock_device, 251) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 252 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "a new user code event at the programming code slot during a sync records the programming code, not a guest", + function() + start_sync(yale_mock_device, 2) + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { PROGRAMMING_USER_ENTRY }) + expect_credentials(yale_mock_device, { PROGRAMMING_CREDENTIAL }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +-- ============================================================================ +-- updating the programming code user +-- ============================================================================ + +local function expect_user_result(device, result) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockUsers.commandResult(result, { state_change = true, visibility = { displayed = false } }))) +end + +test.register_coroutine_test( + "addUser: a Yale programming code user cannot be added in a user slot", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Owner", "programmingUser" } } }) + expect_user_result(yale_mock_device, { commandName = "addUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addUser: other users can still be added on a Yale", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Guest", "guest" } } }) + expect_users(yale_mock_device, { { userIndex = 1, userName = "Guest", userType = "guest" } }) + expect_user_result(yale_mock_device, { commandName = "addUser", statusCode = "success", userIndex = 1 }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateUser: a guest cannot be made the programming code user", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 1, "Guest", "programmingUser" } } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateUser: the programming code user cannot be changed to another type", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY, GUEST_USER }, { PROGRAMMING_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 251, "Master Code", "guest" } } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateUser: the programming code user can be renamed and keeps its type", + function() + seed(yale_mock_device, { PROGRAMMING_USER_ENTRY }, { PROGRAMMING_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 251, "Renamed Master", "programmingUser" } } }) + expect_users(yale_mock_device, { { userIndex = 251, userName = "Renamed Master", userType = "programmingUser" } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "success", userIndex = 251 }) + test.wait_for_events() + end, + { + test_init = make_test_init(yale_mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateUser: no lock can make a guest a programming user", + function() + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 1, "Guest", "programmingUser" } } }) + expect_user_result(mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addUser: no lock can add a programming user", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Owner", "programmingUser" } } }) + expect_user_result(mock_device, { commandName = "addUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: no lock can add a credential of the programming user type", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "programmingUser", "pin", "123456" } } }) + expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: an existing programming user cannot be given another credential", + function() + seed(mock_device, { PROGRAMMING_USER_ENTRY }, {}) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "guest", "pin", "123456" } } }) + expect_credential_result(mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { + test_init = make_test_init(mock_device), + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua index 310fae786f..7327fd27ce 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua @@ -371,7 +371,7 @@ test.register_coroutine_test( }) test.register_coroutine_test( - "updateUser: creates user when userIndex does not exist", + "updateUser: fails for a userIndex that does not exist and does not create it", function() -- empty table — nothing to update test.socket.capability:__queue_receive({ @@ -379,20 +379,10 @@ test.register_coroutine_test( { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 99, "Ghost", "guest" } }, }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockUsers.users( - { - { userIndex = 99, userName = "Ghost", userType = "guest" }, - }, - { visibility = { displayed = false } } - )) - ) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "success", userIndex = 99 }, + { commandName = "updateUser", statusCode = "failure" }, { state_change = true, visibility = { displayed = false } } )) ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua.orig b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua.orig new file mode 100644 index 0000000000..2b4df74dac --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua.orig @@ -0,0 +1,336 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +<<<<<<< HEAD +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, +======= +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" +local lock_utils = require "lock_utils.constants".DRIVER_STATE + +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) + +test.disable_startup_messages() + +local SCHLAGE_MANUFACTURER_ID = 0x003B +local SCHLAGE_PRODUCT_TYPE = 0x0002 +local SCHLAGE_PRODUCT_ID = 0x0469 + +-- supported comand classes +local zwave_lock_endpoints = { + { + command_classes = { + {value = zw.BATTERY}, + {value = zw.DOOR_LOCK}, + {value = zw.USER_CODE}, + {value = zw.NOTIFICATION} +>>>>>>> parent of b6e09b30 (Zwave Lock: Remove Migration on Added (#3127)) + } + } +} + +<<<<<<< HEAD +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x003B, + zwave_product_type = 0x0002, + zwave_product_id = 0x0469, +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Masked occupied USER_CODE_REPORT during code sync advances to the next slot", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "**********", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + end, + { min_api_version = 15 } +======= +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + _provisioning_state = "TYPED", + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = SCHLAGE_MANUFACTURER_ID, + zwave_product_type = SCHLAGE_PRODUCT_TYPE, + zwave_product_id = SCHLAGE_PRODUCT_ID, + } +) + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +local function added() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + + test.socket.zwave:__expect_send( + DoorLock:OperationGet({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + Battery:Get({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") +end + +test.register_coroutine_test( + "Setting a user code should result in the named code changed event firing", + function() + added() + test.timer.__create_and_queue_test_time_advance_timer(4.2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234"} } }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number}) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(4.2) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({user_identifier = 1, user_id_status = UserCode.user_id_status.STATUS_NOT_AVAILABLE, user_code="0000\n\r"}) }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1, userIndex = 1}, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Configuration report should be handled", + function() + added() + test.socket.zwave:__queue_receive({ + mock_device.id, + Configuration:Report({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = 6 + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6)) + ) + end +) + +test.register_coroutine_test( + "Configuration report indicating code deletion should be handled", + function() + added() + test.socket.zwave:__queue_receive({ + mock_device.id, + Configuration:Report({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = 6 + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6)) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_device.id, + Configuration:Report({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = 4 + }) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(4)) + ) + end +) + +test.register_coroutine_test( + "User code report indicating master code is available should indicate code deletion", + function() + added() + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 0, + user_id_status = UserCode.user_id_status.AVAILABLE + }) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, + { visibility = { displayed = false } }) + ) + ) + end +) + +test.register_coroutine_test( + "Device should send appropriate configuration messages", + function() + added() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Get({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Association:Set({ + grouping_identifier = 2, + node_ids = {} + }) + ) + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Basic Sets should result in an Association remove", + function() + added() + test.socket.zwave:__queue_receive({ + mock_device.id, + Basic:Set({ + value = 0x00 + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({})) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Association:Remove({ + grouping_identifier = 1, + node_ids = {} + }) + ) + ) + end +>>>>>>> parent of b6e09b30 (Zwave Lock: Remove Migration on Added (#3127)) +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua index 08e3f610dc..ad735f7927 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -34,6 +34,7 @@ local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +--- @type st.zwave.CommandClass.UserCode local zwave_lock_endpoints = { { @@ -51,6 +52,14 @@ local mock_device = test.mock_device.build_test_zwave_device({ zwave_endpoints = zwave_lock_endpoints, }) +local yale_mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x0129, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000, +}) + local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) @@ -245,6 +254,45 @@ test.register_coroutine_test( min_api_version = 15 }) +test.register_coroutine_test( + "user_code_report: sync on a Yale reads the programming code slot when it completes", + function() + yale_mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + yale_mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + yale_mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + yale_mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 20, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + -- a ZW2 module answers the slot read; the programming code is added at 251 + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.users({ { userIndex = 251, userName = "Programming Code", userType = "programmingUser" } }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Programming Code" } }, + { visibility = { displayed = false } }))) + test.wait_for_events() + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(yale_mock_device) + end, + min_api_version = 15 + } +) + test.register_coroutine_test( "user_code_report: out-of-band ENABLED code creates user and credential entries", function() diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua new file mode 100644 index 0000000000..d658ff3f27 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated and device.zwave_manufacturer_id == require("yale-lock.programming_code").MANUFACTURER_ID then + local subdriver = require("yale-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua new file mode 100644 index 0000000000..d057e17244 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -0,0 +1,112 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cc = require "st.zwave.CommandClass" +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local yale_programming_code = require "yale-lock.programming_code" +local zwave_handlers = require "lock_handlers.zwave_responses" + +-- Yale locks keep their programming code in a user code slot that is not one of the lock's user slots. It is exposed +-- like any other credential, owned by a user that has the same index and the programming code userType. +local function is_programming_credential(device, credential_index) + return credential_index ~= nil and credential_index == yale_programming_code.credential_index(device) +end + +-- Add the programming code credential and the user that owns it. Both use the programming code slot as their index, +-- since the user index only has meaning inside the capability state. +local function add_programming_entries(device, credential_index, name) + name = name or consts.PROGRAMMING_USER.DEFAULT_NAME + local status = tables.add_entry(device, "users", { + userIndex = credential_index, + userName = name, + userType = consts.PROGRAMMING_USER.USER_TYPE, + }, true) + if status ~= consts.COMMAND_RESULT.SUCCESS and status ~= consts.COMMAND_RESULT.OCCUPIED then return status end + return tables.add_entry(device, "credentials", { + userIndex = credential_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = name, + }, true) +end + +-- Record the programming code the lock reported: the programming code credential and the user that owns it. +local function programming_code_reported(device) + add_programming_entries(device, yale_programming_code.credential_index(device)) +end + +-- True if an update of the programming code is waiting on the lock. The lock confirms it like any other +-- credential, so its reports go to the generic handlers. +local function is_programming_change_in_progress(device) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + return command_in_progress == consts.LOCK_CREDENTIALS.UPDATE and + is_programming_credential(device, credential_args.credentialIndex) +end + +local function user_code_report(driver, device, cmd) + local credential_index = cmd.args.user_identifier + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local occupied = lock_utils.is_code_occupied(cmd) + + -- The generic sync handler treats a report for any other index as an empty slot and moves on, which would skip + -- an index. The programming code slot is outside the sync's range and is read separately, so its reports must + -- not reach the sync unless the sync asked for that slot. + local syncing = command_in_progress == consts.SYNC.CODES_FROM_LOCK + local sync_asked_for_slot = syncing and credential_index == device:get_field(consts.SYNC.CODE_INDEX) + + if is_programming_credential(device, credential_index) then + if occupied and not is_programming_change_in_progress(device) then + -- a report of the programming code slot records the programming code, unless it answers an update of the + -- programming code (handled like any other credential). The programming code is already recorded, so a + -- sync does not add it again. + programming_code_reported(device) + if not sync_asked_for_slot then return end + elseif syncing and not sync_asked_for_slot then + -- an unrequested report that the programming code slot is empty must not move the sync on, or it would skip + -- the index the sync is waiting for + return + end + end + + zwave_handlers.user_code_report(driver, device, cmd) + if syncing and device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then + -- the sync is finished. The programming code slot is outside the lock's user code slots, so it is read separately. + yale_programming_code.read_programming_code(device) + end +end + +local function notification_report(driver, device, cmd) + if cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL then + local access_control_event = Notification.event.access_control + if cmd.args.event == access_control_event.NEW_USER_CODE_ADDED and + is_programming_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level))) and not is_programming_change_in_progress(device) then + -- the lock confirms a write to the programming code slot like any other user code (ZW2). An update of the + -- programming code is completed by the generic handler. + programming_code_reported(device) + return + end + end + zwave_handlers.notification_report(driver, device, cmd) +end + +local yale_lock = { + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report + }, + }, + NAME = "Yale Lock", + can_handle = require("yale-lock.can_handle"), +} + +return yale_lock diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/programming_code.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/programming_code.lua new file mode 100644 index 0000000000..d2a9b8bd32 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/programming_code.lua @@ -0,0 +1,29 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Yale locks keep their programming code in a user code slot that is not one of the lock's user slots. It is exposed +-- like any other credential, owned by a user with the same index and the programming code userType. +-- Everything that is specific to where Yale puts that slot lives here, so generic lock code only has to know +-- about the programmingUser userType. + +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local yale_programming_code = { + MANUFACTURER_ID = 0x0129, + SLOT = 251, +} + +-- Returns the credentialIndex of the programming code slot, or nil if the device is not a Yale lock. +function yale_programming_code.credential_index(device) + if device.zwave_manufacturer_id == yale_programming_code.MANUFACTURER_ID then + return yale_programming_code.SLOT + end +end + +-- Read the programming code slot back from the lock, so the programming code is recorded even if it was set outside +-- SmartThings. The report is handled like any other user code report. +function yale_programming_code.read_programming_code(device) + device:send(UserCode:Get({ user_identifier = yale_programming_code.SLOT })) +end + +return yale_programming_code diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua index fa9836498b..dd9c25e2b8 100644 --- a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua @@ -69,9 +69,9 @@ local function alarm_report_handler(driver, device, cmd) event = capabilities.lock.lock.locked() event.data = {method = METHOD.AUTO} elseif (alarm_type == 32) then - -- all credentials have been deleted - tables.delete_all_entries(device, "credentials") - tables.delete_all_entries(device, "users") + -- all user codes have been deleted, which does not affect the programming code + lock_utils.delete_all_non_programming_entries(device, "credentials") + lock_utils.delete_all_non_programming_entries(device, "users") elseif (alarm_type == 33) then -- credential has been deleted lock_utils.delete_credential_report_helper(device, credential_index)