diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 65cd6cca4b..a2ae72b715 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -55,6 +55,7 @@ end local function migrate(driver, device, cmd) local LockCodesDefaults = require "st.zwave.defaults.lockCodes" local table_utils = require "lock_utils.tables" + local consts = require("lock_utils.constants") local get_lock_codes = LockCodesDefaults.get_lock_codes local lock_users = {} local lock_credentials = {} @@ -74,10 +75,23 @@ local function migrate(driver, device, cmd) end table.sort(ordered_codes, function(a, b) return a.index < b.index end) + local admin_index = table_utils.admin_credential_index(device) + -- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the admin code's + -- user takes the admin code slot as its index. + local user_index = 0 for _, code in ipairs(ordered_codes) do - local user_index = #lock_users + 1 - table.insert(lock_users, {userIndex = user_index, userType = "guest", userName = lock_codes[code.slot]}) - table.insert(lock_credentials, {userIndex = user_index, credentialIndex = code.index, credentialType = "pin"}) + local is_admin = admin_index ~= nil and code.index == admin_index + if not is_admin then + user_index = user_index + 1 + if admin_index ~= nil and user_index == admin_index then user_index = user_index + 1 end + end + local owner_index = is_admin and code.index or user_index + table.insert(lock_users, { + userIndex = owner_index, + userType = is_admin and consts.ADMIN.USER_TYPE or "guest", + userName = lock_codes[code.slot], + }) + table.insert(lock_credentials, {userIndex = owner_index, credentialIndex = code.index, credentialType = "pin"}) end device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) @@ -99,9 +113,20 @@ local function migrate(driver, device, cmd) device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - local consts = require("lock_utils.constants") device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore + + -- lockCodes only holds the admin code if SmartThings set it and the lock confirmed it as a user code. A factory + -- admin code, or one a Yale ZW3 confirmed with the "master code changed" notification, is read back instead. + if admin_index ~= nil then + local admin_migrated = false + for _, code in ipairs(ordered_codes) do + if code.index == admin_index then admin_migrated = true end + end + if not admin_migrated then + require("lock_utils.utils").read_admin_code(device) + end + end end local legacy_capabilities = { diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 7ebd38d191..a7d3e50228 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -121,7 +121,7 @@ function CapabilityHandlers.add_credential(driver, device, command) local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex - if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + if tables.is_full(device, "credentials") then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) return end @@ -152,24 +152,49 @@ function CapabilityHandlers.update_credential(driver, device, command) return end - if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + local credential_index = command.args.credentialIndex + if not tables.find_entry(device, "credentials", credential_index) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) return + elseif credential_index == 0 then + -- user identifier 0 addresses every user code on the lock, never a single credential + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return end lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { userIndex = command.args.userIndex, - credentialIndex = command.args.credentialIndex, + credentialIndex = credential_index, credentialType = command.args.credentialType, credentialName = command.args.credentialName, }) device:send(UserCode:Set({ - user_identifier = command.args.credentialIndex, + user_identifier = credential_index, user_code = command.args.credentialData, user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS })) end +-- Remove a credential from the lock. The busy state must already be set. +local function send_credential_delete(device, credential_index) + if credential_index == 0 then + -- user identifier 0 addresses every user code on the lock, so a stale entry is only removed locally + lock_utils.delete_credential_report_helper(device, credential_index) + else + device:send(UserCode:Set({ + user_identifier = credential_index, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + -- A lock that refuses the delete (e.g. a Yale admin code) ignores the Set and sends nothing, so read the + -- slot back if the delete has not completed. The report tells us whether the code is still there. + device.thread:call_with_delay(consts.DELETE_CONFIRM_DELAY, function() + if lock_utils.is_delete_in_progress(device, credential_index) then + device:send(UserCode:Get({ user_identifier = credential_index })) + end + end) + end +end + function CapabilityHandlers.delete_credential(driver, device, command) -- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials. if command.args.credentialIndex == 0 then @@ -182,10 +207,7 @@ function CapabilityHandlers.delete_credential(driver, device, command) if cmd_in_progress == consts.LOCK_USERS.DELETE then -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} - device:send(UserCode:Set({ - user_identifier = credential_args.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + send_credential_delete(device, credential_args.credentialIndex) elseif lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) else @@ -200,10 +222,7 @@ function CapabilityHandlers.delete_credential(driver, device, command) credentialType = command.args.credentialType, userIndex = found_cred.userIndex, }) - device:send(UserCode:Set({ - user_identifier = command.args.credentialIndex, - user_id_status = UserCode.user_id_status.AVAILABLE - })) + send_credential_delete(device, command.args.credentialIndex) end end @@ -211,7 +230,8 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local send_clear_commands = function() - -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot + -- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with a + -- admin code (Yale) keeps it, so the admin entries are kept in state below. device:send(UserCode:Set({ user_identifier = 0, user_id_status = UserCode.user_id_status.AVAILABLE @@ -219,10 +239,10 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) -- Delayed cleanup: ensure all tables are cleared after the lock has processed the command device.thread:call_with_delay(4, function() if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then - tables.delete_all_entries(device, "users") + lock_utils.delete_all_non_admin_entries(device, "users") lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) end - tables.delete_all_entries(device, "credentials") + lock_utils.delete_all_non_admin_entries(device, "credentials") lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) lock_utils.clear_busy_state(device) end) diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua index 7822ab2bfe..59ef456427 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -37,18 +37,23 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) if command_in_progress == consts.SYNC.CODES_FROM_LOCK then if user_code_occupied then - -- If an entry already exists at this index, this will be a no-op. - tables.add_entry(device, "users", { - userIndex = credential_index, - userName = "Guest " .. credential_index, - userType = "guest", - }) - tables.add_entry(device, "credentials", { - userIndex = credential_index, - credentialIndex = credential_index, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "Guest " .. credential_index, - }) + -- If a credential already exists at this index, this will be a no-op. + if not tables.find_entry(device, "credentials", credential_index) then + -- Prefer the matching userIndex, but never attach this credential to a different, existing user. + local user_index = tables.find_entry(device, "users", credential_index) and + tables.next_index(device, "users") or credential_index + tables.add_entry(device, "users", { + userIndex = user_index, + userName = "Guest " .. user_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = user_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. user_index, + }) + end -- reset the consecutive unoccupied codes counter since we found an occupied code device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) elseif user_code_available then @@ -69,6 +74,9 @@ function ZwaveHandlers.user_code_report(driver, device, cmd) device:send(UserCode:Get({user_identifier = synced_code_index})) end return + elseif user_code_occupied and lock_utils.is_delete_in_progress(device, credential_index) then + -- the code is still on the lock after a delete, so the lock refused it + lock_utils.emit_delete_failure(device) elseif user_code_occupied then -- Code slot is now occupied: add or update credential and associated user lock_utils.set_credential_report_helper(device, credential_index) @@ -108,8 +116,9 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd) local access_control_event = Notification.event.access_control if event == access_control_event.ALL_USER_CODES_DELETED then - tables.delete_all_entries(device, "credentials") - tables.delete_all_entries(device, "users") + -- Deleting all user codes does not affect the admin code. + lock_utils.delete_all_non_admin_entries(device, "credentials") + lock_utils.delete_all_non_admin_entries(device, "users") elseif event == access_control_event.SINGLE_USER_CODE_DELETED then lock_utils.delete_credential_report_helper(device, credential_index) diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua index f66c452beb..8d6ee2d404 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -16,6 +16,15 @@ lock_constants.SYNC = { CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", } +lock_constants.ADMIN = { + -- Yale locks keep their admin code in this user code slot. + YALE_SLOT = 251, + YALE_MANUFACTURER_ID = 0x0129, + -- The lockUsers userType for the admin code. + USER_TYPE = "programmingUser", + NAME = "Master Code", +} + lock_constants.COMMAND_RESULT = { SUCCESS = "success", FAILURE = "failure", @@ -44,5 +53,7 @@ lock_constants.CRED_TYPE_PIN = "pin" lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" lock_constants.DELAY_LOCK_EVENT_TIMER = "_delay_lock_event_timer" lock_constants.MAX_DELAY = 10 +-- Seconds to wait for a delete to be confirmed before reading the slot back +lock_constants.DELETE_CONFIRM_DELAY = 4.2 return lock_constants diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua index c6da0ac401..da44a27607 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -3,7 +3,8 @@ local capabilities = require "st.capabilities" local st_utils = require "st.utils" -local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT +local consts = require "lock_utils.constants" +local COMMAND_RESULT = consts.COMMAND_RESULT local table_utils = {} @@ -41,6 +42,14 @@ local DEFS = { } } +-- Returns the credentialIndex of the device's admin code slot, or nil if the device does not have one. +-- Only Yale locks have one. +function table_utils.admin_credential_index(device) + if device.zwave_manufacturer_id == consts.ADMIN.YALE_MANUFACTURER_ID then + return consts.ADMIN.YALE_SLOT + end +end + -- Resolve a table name to its definition. Logs an error and returns nil if unknown. local function resolve_table_def(device, table_name) local def = DEFS[table_name] @@ -156,11 +165,31 @@ function table_utils.get_max_entries(device, table_name) return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing end +-- Return true if the named table has reached its max_entries limit. The admin code credential, and the +-- user that owns it, are not one of the lock's user slots, so they do not count toward the limit. +function table_utils.is_full(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return true end + local admin_index = table_utils.admin_credential_index(device) + local admin_cred = admin_index and table_utils.find_entry(device, "credentials", admin_index) + local count = 0 + for _, entry in ipairs(table_utils.get_state(device, def) or {}) do + local is_admin + if table_name == "credentials" then + is_admin = admin_index ~= nil and entry.credentialIndex == admin_index + else + is_admin = admin_cred ~= nil and entry.userIndex == admin_cred.userIndex + end + if not is_admin then count = count + 1 end + end + return count >= table_utils.get_max_entries(device, table_name) +end + -- Add an entry to a named table. The entry must satisfy all required_keys for -- that table. An entry whose match_key value already exists in the -- table is skipped to prevent duplicates. If the table has a max_entries limit, --- entries that exceed the limit are not added. -function table_utils.add_entry(device, table_name, entry) +-- entries that exceed the limit are not added, unless exempt_from_limit is true (used for the admin code). +function table_utils.add_entry(device, table_name, entry, exempt_from_limit) device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) local def = resolve_table_def(device, table_name) if not def then return COMMAND_RESULT.FAILURE end @@ -168,7 +197,7 @@ function table_utils.add_entry(device, table_name, entry) local t = table_utils.get_state(device, def) if not t then return COMMAND_RESULT.FAILURE end - if #t >= table_utils.get_max_entries(device, table_name) then + if not exempt_from_limit and table_utils.is_full(device, table_name) then device.log.warn(string.format( "table_helpers: cannot add entry to %q, max entries reached", table_name )) @@ -247,13 +276,20 @@ function table_utils.delete_entry(device, table_name, matcher) return COMMAND_RESULT.FAILURE end --- Delete all entries from a table. -function table_utils.delete_all_entries(device, table_name) +-- Delete all entries from a table. If keep is given, entries for which keep(entry) +-- returns true are retained. +function table_utils.delete_all_entries(device, table_name, keep) device.log.debug("table_helpers: attempting to delete all entries from " .. table_name) local def = resolve_table_def(device, table_name) if not def then return COMMAND_RESULT.FAILURE end - device:emit_event(def.attribute({}, {visibility = {displayed = false}})) - persist_table(device, def, {}) + local kept = {} + if keep then + for _, entry in ipairs(table_utils.get_state(device, def) or {}) do + if keep(entry) then table.insert(kept, entry) end + end + end + device:emit_event(def.attribute(kept, {visibility = {displayed = false}})) + persist_table(device, def, kept) return COMMAND_RESULT.SUCCESS end diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua index 64851e485a..0cfd90a0dd 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -37,6 +37,25 @@ function lock_utils.clear_busy_state(device) end +-- True if a deleteCredential or deleteUser of the credential at credential_index is waiting on the lock. +function lock_utils.is_delete_in_progress(device, credential_index) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + return (command_in_progress == consts.LOCK_CREDENTIALS.DELETE or command_in_progress == consts.LOCK_USERS.DELETE) and + credential_args.credentialIndex == credential_index +end + +-- Report a delete the lock refused, leaving the user and credential in place. +function lock_utils.emit_delete_failure(device) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress == consts.LOCK_USERS.DELETE then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.FAILURE) + end + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) + lock_utils.clear_busy_state(device) +end + + -- [[ SYNC STATE MANAGEMENT ]] -- function lock_utils.sync_device_state(device) @@ -50,6 +69,36 @@ function lock_utils.sync_device_state(device) end +-- [[ ADMIN CODE MANAGEMENT ]] -- +-- +-- Yale locks keep their admin code in a user code slot (see consts.ADMIN.YALE_SLOT) that is not one of the +-- lock's user slots. It is exposed like any other credential, owned by a user with the same index. The +-- runtime handling lives in the yale-lock sub-driver; these helpers are shared with the lockCodes migration. + +function lock_utils.get_admin_credential(device) + local admin_index = tables.admin_credential_index(device) + return admin_index ~= nil and tables.find_entry(device, "credentials", admin_index) or nil +end + +-- Read the admin code slot back from the lock, so the admin code is recorded even if it was set outside +-- SmartThings. The report is handled like any other user code report. +function lock_utils.read_admin_code(device) + local admin_index = tables.admin_credential_index(device) + if admin_index == nil then return end + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + device:send(UserCode:Get({ user_identifier = admin_index })) +end + +-- Clear every entry of the "users" or "credentials" table except the admin code credential and its user. +function lock_utils.delete_all_non_admin_entries(device, table_name) + local admin = lock_utils.get_admin_credential(device) + local match_key = table_name == "users" and "userIndex" or "credentialIndex" + tables.delete_all_entries(device, table_name, function(entry) + return admin ~= nil and entry[match_key] == admin[match_key] + end) +end + + -- [[ COMMAND RESULT STATE MANAGEMENT ]] -- function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) 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 b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua index ac127f234a..909adde2fe 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -87,6 +87,27 @@ test.register_coroutine_test( { test_init = make_test_init(mock_device_typed) } ) +test.register_coroutine_test( + "added: TYPED device syncs the codes already on the lock 5 seconds after joining", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_typed.id)) + test.wait_for_events() + + test.mock_time.advance_time(5) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device_typed.id)) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_typed) } +) + test.register_coroutine_test( "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", function() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua new file mode 100644 index 0000000000..ae397cceff --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_admin_code.lua @@ -0,0 +1,559 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the Yale admin code, which is kept in user code slot 251: +-- it is set with a plain User Code Set and confirmed by the "master code changed" notification, +-- it is exposed as a programmingUser user whose index matches the slot, a delete is left to the lock and +-- reported as it confirms or refuses it, and delete-all keeps it. User identifier 0 (all users) is never +-- sent for a single credential. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local UserCodeV2 = (require "st.zwave.CommandClass.UserCode")({ version = 2 }) +local access_control_event = Notification.event.access_control + +test.disable_startup_messages() + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local yale_mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = constants.ADMIN.YALE_MANUFACTURER_ID, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000, +}) + +-- Capability state is reset for every test: get_latest_state only returns what the test seeded or the driver +-- emitted since, and the tables otherwise come from the persistent store. +local mock_latest_state = {} +local function mock_state_key(capability_id, attribute_name) + return capability_id .. "|" .. attribute_name +end + +local function install_state_mocks(device) + mock_latest_state = {} + device.get_latest_state = function(_, _, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(capability_id, attribute_name)] + if value == nil then return default_value end + return value + end + device.emit_event = function(_, event) + mock_latest_state[mock_state_key(event.capability.ID, event.attribute.NAME)] = event.value.value + local message = device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end +end + +local function make_test_init(device) + return function() + install_state_mocks(device) + device:set_field("persistedUsers", nil, { persist = true }) + device:set_field("persistedCredentials", nil, { persist = true }) + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.mock_device.add_test_device(device) + end +end + +local function seed(device, users, credentials) + mock_latest_state[mock_state_key(capabilities.lockUsers.ID, capabilities.lockUsers.users.NAME)] = users + mock_latest_state[mock_state_key(capabilities.lockCredentials.ID, capabilities.lockCredentials.credentials.NAME)] = credentials + device:set_field("persistedUsers", users, { persist = true }) + device:set_field("persistedCredentials", credentials, { persist = true }) +end + +local function expect_users(device, users) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockUsers.users(users, { visibility = { displayed = false } }))) +end + +local function expect_credentials(device, credentials) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCredentials.credentials(credentials, { visibility = { displayed = false } }))) +end + +local function expect_credential_result(device, result) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCredentials.commandResult(result, { state_change = true, visibility = { displayed = false } }))) +end + +local function queue_admin_code_changed(device) + test.socket.zwave:__queue_receive({ device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, + }) }) +end + +local ADMIN_USER = { userIndex = 251, userName = "Master Code", userType = "programmingUser" } +local ADMIN_CREDENTIAL = { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } +local GUEST_USER = { userIndex = 1, userName = "Guest 1", userType = "guest" } +local GUEST_CREDENTIAL = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + +-- ============================================================================ +-- setting the admin code +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: Yale admin code is written to slot 251 and added when the lock reports it changed", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "programmingUser", "pin", "199111" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "199111", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + -- The lock reports the change with a notification, not a User Code Report. + queue_admin_code_changed(yale_mock_device) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "addCredential: a guest credential cannot be written to the Yale admin code slot", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) + expect_credential_result(yale_mock_device, { commandName = "addCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "addCredential: slot 251 is an ordinary slot on a lock that is not a Yale", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 251, "guest", "pin", "1234" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id)) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "addCredential: the Yale admin code can be added when the credentials table is full", + function() + local function guests() + local users, credentials = {}, {} + for i = 1, 20 do + table.insert(users, { userIndex = i, userName = "Guest " .. i, userType = "guest" }) + table.insert(credentials, { userIndex = i, credentialIndex = i, credentialType = "pin" }) + end + return users, credentials + end + seed(yale_mock_device, guests()) + + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "programmingUser", "pin", "199111" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "199111", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + queue_admin_code_changed(yale_mock_device) + local expected_users, expected_credentials = guests() + table.insert(expected_users, ADMIN_USER) + table.insert(expected_credentials, ADMIN_CREDENTIAL) + expect_users(yale_mock_device, expected_users) + expect_credentials(yale_mock_device, expected_credentials) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateCredential: Yale admin code is written to slot 251 and succeeds when the lock reports it changed", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 251, 251, "pin", "5678" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "5678", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + queue_admin_code_changed(yale_mock_device) + expect_credential_result(yale_mock_device, { commandName = "updateCredential", statusCode = "success" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "the User Code v2 Master Code Report that follows an admin code change is ignored", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + queue_admin_code_changed(yale_mock_device) + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCodeV2:MasterCodeReport({ master_code = "199111" }) }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +-- ============================================================================ +-- deleting the admin code: the lock decides, and the driver reports what it did +-- ============================================================================ + +local DELETE_CONFIRM_DELAY = constants.DELETE_CONFIRM_DELAY + +local function expect_delete_set(device, slot) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = slot, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(device.id)) +end + +local function queue_delete_admin_credential(device) + test.socket.capability:__queue_receive({ device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 251, "pin" } } }) +end + +local function read_back_after_delay(device, slot) + test.mock_time.advance_time(DELETE_CONFIRM_DELAY) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = slot }):build_test_tx(device.id)) + test.wait_for_events() +end + +test.register_coroutine_test( + "deleteCredential: the Yale admin code is deleted when the lock confirms it", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_admin_credential(yale_mock_device) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.SINGLE_USER_CODE_DELETED, + v1_alarm_level = 251, + }) }) + expect_credentials(yale_mock_device, {}) + expect_credential_result(yale_mock_device, + { commandName = "deleteCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + + -- the delete is already confirmed, so the slot is not read back + test.mock_time.advance_time(DELETE_CONFIRM_DELAY) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: an unconfirmed delete is read back and succeeds when the slot is empty", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_admin_credential(yale_mock_device) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + read_back_after_delay(yale_mock_device, 251) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + expect_credentials(yale_mock_device, {}) + expect_credential_result(yale_mock_device, + { commandName = "deleteCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: a delete the lock refuses is reported as a failure and the admin code is kept", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + queue_delete_admin_credential(yale_mock_device) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + read_back_after_delay(yale_mock_device, 251) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "deleteUser: a delete of the admin code user the lock refuses fails for both capabilities", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.timer.__create_and_queue_test_time_advance_timer(DELETE_CONFIRM_DELAY, "oneshot") + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 251 } } }) + expect_delete_set(yale_mock_device, 251) + test.wait_for_events() + read_back_after_delay(yale_mock_device, 251) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult({ commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } }))) + expect_credential_result(yale_mock_device, { commandName = "deleteCredential", statusCode = "failure" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "all user codes deleted at the lock keeps the Yale admin code", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.zwave:__queue_receive({ yale_mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED, + }) }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_users(yale_mock_device, { ADMIN_USER }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +-- ============================================================================ +-- user identifier 0 means "all users" and is never used for a single credential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: credentialIndex 0 is rejected without sending user identifier 0", + function() + seed(mock_device, { { userIndex = 1, userName = "Master Code", userType = "guest" } }, + { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", args = { 1, 0, "pin", "5678" } } }) + expect_credential_result(mock_device, { commandName = "updateCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "deleteCredential: credentialIndex 0 is rejected without sending user identifier 0", + function() + seed(mock_device, { { userIndex = 1, userName = "Master Code", userType = "guest" } }, + { { userIndex = 1, credentialIndex = 0, credentialType = "pin" } }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", args = { 0, "pin" } } }) + expect_credential_result(mock_device, { commandName = "deleteCredential", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.register_coroutine_test( + "deleteAllCredentials: sends a single user identifier 0 clear and keeps the admin code in state", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} } }) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 0, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.mock_time.advance_time(4) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_credential_result(yale_mock_device, { commandName = "deleteAllCredentials", statusCode = "success" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +-- ============================================================================ +-- admin code confirmed or reported in other ways +-- ============================================================================ + +local function queue_new_user_code_added(device, slot) + test.socket.zwave:__queue_receive({ device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(slot), + }) }) +end + +test.register_coroutine_test( + "addCredential: Yale admin code is added when the lock confirms the write with a new user code event", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 251, "programmingUser", "pin", "199111" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 251, + user_code = "199111", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 251, credentialIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "new user code event at the Yale admin slot with no command in progress adds the admin code", + function() + queue_new_user_code_added(yale_mock_device, 251) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "admin code report during an addCredential for another slot records the admin code and leaves the add alone", + function() + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 5, "guest", "pin", "5555" } } }) + test.socket.zwave:__expect_send(UserCode:Set({ + user_identifier = 5, + user_code = "5555", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) }) + expect_users(yale_mock_device, { ADMIN_USER }) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL }) + test.wait_for_events() + + -- the guest add still completes when the lock confirms it + queue_new_user_code_added(yale_mock_device, 5) + expect_credentials(yale_mock_device, { ADMIN_CREDENTIAL, + { userIndex = 5, credentialIndex = 5, credentialType = "pin" } }) + expect_credential_result(yale_mock_device, + { commandName = "addCredential", statusCode = "success", userIndex = 5, credentialIndex = 5 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +-- ============================================================================ +-- updating the admin code user +-- ============================================================================ + +local function expect_user_result(device, result) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockUsers.commandResult(result, { state_change = true, visibility = { displayed = false } }))) +end + +test.register_coroutine_test( + "updateUser: a guest cannot be made the admin code user", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 1, "Guest", "programmingUser" } } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateUser: the admin code user cannot be changed to another type", + function() + seed(yale_mock_device, { ADMIN_USER, GUEST_USER }, { ADMIN_CREDENTIAL, GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 251, "Master Code", "guest" } } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "invalidCommand" }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateUser: the admin code user can be renamed and keeps its type", + function() + seed(yale_mock_device, { ADMIN_USER }, { ADMIN_CREDENTIAL }) + test.socket.capability:__queue_receive({ yale_mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 251, "Renamed Master", "programmingUser" } } }) + expect_users(yale_mock_device, { { userIndex = 251, userName = "Renamed Master", userType = "programmingUser" } }) + expect_user_result(yale_mock_device, { commandName = "updateUser", statusCode = "success", userIndex = 251 }) + test.wait_for_events() + end, + { test_init = make_test_init(yale_mock_device) } +) + +test.register_coroutine_test( + "updateUser: other locks are not restricted by the admin code user type", + function() + seed(mock_device, { GUEST_USER }, { GUEST_CREDENTIAL }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", + args = { 1, "Guest", "programmingUser" } } }) + expect_users(mock_device, { { userIndex = 1, userName = "Guest", userType = "programmingUser" } }) + expect_user_result(mock_device, { commandName = "updateUser", statusCode = "success", userIndex = 1 }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device) } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua index a486f943e8..00d7bce8f2 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -45,6 +45,16 @@ local schlage_mock_device = test.mock_device.build_test_zwave_device( } ) +local yale_mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = 0x0129, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000 + } +) + local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} local function init_code_slot(slot_number, name, device) @@ -59,6 +69,7 @@ end local function test_init() test.mock_device.add_test_device(mock_device) test.mock_device.add_test_device(schlage_mock_device) + test.mock_device.add_test_device(yale_mock_device) end test.set_test_init_function(test_init) @@ -157,4 +168,40 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Migrate maps the Yale admin code slot 251 to a programmingUser user with userIndex 251", + function() + init_code_slot(1, "Zach", yale_mock_device) + init_code_slot(251, "Master", yale_mock_device) + test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Master", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Migrate adds the Yale admin code at slot 251 when the lock answers the slot read", + function() + init_code_slot(1, "Zach", yale_mock_device) + test.socket.capability:__queue_receive({ yale_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(yale_mock_device.id)) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_code = "199111" }) }) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=251, userName="Master Code", userType="programmingUser"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( yale_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=251, credentialType="pin", userIndex=251, credentialName="Master Code"}}, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua index 24db26a3ba..b83aaad05e 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -32,6 +32,7 @@ local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +--- @type st.zwave.CommandClass.UserCode local zwave_lock_endpoints = { { @@ -49,6 +50,14 @@ local mock_device = test.mock_device.build_test_zwave_device({ zwave_endpoints = zwave_lock_endpoints, }) +local yale_mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = constants.ADMIN.YALE_MANUFACTURER_ID, + zwave_product_type = 0x0002, + zwave_product_id = 0x0000, +}) + local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) @@ -148,6 +157,45 @@ test.register_coroutine_test( min_api_version = 15 }) +test.register_coroutine_test( + "user_code_report: sync on a Yale reads the admin code slot when it completes", + function() + yale_mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + yale_mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + yale_mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + yale_mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 20, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) }) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 251 }):build_test_tx(yale_mock_device.id)) + test.wait_for_events() + + -- a ZW2 module answers the slot read; the admin code is added at 251 + test.socket.zwave:__queue_receive({ yale_mock_device.id, UserCode:Report({ + user_identifier = 251, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "199111", + }) }) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockUsers.users({ { userIndex = 251, userName = "Master Code", userType = "programmingUser" } }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(yale_mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 251, credentialIndex = 251, credentialType = "pin", credentialName = "Master Code" } }, + { visibility = { displayed = false } }))) + test.wait_for_events() + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(yale_mock_device) + end, + min_api_version = 15 + } +) + test.register_coroutine_test( "user_code_report: out-of-band ENABLED code creates user and credential entries", function() diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua new file mode 100644 index 0000000000..b901b4b5d6 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated and device.zwave_manufacturer_id == consts.ADMIN.YALE_MANUFACTURER_ID then + local subdriver = require("yale-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua new file mode 100644 index 0000000000..03dfaeae6b --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/yale-lock/init.lua @@ -0,0 +1,166 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local user_id_status = UserCode.user_id_status + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local cap_handlers = require "lock_handlers.capabilities" + +-- Yale locks keep their admin code in a user code slot that is not one of the lock's user slots. It is exposed +-- like any other credential, owned by a user that has the same index and the admin code userType. + +local function is_admin_credential(device, credential_index) + return credential_index ~= nil and credential_index == tables.admin_credential_index(device) +end + +-- Add the admin code credential and the user that owns it. Both use the admin code slot as their index, +-- since the user index only has meaning inside the capability state. +local function add_admin_entries(device, credential_index, name) + name = name or consts.ADMIN.NAME + local status = tables.add_entry(device, "users", { + userIndex = credential_index, + userName = name, + userType = consts.ADMIN.USER_TYPE, + }, true) + if status ~= consts.COMMAND_RESULT.SUCCESS and status ~= consts.COMMAND_RESULT.OCCUPIED then return status end + return tables.add_entry(device, "credentials", { + userIndex = credential_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = name, + }, true) +end + +-- Record the admin code and, if an addCredential for the admin code is in progress, report the result. A +-- command in progress for any other credential is left alone. +local function admin_code_reported(device) + local admin_index = tables.admin_credential_index(device) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and credential_args.credentialIndex == admin_index then + local status = add_admin_entries(device, admin_index, credential_args.credentialName) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args.userIndex, + credentialIndex = credential_args.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, status, additional_info) + lock_utils.clear_busy_state(device) + else + add_admin_entries(device, admin_index) + end +end + +local function user_code_report(driver, device, cmd) + local credential_index = cmd.args.user_identifier + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local occupied = cmd.args.user_id_status == user_id_status.ENABLED_GRANT_ACCESS or + (cmd.args.user_id_status == user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code) + + -- a report of the admin code slot while it is added, or with no command in progress, records the admin code. + -- Anything else (a delete the lock refused, an update) is handled like any other credential. + if is_admin_credential(device, credential_index) and occupied and + (command_in_progress == nil or command_in_progress == consts.LOCK_CREDENTIALS.ADD) then + admin_code_reported(device) + return + end + + local was_syncing = command_in_progress == consts.SYNC.CODES_FROM_LOCK + zwave_handlers.user_code_report(driver, device, cmd) + if was_syncing and device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) ~= consts.SYNC.CODES_FROM_LOCK then + -- the sync is finished. The admin code slot is outside the lock's user code slots, so it is read separately. + lock_utils.read_admin_code(device) + end +end + +local function notification_report(driver, device, cmd) + if cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL then + local access_control_event = Notification.event.access_control + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + if cmd.args.event == access_control_event.NEW_USER_CODE_ADDED and + is_admin_credential(device, tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level))) then + -- the lock confirms a write to the admin code slot like any other user code (ZW2) + admin_code_reported(device) + return + elseif cmd.args.event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION and + command_in_progress == consts.LOCK_CREDENTIALS.ADD and + is_admin_credential(device, credential_args.credentialIndex) then + -- the "master code changed" event is sent instead of NEW_USER_CODE_ADDED by other modules (ZW3), so it + -- completes an addCredential in progress. + admin_code_reported(device) + return + end + end + zwave_handlers.notification_report(driver, device, cmd) +end + +local function add_credential(driver, device, command) + local cred_index = command.args.userIndex + if not is_admin_credential(device, cred_index) then + cap_handlers.add_credential(driver, device, command) + return + end + + -- only an admin code credential may be written to the admin code slot, and it is not limited by the lock's + -- user slots + if command.args.userType ~= consts.ADMIN.USER_TYPE then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = cred_index, + credentialIndex = cred_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = cred_index, + user_code = command.args.credentialData, + user_id_status = user_id_status.ENABLED_GRANT_ACCESS + })) +end + +local function update_user(driver, device, command) + -- the admin code user cannot be changed to another type, and no other user can be made an admin code user + if (command.args.userType == consts.ADMIN.USER_TYPE) ~= is_admin_credential(device, command.args.userIndex) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND) + return + end + cap_handlers.update_user(driver, device, command) +end + +local yale_lock = { + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report + }, + }, + capability_handlers = { + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.updateUser.NAME] = update_user + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential + } + }, + NAME = "Yale Lock", + can_handle = require("yale-lock.can_handle"), +} + +return yale_lock