From 9a1daa1990f728e3e179976c4e81768354ea73de Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Thu, 30 Apr 2026 12:55:08 -0500 Subject: [PATCH 1/2] CHAD-16364: Update Z-Wave lock capabilities --- .../zwave-lock/profiles/base-lock-tamper.yml | 4 + .../zwave-lock/profiles/base-lock.yml | 4 + drivers/SmartThings/zwave-lock/src/init.lua | 237 +++--- .../zwave-lock/src/keywe-lock/can_handle.lua | 10 +- .../zwave-lock/src/keywe-lock/init.lua | 8 +- .../src/legacy-handlers/can_handle.lua | 12 + .../zwave-lock/src/legacy-handlers/init.lua | 130 +++ .../legacy-handlers/keywe-lock/can_handle.lua | 12 + .../src/legacy-handlers/keywe-lock/init.lua | 71 ++ .../samsung-lock/can_handle.lua | 11 + .../src/legacy-handlers/samsung-lock/init.lua | 96 +++ .../schlage-lock/can_handle.lua | 11 + .../src/legacy-handlers/schlage-lock/init.lua | 178 ++++ .../src/legacy-handlers/sub_drivers.lua | 11 + .../zwave-alarm-v1-lock/can_handle.lua | 10 + .../zwave-alarm-v1-lock/init.lua | 146 ++++ .../src/lock_handlers/capabilities.lua | 252 ++++++ .../src/lock_handlers/zwave_responses.lua | 223 +++++ .../zwave-lock/src/lock_utils/constants.lua | 47 ++ .../zwave-lock/src/lock_utils/tables.lua | 268 ++++++ .../zwave-lock/src/lock_utils/utils.lua | 160 ++++ .../src/samsung-lock/can_handle.lua | 17 +- .../zwave-lock/src/samsung-lock/init.lua | 60 +- .../src/schlage-lock/can_handle.lua | 17 +- .../zwave-lock/src/schlage-lock/init.lua | 133 +-- .../zwave-lock/src/sub_drivers.lua | 1 + .../src/test/test_init_lifecycle_handlers.lua | 284 +++++++ .../zwave-lock/src/test/test_keywe_lock.lua | 84 +- .../src/test/test_keywe_lock_legacy.lua | 101 +++ .../test/test_lock_code_slga_migration.lua | 132 +++ .../test/test_lock_credentials_commands.lua | 480 +++++++++++ .../src/test/test_lock_pre_configured.lua | 514 ++++++++++++ .../zwave-lock/src/test/test_lock_tables.lua | 761 ++++++++++++++++++ .../src/test/test_lock_users_commands.lua | 705 ++++++++++++++++ .../zwave-lock/src/test/test_samsung_lock.lua | 190 +++-- .../src/test/test_samsung_lock_legacy.lua | 183 +++++ .../zwave-lock/src/test/test_schlage_lock.lua | 189 +++-- .../src/test/test_schlage_lock_legacy.lua | 266 ++++++ .../test/test_zwave_lock_code_migration.lua | 251 ------ ...ve_lock.lua => test_zwave_lock_legacy.lua} | 41 +- .../src/test/test_zwave_responses.lua | 366 +++++++++ .../src/zwave-alarm-v1-lock/can_handle.lua | 15 +- .../src/zwave-alarm-v1-lock/init.lua | 100 +-- 43 files changed, 5918 insertions(+), 873 deletions(-) create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua delete mode 100644 drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua rename drivers/SmartThings/zwave-lock/src/test/{test_zwave_lock.lua => test_zwave_lock_legacy.lua} (97%) create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml index 5fbfb13f3d..e3a25ab57a 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: tamperAlert diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml index f4957f9ad0..efb97c8b27 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: refresh diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index c3506c5005..384762fd04 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -1,175 +1,132 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" ---- @type st.zwave.CommandClass -local cc = require "st.zwave.CommandClass" --- @type st.zwave.Driver local ZwaveDriver = require "st.zwave.driver" --- @type st.zwave.defaults local defaults = require "st.zwave.defaults" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) ---- @type st.zwave.CommandClass.Battery -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.CommandClass.Time -local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) -local constants = require "st.zwave.constants" -local utils = require "st.utils" -local json = require "st.json" +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local SCAN_CODES_CHECK_INTERVAL = 30 -local MIGRATION_COMPLETE = "migrationComplete" -local MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped" +local capabilities = require "st.capabilities" -local function periodic_codes_state_verification(driver, device) - local scan_codes_state = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME) - if scan_codes_state == "Scanning" then - driver:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - } - ) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(driver, device) - end - ) - end -end +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local table_utils = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local capability_handlers = require "lock_handlers.capabilities" -local function populate_state_from_data(device) - if device.data.lockCodes ~= nil and device:get_field(MIGRATION_COMPLETE) ~= true then - -- build the lockCodes table - local lockCodes = {} - local lc_data = json.decode(device.data.lockCodes) - for k, v in pairs(lc_data) do - lockCodes[k] = v - end - -- Populate the devices `lockCodes` field - device:set_field(constants.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) - -- Populate the devices state history cache - device.state_cache["main"] = device.state_cache["main"] or {} - device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} - device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} - device:set_field(MIGRATION_COMPLETE, true, { persist = true }) - end -end +local LockLifecycle = {} ---- Builds up initial state for the device ---- ---- @param self st.zwave.Driver ---- @param device st.zwave.Device -local function added_handler(self, device) - populate_state_from_data(device) - if device.data.lockCodes == nil or device:get_field(MIGRATION_RELOAD_SKIPPED) == true then - if (device:supports_capability(capabilities.lockCodes)) then - self:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} }) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(self, device) - end - ) - end - else - device:set_field(MIGRATION_RELOAD_SKIPPED, true, { persist = true }) +function LockLifecycle.device_added(driver, device) + if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then + -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. + -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, + -- and should be migrated manually by the user. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) end - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) - if (device:supports_capability(capabilities.tamperAlert)) then + if device:supports_capability(capabilities.tamperAlert) then device:emit_event(capabilities.tamperAlert.tamper.clear()) end + -- set initial state + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) end -local init_handler = function(driver, device, event) - populate_state_from_data(device) - -- temp fix before this can be changed from being persisted in memory - device:set_field(constants.CODE_STATE, nil, { persist = true }) -end +function LockLifecycle.init(driver, device) + -- Restore users/credentials capability state from the persistent store in case + -- the capability state cache was wiped since the last driver run. + table_utils.restore_from_persistent_store(device) -local do_refresh = function(self, device) - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) -end + local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) + if lock_pins_supported_by_profile and device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) == true then + -- ensure lockCodes capability state is reflected correctly for already migrated devices + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + end ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @param cmd table -local function update_codes(driver, device, cmd) - local delay = 0 - -- args.codes is json - for name, code in pairs(cmd.args.codes) do - -- these seem to come in the format "code[slot#]: code" - local code_slot = tonumber(string.gsub(name, "code", ""), 10) - if (code_slot ~= nil) then - if (code ~= nil and (code ~= "0" and code ~= "")) then - -- code changed - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({ - user_identifier = code_slot, - user_code = code, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) - end) - delay = delay + 2.2 - else - -- code deleted - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) - end) - delay = delay + 2.2 - device.thread:call_with_delay(delay, function () - device:send(UserCode:Get({user_identifier = code_slot})) - end) - delay = delay + 2.2 - end - end + if device:supports_capability(capabilities.tamperAlert) then + -- ensure our user/credential state is accurate to the current device state + device:emit_event(capabilities.tamperAlert.tamper.clear()) end end -local function time_get_handler(driver, device, cmd) - local time = os.date("*t") - device:send_to_component( - Time:Report({ - hour_local_time = time.hour, - minute_local_time = time.min, - second_local_time = time.sec - }), - device:endpoint_to_component(cmd.src_channel) - ) +function LockLifecycle.info_changed(driver, device, event, args) + local profile_switched = device.profile.id ~= args.old_st_store.profile.id + if profile_switched and device:supports_capability(capabilities.lockCodes) then + -- ensure all slga migration steps are run, and that the latest device state is synced to the driver. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + end + -- ensure all requisite initial state is set + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + -- ensure our user/credential state is accurate to the current device state + device.thread:call_with_delay(2, function() lock_utils.sync_device_state(device) end) + end end local driver_template = { - supported_capabilities = { - capabilities.lock, - capabilities.lockCodes, - capabilities.battery, - capabilities.tamperAlert - }, lifecycle_handlers = { - added = added_handler, - init = init_handler, + added = LockLifecycle.device_added, + init = LockLifecycle.init, + infoChanged = LockLifecycle.info_changed, + driverSwitched = LockLifecycle.driver_switched, + }, + zwave_handlers = { + [cc.TIME] = { + [0x01] = zwave_handlers.time_get_handler -- used by DanaLock + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = zwave_handlers.notification_report + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = zwave_handlers.user_code_report, + [UserCode.USERS_NUMBER_REPORT] = zwave_handlers.users_number_report, + } }, capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = capability_handlers.lock, + [capabilities.lock.commands.unlock.NAME] = capability_handlers.unlock, + }, + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = capability_handlers.add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = capability_handlers.update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = capability_handlers.delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = capability_handlers.delete_all_users, + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = capability_handlers.add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = capability_handlers.update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = capability_handlers.delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = capability_handlers.delete_all_credentials, }, [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = do_refresh - } + [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh, + }, }, - zwave_handlers = { - [cc.TIME] = { - [Time.GET] = time_get_handler -- used by DanaLock - } + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.lockUsers, + capabilities.lockCredentials, + capabilities.battery, + capabilities.tamperAlert, }, sub_drivers = require("sub_drivers"), shared_device_thread_enabled = true, diff --git a/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua index d8bcd5756e..fda854593d 100644 --- a/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua +++ b/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua @@ -2,9 +2,13 @@ -- Licensed under the Apache License, Version 2.0 local function can_handle_keywe_lock(opts, self, device, cmd, ...) - local KEYWE_MFR = 0x037B - if device.zwave_manufacturer_id == KEYWE_MFR then - return true, require("keywe-lock") + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated then + local KEYWE_MFR = 0x037B + if device.zwave_manufacturer_id == KEYWE_MFR then + return true, require("legacy-handlers.keywe-lock") + end end return false end diff --git a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua index a51af26e00..455d301473 100644 --- a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua @@ -9,9 +9,8 @@ local Association = (require "st.zwave.CommandClass.Association")({version=2}) local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local access_control_event = Notification.event.access_control -local LockDefaults = require "st.zwave.defaults.lock" -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" local TamperDefaults = require "st.zwave.defaults.tamperAlert" +local zwave_handlers = require "lock_handlers.zwave_responses" local TAMPER_CLEAR_DELAY = 10 @@ -39,8 +38,9 @@ local function notification_report_handler(self, device, cmd) if event ~= nil then device:emit_event(event) else - LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + zwave_handlers.door_operation_event_handler(self, device, cmd) + zwave_handlers.code_event_handler(self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) device.thread:call_with_delay( TAMPER_CLEAR_DELAY, diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua new file mode 100644 index 0000000000..78757208a4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if not slga_migrated then + local subdriver = require("legacy-handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua new file mode 100644 index 0000000000..6afbd6f2f9 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -0,0 +1,130 @@ +-- Copyright 2022 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 LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local init_handler = function(driver, device, event) + local constants = require "st.zwave.constants" + -- temp fix before this can be changed from being persisted in memory + device:set_field(constants.CODE_STATE, nil, { persist = true }) +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd table +local function update_codes(driver, device, cmd) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + local delay = 0 + -- args.codes is json + for name, code in pairs(cmd.args.codes) do + -- these seem to come in the format "code[slot#]: code" + local code_slot = tonumber(string.gsub(name, "code", ""), 10) + if (code_slot ~= nil) then + if (code ~= nil and (code ~= "0" and code ~= "")) then + -- code changed + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({ + user_identifier = code_slot, + user_code = code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) + end) + delay = delay + 2.2 + else + -- code deleted + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) + end) + delay = delay + 2.2 + device.thread:call_with_delay(delay, function () + device:send(UserCode:Get({user_identifier = code_slot})) + end) + delay = delay + 2.2 + end + end + 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 get_lock_codes = LockCodesDefaults.get_lock_codes + local lock_users = {} + local lock_credentials = {} + local lock_codes = get_lock_codes(device) + local ordered_codes = {} + + for code in pairs(lock_codes) do + table.insert(ordered_codes, code) + end + + table.sort(ordered_codes) + for index = 1, #ordered_codes do + local code_slot, code_name = ordered_codes[index], lock_codes[ ordered_codes[index] ] + table.insert(lock_users, {userIndex = index, userType = "guest", userName = code_name}) + table.insert(lock_credentials, {userIndex = index, credentialIndex = tonumber(code_slot), credentialType = "pin"}) + end + + local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME, 4) + local max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME, 10) + local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME, 8) + if (code_length ~= nil) then + max_code_len = code_length + min_code_len = code_length + end + + device:emit_event(capabilities.lockCredentials.minPinCodeLen(min_code_len, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(max_code_len, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + local consts = require("lock_utils.constants") + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore +end + +local using_old_capabilities = { + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.battery, + capabilities.tamperAlert + }, + lifecycle_handlers = { + init = init_handler, + }, + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes, + [capabilities.lockCodes.commands.migrate.NAME] = migrate + }, + }, + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = function(driver, device, cmd) + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + local TamperDefaults = require "st.zwave.defaults.tamperAlert" + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + end + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT], + [UserCode.USERS_NUMBER_REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.USERS_NUMBER_REPORT], + } + }, + sub_drivers = require("legacy-handlers.sub_drivers"), + can_handle = require("legacy-handlers.can_handle"), + NAME = "legacy-handlers" +} + +return using_old_capabilities diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua new file mode 100644 index 0000000000..0970a9c9ed --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_keywe_lock(opts, self, device, cmd, ...) + local KEYWE_MFR = 0x037B + if device.zwave_manufacturer_id == KEYWE_MFR then + return true, require("legacy-handlers.keywe-lock") + end + return false +end + +return can_handle_keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua new file mode 100644 index 0000000000..b40e6658b1 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua @@ -0,0 +1,71 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Association = (require "st.zwave.CommandClass.Association")({version=2}) +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local TAMPER_CLEAR_DELAY = 10 + +local function clear_tamper_if_needed(device) + local current_tamper_state = device:get_latest_state("main", capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME) + if current_tamper_state == "detected" then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_OPEN then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_CLOSED then + event = capabilities.lock.lock.locked() + end + if event ~= nil then + event["data"] = {method = "manual"} + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + device.thread:call_with_delay( + TAMPER_CLEAR_DELAY, + function(d) + clear_tamper_if_needed(device) + end + ) + end +end + +local function do_configure(self, device) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local keywe_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Keywe Lock", + can_handle = require("keywe-lock.can_handle"), +} + +return keywe_lock \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua new file mode 100644 index 0000000000..dc97d03ece --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local SAMSUNG_MFR = 0x022E + if device.zwave_manufacturer_id == SAMSUNG_MFR then + local subdriver = require("legacy-handlers.samsung-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua new file mode 100644 index 0000000000..6e28668b0b --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua @@ -0,0 +1,96 @@ +-- Copyright 2022 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 access_control_event = Notification.event.access_control + +local json = require "dkjson" +local constants = require "st.zwave.constants" + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local get_lock_codes = LockCodesDefaults.get_lock_codes +local clear_code_state = LockCodesDefaults.clear_code_state +local code_deleted = LockCodesDefaults.code_deleted + + +local function get_ongoing_code_set(device) + local code_id + local code_state = device:get_field(constants.CODE_STATE) + if code_state ~= nil then + for key, state in pairs(code_state) do + if state ~= nil then + code_id = key:match("setName(%d)") + end + end + end + return code_id +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.NEW_USER_CODE_ADDED then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + device:send(UserCode:Get({user_identifier = code_id})) + return + end + elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) + clear_code_state(device, code_id) + end + elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- Update Master Code in the same way as in defaults... + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + -- ...and delete rest of them, as lock does + local lock_codes = get_lock_codes(device) + for code_id, _ in pairs(lock_codes) do + if code_id ~= "0" then + code_deleted(device, code_id) + end + end + event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + end +end + +-- Used doConfigure instead of added to not overwrite parent driver's added_handler +local function do_configure(self, device) + -- taken directly from DTH + -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked + device:emit_event(capabilities.lock.lock.unlocked()) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) +end + +local samsung_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Samsung Lock", + can_handle = require("legacy-handlers.samsung-lock.can_handle"), +} + +return samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua new file mode 100644 index 0000000000..e45491efe2 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local SCHLAGE_MFR = 0x003B + if device.zwave_manufacturer_id == SCHLAGE_MFR then + local subdriver = require("legacy-handlers.schlage-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua new file mode 100644 index 0000000000..a6604b01f5 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua @@ -0,0 +1,178 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local constants = require "st.zwave.constants" +local json = require "dkjson" + +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local user_id_status = UserCode.user_id_status +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) + +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds + +local function set_code_length(self, device, cmd) + local length = cmd.args.length + if length >= 4 and length <= 8 then + device:send(Configuration:Set({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = length, + size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size + })) + end +end + +local function reload_all_codes(self, device, cmd) + LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + end +end + +local function set_code(self, device, cmd) + if (cmd.args.codePIN == "") then + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = {cmd.args.codeSlot, cmd.args.codeName}, + }) + else + -- copied from defaults with additional check for Schlage's configuration + if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then + if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end + local code_state = device:get_field(constants.CODE_STATE) + code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName + device:set_field(constants.CODE_STATE, code_state, { persist = true }) + end + local send_set_user_code = function () + device:send(UserCode:Set({ + user_identifier = cmd.args.codeSlot, + user_code = cmd.args.codePIN, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + end + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) + else + send_set_user_code() + end + end +end + +local function do_configure(self, device) + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function basic_set_handler(self, device, cmd) + device:emit_event(cmd.args.value == 0 and capabilities.lock.lock.unlocked() or capabilities.lock.lock.locked()) + device:send(Association:Remove({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function configuration_report(self, device, cmd) + local parameter_number = cmd.args.parameter_number + if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then + local reported_code_length = cmd.args.configuration_value + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length ~= nil and current_code_length ~= reported_code_length then + local all_codes_deleted_mocked_command = Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED + }) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + end + device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + end +end + +local function is_user_code_report_mfr_specific(device, cmd) + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local code_id = cmd.args.user_identifier + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_state = device:get_field(constants.CODE_STATE) + return user_code == "**********" or user_code == nil or (code_state ~= nil and code_state["setName"..cmd.args.user_identifier] ~= nil) + else + return (code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE) or + reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE + end +end + +local function user_code_report_handler(self, device, cmd) + local code_id = cmd.args.user_identifier + if is_user_code_report_mfr_specific(device, cmd) then + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local event + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_name = LockCodesDefaults.get_code_name(device, code_id) + local change_type = LockCodesDefaults.get_change_type(device, code_id) + event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) + event.data = {codeName = code_name} + if code_id ~= 0 then -- ~= MASTER_CODE + LockCodesDefaults.code_set_event(device, code_id, code_name) + end + elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then + local lock_codes = LockCodesDefaults.get_lock_codes(device) + for _code_id, _ in pairs(lock_codes) do + LockCodesDefaults.code_deleted(device, _code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) + else -- user_id_status.STATUS_NOT_AVAILABLE + event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + end + + if event ~= nil then + device:emit_event(event) + end + LockCodesDefaults.clear_code_state(device, code_id) + LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) + else + LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + end +end + +local schlage_lock = { + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, + [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [capabilities.lockCodes.commands.setCode.NAME] = set_code + } + }, + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report_handler + }, + [cc.CONFIGURATION] = { + [Configuration.REPORT] = configuration_report + }, + [cc.BASIC] = { + [Basic.SET] = basic_set_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure, + }, + NAME = "Schlage Lock", + can_handle = require("legacy-handlers.schlage-lock.can_handle"), +} + +return schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua new file mode 100644 index 0000000000..4c94ca5559 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers.zwave-alarm-v1-lock"), + lazy_load_if_possible("legacy-handlers.schlage-lock"), + lazy_load_if_possible("legacy-handlers.samsung-lock"), + lazy_load_if_possible("legacy-handlers.keywe-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua new file mode 100644 index 0000000000..324f83e6f9 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 then + local subdriver = require("legacy-handlers.zwave-alarm-v1-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua new file mode 100644 index 0000000000..5865091852 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua @@ -0,0 +1,146 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Alarm +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.defaults.lockCodes +local lock_code_defaults = require "st.zwave.defaults.lockCodes" +local json = require "dkjson" + +local METHOD = { + KEYPAD = "keypad", + MANUAL = "manual", + COMMAND = "command", + AUTO = "auto" +} + +--- Default handler for alarm command class reports, these were largely OEM-defined +--- +--- This converts alarm V1 reports to correct lock events +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Alarm.Report +local function alarm_report_handler(driver, device, cmd) + local alarm_type = cmd.args.alarm_type + local event = nil + local lock_codes = lock_code_defaults.get_lock_codes(device) + local code_id = nil + if (cmd.args.alarm_level ~= nil) then + code_id = tostring(cmd.args.alarm_level) + end + if (alarm_type == 9 or alarm_type == 17) then + event = capabilities.lock.lock.unknown() + elseif (alarm_type == 16 or alarm_type == 19) then + event = capabilities.lock.lock.unlocked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 18) then + event = capabilities.lock.lock.locked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 21) then + event = capabilities.lock.lock.locked() + if (cmd.args.alarm_level == 2) then + event["data"] = {method = METHOD.MANUAL} + else + event["data"] = {method = METHOD.KEYPAD} + end + elseif (alarm_type == 22) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.MANUAL} + elseif (alarm_type == 23) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 24) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 25) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 26) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 27) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 32) then + -- all user codes deleted + for code_id, _ in pairs(lock_codes) do + lock_code_defaults.code_deleted(device, code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + elseif (alarm_type == 33) then + -- user code deleted + if (code_id ~= nil) then + lock_code_defaults.clear_code_state(device, code_id) + if (lock_codes[code_id] ~= nil) then + lock_code_defaults.code_deleted(device, code_id) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + end + end + elseif (alarm_type == 13 or alarm_type == 112) then + -- user code changed/set + if (code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + local change_type = lock_code_defaults.get_change_type(device, code_id) + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) + code_changed_event["data"] = { codeName = code_name} + lock_code_defaults.code_set_event(device, code_id, code_name) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 34 or alarm_type == 113) then + -- duplicate lock code + if (code_id ~= nil) then + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 130) then + -- batteries replaced + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 161) then + -- tamper alarm + event = capabilities.tamperAlert.tamper.detected() + elseif (alarm_type == 167) then + -- low battery + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 168) then + -- critical battery + event = capabilities.battery.battery(1) + elseif (alarm_type == 169) then + -- battery too low to operate + event = capabilities.battery.battery(0) + end + + if (event ~= nil) then + device:emit_event(event) + end +end + +local zwave_lock = { + zwave_handlers = { + [cc.ALARM] = { + [Alarm.REPORT] = alarm_report_handler + } + }, + NAME = "Z-Wave lock alarm V1", + can_handle = require("legacy-handlers.zwave-alarm-v1-lock.can_handle") +} + +return zwave_lock diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua new file mode 100644 index 0000000000..13d45bc4dd --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -0,0 +1,252 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local consts = 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 CapabilityHandlers = {} + + +-- [[ LOCK USERS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.BUSY) + 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", { + userIndex = next_available_index, + userName = command.args.userName, + userType = command.args.userType, + }) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = next_available_index } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, status, additional_info) +end + +function CapabilityHandlers.update_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.BUSY) + 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 } + ) + end + + 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 + +function CapabilityHandlers.delete_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.BUSY) + return + end + + local associated_credentials = tables.find_all_entries_by(device, "credentials", "userIndex", command.args.userIndex) + for _, associated_credential in ipairs(associated_credentials) do + -- Set busy state with the full user+credential context BEFORE injecting. + -- Injected capability commands are schema-validated, so extra args like userIndex + -- would be stripped. By setting device fields here we preserve the full context. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE, { + userIndex = command.args.userIndex, + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + }) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + } + }) + end + if #associated_credentials == 0 then + -- No associated credentials: delete the user entry directly and report the result + local status = tables.delete_entry(device, "users", command.args.userIndex) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, status, additional_info) + end +end + +function CapabilityHandlers.delete_all_users(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + return + end + -- Set busy state with DELETE_ALL context BEFORE injecting so the response handler + -- knows to clear both tables and emit results for both capabilities. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE_ALL, {}) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteAllCredentials.NAME, + args = { credentialType = consts.CRED_TYPE_PIN } + }) +end + + +-- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_credential(driver, device, command) + 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 + + -- A userIndex of 0 means "auto-assign the next available slot" + local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex + local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + + if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) + return + end + + -- Set busy state and attempt to set the PIN on-device. + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = user_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 = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) +end + +function CapabilityHandlers.update_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) + return + end + + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { + userIndex = command.args.userIndex, + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = command.args.credentialIndex, + user_code = command.args.credentialData, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) +end + +function CapabilityHandlers.delete_credential(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == consts.LOCK_USERS.DELETE then + -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + device:send(UserCode:Set({ + user_identifier = credential_args.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + 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 + -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. + local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) + if not found_cred then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) + 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 + })) + end +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() + local credentials = tables.get_state(device, "credentials") or {} + local delay = 0 + for _, credential in ipairs(credentials) do + device.thread:call_with_delay(delay, function() + device:send(UserCode:Set({ + user_identifier = credential.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + end) + delay = delay + 2 + end + -- Delayed cleanup: ensure all tables are cleared after individual responses complete + device.thread:call_with_delay(delay + 4, function() + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + tables.delete_all_entries(device, "users") + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) + end + tables.delete_all_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) + end + + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + -- Injected by deleteAllUsers; busy state was already set with LOCK_USERS.DELETE_ALL context. + send_clear_commands() + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + else + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE_ALL, command.args) + send_clear_commands() + end +end + + +-- [[ REFRESH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.refresh(driver, device, cmd) + device:send(DoorLock:OperationGet({})) + device:send(Battery:Get({})) + + if device:supports_capability(capabilities.lockCredentials) then + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) or + (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then + device:send(UserCode:UsersNumberGet({})) + end + end +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua new file mode 100644 index 0000000000..32ff924d7b --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -0,0 +1,223 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local socket = require "cosock.socket" + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" + + +local ZwaveHandlers = {} + + +-- [[ USER CODE COMMAND CLASS ]] + +function ZwaveHandlers.user_code_report(driver, device, cmd) + -- zw report values + local credential_index = cmd.args.user_identifier + local user_id_status = cmd.args.user_id_status + + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if (user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or + user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE) then + -- Code slot is now occupied: add or update credential and associated user + if command_in_progress ~= consts.SYNC.CODES_FROM_LOCK then + lock_utils.set_credential_report_helper(device, credential_index) + end + elseif user_id_status == UserCode.user_id_status.AVAILABLE then + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.FAILURE) + lock_utils.clear_busy_state(device) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE or tables.find_entry(device, "credentials", credential_index) then + lock_utils.delete_credential_report_helper(device, credential_index) + end + end + + -- Sync: continue to next code or finish + if credential_index == device:get_field(consts.SYNC.CODE_INDEX) then + local last_slot = tables.get_max_entries(device, "credentials") + if credential_index >= last_slot then + device:set_field(consts.SYNC.CODE_INDEX, nil) + lock_utils.clear_busy_state(device) + else + local next_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 + device:set_field(consts.SYNC.CODE_INDEX, next_index) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = next_index }) + device:send(UserCode:Get({user_identifier = next_index})) + end + return -- don't emit command result during sync + end +end + +function ZwaveHandlers.users_number_report(driver, device, cmd) + device:emit_event(capabilities.lockUsers.totalUsersSupported( + cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.pinUsersSupported( + cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) +end + + +-- [[ NOTIFICATION COMMAND CLASS ]] -- + +function ZwaveHandlers.user_code_event_handler(driver, device, cmd) + if cmd.args.notification_type ~= Notification.notification_type.ACCESS_CONTROL then + return + end + -- zw event values + local event = cmd.args.event + local credential_index = tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level)) + -- cached value from capability event, if applicable + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- This type includes many lock-related events, way too many to list, + -- including user code changes and door operations + 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") + + elseif event == access_control_event.SINGLE_USER_CODE_DELETED then + lock_utils.delete_credential_report_helper(device, credential_index) + + elseif event == access_control_event.NEW_USER_CODE_ADDED then + lock_utils.set_credential_report_helper(device, credential_index) + + elseif event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- aka "master code" changed + if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.SUCCESS) + lock_utils.clear_busy_state(device) + end + + elseif event == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + if command_in_progress then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.DUPLICATE) + lock_utils.clear_busy_state(device) + end + end +end + +function ZwaveHandlers.door_operation_event_handler(driver, device, cmd) + if cmd.args.notification_type ~= Notification.notification_type.ACCESS_CONTROL then + return + end + -- zw event value + local event = cmd.args.event + -- This type includes many lock-related events, way too many to list, + -- including user code changes and door operations + local access_control_event = Notification.event.access_control + -- send lock, unlock, or unknown event based on the event coded + local capability_event + if not (event >= access_control_event.MANUAL_LOCK_OPERATION and event <= access_control_event.LOCK_JAMMED) then + return -- This is the subset of event kinds that we care about for door operation reporting + elseif ((event >= access_control_event.MANUAL_LOCK_OPERATION and + event <= access_control_event.KEYPAD_UNLOCK_OPERATION) or + event == access_control_event.AUTO_LOCK_LOCKED_OPERATION) then + -- even event codes are unlocks, odd event codes are locks + local events = {[0] = capabilities.lock.lock.unlocked(), [1] = capabilities.lock.lock.locked()} + capability_event = events[event & 1] + elseif (event >= access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION and + event <= access_control_event.LOCK_JAMMED) then + capability_event = capabilities.lock.lock.unknown() + else + return -- no lock event to send for this code + end + + local access_control_event_capability_map = { + [access_control_event.MANUAL_UNLOCK_OPERATION] = "manual", + [access_control_event.MANUAL_LOCK_OPERATION] = "manual", + [access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION] = "manual", + [access_control_event.RF_LOCK_OPERATION] = "command", + [access_control_event.RF_UNLOCK_OPERATION] = "command", + [access_control_event.RF_NOT_FULLY_LOCKED_OPERATION] = "command", + [access_control_event.KEYPAD_LOCK_OPERATION] = "keypad", + [access_control_event.KEYPAD_UNLOCK_OPERATION] = "keypad", + [access_control_event.AUTO_LOCK_LOCKED_OPERATION] = "auto", + [access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION] = "auto" + } + + capability_event.data = {} + capability_event.data.method = access_control_event_capability_map[event] + + if (event == access_control_event.MANUAL_UNLOCK_OPERATION and cmd.args.event_parameter == 2) then + capability_event.data.method = "keypad" -- some locks can distinguish being manually locked via keypad + elseif (event == access_control_event.KEYPAD_LOCK_OPERATION or event == access_control_event.KEYPAD_UNLOCK_OPERATION) then + local code_id = tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level)) -- Look up stored lockUsers data if applicable + if device:supports_capability(capabilities.lockUsers) then + local credential = tables.find_entry(device, "credentials", code_id) + if credential then + local user = tables.find_entry(device, "users", credential.userIndex) + capability_event.data.userIndex = credential.userIndex + if user then + capability_event.data.userName = user.userName + capability_event.data.userType = user.userType + end + else + capability_event.data.userIndex = code_id + end + end + end + + -- Delay timer logic to handle duplicate lock state reports + if device:get_latest_state( + "main", + capabilities.lock.ID, + capabilities.lock.lock.ID) == capability_event.value.value then + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) + end + end + + local timer = device:get_field(consts.DELAY_LOCK_EVENT_TIMER) + if timer ~= nil then + device.thread:cancel_timer(timer) + device:set_field(consts.DELAY_LOCK_EVENT_TIMER, nil) + end + + device:emit_event(capability_event) +end + +function ZwaveHandlers.notification_report(driver, device, cmd) + ZwaveHandlers.user_code_event_handler(driver, device, cmd) + ZwaveHandlers.door_operation_event_handler(driver, device, cmd) + -- Tamper events handled by default tamper handler + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) +end + + +-- [[ TIME COMMAND CLASS ]] -- + +function ZwaveHandlers.time_get_handler(driver, device, cmd) + local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) + local time = os.date("*t") + device:send_to_component( + Time:Report({ + hour_local_time = time.hour, + minute_local_time = time.min, + second_local_time = time.sec + }), + device:endpoint_to_component(cmd.src_channel) + ) +end + +return ZwaveHandlers diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua new file mode 100644 index 0000000000..a272160180 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -0,0 +1,47 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lock_constants = {} + +lock_constants.DRIVER_STATE = { + BUSY = "busy", + COMMAND_IN_PROGRESS = "commandInProgress", + CREDENTIAL_ARGS_IN_USE = "currentCredential", + SLGA_MIGRATED = "slgaMigrated", +} + +lock_constants.SYNC = { + CODES_FROM_LOCK = "syncCodesFromLock", + CODE_INDEX = "syncCodeIndex", +} + +lock_constants.COMMAND_RESULT = { + SUCCESS = "success", + FAILURE = "failure", + DUPLICATE = "duplicate", + OCCUPIED = "occupied", + INVALID_COMMAND = "invalidCommand", + RESOURCE_EXHAUSTED = "resourceExhausted", + BUSY = "busy" +} + +lock_constants.LOCK_CREDENTIALS = { + ADD = "addCredential", + UPDATE = "updateCredential", + DELETE = "deleteCredential", + DELETE_ALL = "deleteAllCredentials" +} + +lock_constants.LOCK_USERS = { + ADD = "addUser", + UPDATE = "updateUser", + DELETE = "deleteUser", + DELETE_ALL = "deleteAllUsers" +} + +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 + +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 new file mode 100644 index 0000000000..bfe3562344 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -0,0 +1,268 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT + +local table_utils = {} + +-- DEFS describes how each capability-backed state table is structured: +-- +-- capability SmartThings capability (used for device support checks and to get latest state) +-- attribute Capability attribute function (used to emit state) +-- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table +-- match_key Key used to identify entries in flat tables +-- required_keys Keys that must be non-nil when adding an entry +-- persistent_field device:set_field key used to back up the table across restarts +-- + +local DEFS = { + users = { + capability = capabilities.lockUsers, + attribute = capabilities.lockUsers.users, + max_entries = capabilities.lockUsers.totalUsersSupported, + match_key = "userIndex", + required_keys = {"userIndex", "userType"}, + persistent_field = "persistedUsers", + }, + credentials = { + capability = capabilities.lockCredentials, + attribute = capabilities.lockCredentials.credentials, + max_entries = capabilities.lockCredentials.pinUsersSupported, + match_key = "credentialIndex", + required_keys = {"userIndex", "credentialIndex", "credentialType"}, + persistent_field = "persistedCredentials", + } +} + +-- 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] + if not def then + device.log.error(string.format("table_helpers: unknown table %q", table_name)) + end + return def +end + +-- Validate that an entry table contains all required keys. +local function validate_entry(device, entry, required_keys) + for _, key in ipairs(required_keys or {}) do + if entry[key] == nil then + device.log.error(string.format("table_helpers: entry missing required key %q", key)) + return false + end + end + return true +end + +-- Write the current table contents to the device's persistent field store so that +-- the state survives driver restarts and can be restored if the capability state +-- cache is wiped. +local function persist_table(device, def, data) + device:set_field(def.persistent_field, st_utils.deep_copy(data), { persist = true }) +end + +-- Read the current state for a table and return a deep-copied array. +-- Accepts either a string table name ("users", "credentials") or a DEFS entry directly. +-- Returns nil (with a warning) if the capability is unsupported by the device. +-- When the capability state cache has been wiped (get_latest_state returns nil), +-- falls back to the persistent field store so that callers always receive the +-- last-known state rather than an empty table. +--- @return table[] | nil +function table_utils.get_state(device, name_or_def) + local def = type(name_or_def) == "string" and resolve_table_def(device, name_or_def) or name_or_def + if not def then return nil end + if not device:supports_capability(def.capability, "main") then + device.log.warn(string.format( + "table_helpers: device does not support capability %q", def.capability.ID + )) + return + end + local state = device:get_latest_state("main", def.capability.ID, def.attribute.NAME) + if state ~= nil then + return st_utils.deep_copy(state) + end + -- Capability state cache is absent (e.g. after a hub reboot); fall back to the + -- persistent store so that callers see the last-known table contents. + return st_utils.deep_copy(device:get_field(def.persistent_field) or {}) +end + +-- Find an entry in a named table where the match_key equals value. +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry(device, table_name, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[def.match_key] == value then return entry end + end + return nil +end + +-- Find an entry in a named table where entry[key] equals value (arbitrary key search). +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[key] == value then return entry end + end + return nil +end + +-- Find all entries in a named table where entry[key] equals value (arbitrary key search). +-- Returns an array of matching entries, or an empty array if none found. +function table_utils.find_all_entries_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return {} end + local t = table_utils.get_state(device, def) + if not t then return {} end + local matches = {} + for _, entry in ipairs(t) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches +end + +-- Return the lowest positive integer not yet used as the match_key in the named table. +-- Used to auto-assign the next available slot for a new entry. +function table_utils.next_index(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return 1 end + local t = table_utils.get_state(device, def) or {} + local occupied = {} + for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end + local idx = 1 + while occupied[idx] do idx = idx + 1 end + return idx +end + +function table_utils.get_max_entries(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return end + return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing +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) + 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 + if not validate_entry(device, entry, def.required_keys) then return COMMAND_RESULT.FAILURE end + 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 + device.log.warn(string.format( + "table_helpers: cannot add entry to %q, max entries reached", table_name + )) + return COMMAND_RESULT.RESOURCE_EXHAUSTED + end + + -- Object entry: skip if an entry with the same match_key value already exists + if def.match_key then + for _, existing in ipairs(t) do + if existing[def.match_key] == entry[def.match_key] then + device.log.warn(string.format( + "table_helpers: entry with %s == %s already exists in %q, skipping", + def.match_key, tostring(entry[def.match_key]), table_name + )) + return COMMAND_RESULT.OCCUPIED + end + end + end + + table.insert(t, st_utils.deep_copy(entry)) + + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS +end + + +--- Update fields of an existing entry in a table. +--- The entry to update is identified by the match_key parameter in DEFS. +function table_utils.update_entry(device, table_name, match_value, updates) + device.log.debug("table_helpers: attempting to update entry " .. match_value .. " in " .. table_name .. ": " .. st_utils.stringify_table(updates)) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + for _, entry in ipairs(t) do + if entry[def.match_key] == match_value then + for k, v in pairs(updates) do + entry[k] = v + end + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + + device.log.warn(string.format( + "table_helpers: no entry found in %q with %s == %s", + table_name, def.match_key, tostring(match_value) + )) + return COMMAND_RESULT.FAILURE +end + + +-- Delete an entry from a table. +-- +-- Returns SUCCESS, or FAILURE if nothing matched. +function table_utils.delete_entry(device, table_name, matcher) + device.log.debug("table_helpers: attempting to delete entry " .. matcher .. " from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + local predicate = function(entry) return entry[def.match_key] == matcher end + + for i, entry in ipairs(t) do + if predicate(entry) then + table.remove(t, i) + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + return COMMAND_RESULT.FAILURE +end + +-- Delete all entries from a table. +function table_utils.delete_all_entries(device, table_name) + 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, {}) + return COMMAND_RESULT.SUCCESS +end + +-- Restore capability state from the persistent field store. +-- Called during init to re-emit table events if the capability state cache +-- has been wiped (e.g. after a hub reboot). Only emits for tables that have +-- persisted data, are in a nil state, and whose capability is supported by the device. +function table_utils.restore_from_persistent_store(device) + for _, internal in pairs(DEFS) do + if device:supports_capability(internal.capability, "main") and + device:get_latest_state("main", internal.capability.ID, internal.attribute.NAME) == nil + then + local persisted = st_utils.deep_copy(device:get_field(internal.persistent_field)) + if persisted and #persisted > 0 then + device:emit_event(internal.attribute(persisted, {visibility = {displayed = false}})) + end + end + end +end + +return table_utils diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua new file mode 100644 index 0000000000..64851e485a --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -0,0 +1,160 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" + +local lock_utils = {} + +-- [[ BUSY STATE MANAGEMENT ]] -- + +-- Check if we are currently busy performing a task, or at least 10 seconds have passed since the busy state was last set. +-- If busy, return true. If not busy, clear any stale state and return false. +function lock_utils.is_device_busy(device) + local c_time = os.time() + local busy_since = device:get_field(consts.DRIVER_STATE.BUSY) or false + + if (busy_since == false) or (c_time - busy_since > 10) then + lock_utils.clear_busy_state(device) + return false + end + return true +end + +-- Set states that may be required when in busy state +function lock_utils.set_busy_state(device, command_name, command_args) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) + device:set_field(consts.DRIVER_STATE.BUSY, os.time()) +end + +-- Clear states that were set when in busy state +function lock_utils.clear_busy_state(device) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) + 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 }) + + if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then + device:set_field(consts.SYNC.CODE_INDEX, 1) + end + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(UserCode:Get({user_identifier = device:get_field(consts.SYNC.CODE_INDEX)})) +end + + +-- [[ COMMAND RESULT STATE MANAGEMENT ]] -- + +function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) + local info = additional_info or {} + info.commandName = command_name + info.statusCode = status_code + if capability then + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) + end +end + + +-- [[ HELPERS ]] -- + +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 + event_params = {event_params:byte(1,-1)} + code_id = (#event_params == 1) and event_params[1] or event_params[3] + end + return tostring(code_id) +end + +function lock_utils.set_credential_report_helper(device, credential_index) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + + local result_status + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args.userIndex, + credentialIndex = credential_args.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + credentialName = credential_args.credentialName, + }) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + elseif not tables.find_entry(device, "credentials", credential_index) then + -- credential does not exist and no add/update command in progress, therefore it was added out-of-band + -- check what user slot we have to associate this with, and add a default user + local next_available_index = tables.next_index(device, "users") + if next_available_index <= tables.get_max_entries(device, "users") then + tables.add_entry(device, "users", { + userIndex = next_available_index, + userName = "Guest " .. next_available_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = next_available_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. next_available_index, + }) + end + end + + -- emit command result + if command_in_progress and result_status then + local additional_info = result_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, result_status, additional_info) + lock_utils.clear_busy_state(device) + end +end + +function lock_utils.delete_credential_report_helper(device, credential_index) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + + local user_status, credential_status + if command_in_progress == consts.LOCK_USERS.DELETE then + user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE then + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + else + -- out-of-band deletion, find the credential index and associated user to delete + local credential = tables.find_entry(device, "credentials", credential_index) + if credential then + credential_status = tables.delete_entry(device, "credentials", credential_index) + local associated_user = tables.find_entry_by(device, "users", "userIndex", credential.userIndex) + if associated_user then + tables.delete_entry(device, "users", credential.userIndex) + end + end + end + + -- emit command results + if command_in_progress == consts.LOCK_USERS.DELETE and user_status and credential_status then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + lock_utils.clear_busy_state(device) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE and credential_status then + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + lock_utils.clear_busy_state(device) + end +end + + +return lock_utils diff --git a/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua index e9222cb8fb..002d4c169d 100644 --- a/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua +++ b/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua @@ -1,12 +1,15 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local function can_handle_samsung_lock(opts, self, device, cmd, ...) - local SAMSUNG_MFR = 0x022E - if device.zwave_manufacturer_id == SAMSUNG_MFR then - return true, require("samsung-lock") +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 then + local SAMSUNG_MFR = 0x022E + if device.zwave_manufacturer_id == SAMSUNG_MFR then + local subdriver = require("samsung-lock") + return true, subdriver + end end return false end - -return can_handle_samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua index b2f4f60975..80c3ce25d5 100644 --- a/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua @@ -1,7 +1,6 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -9,28 +8,9 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local access_control_event = Notification.event.access_control -local json = require "dkjson" -local constants = require "st.zwave.constants" - -local LockDefaults = require "st.zwave.defaults.lock" -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" -local get_lock_codes = LockCodesDefaults.get_lock_codes -local clear_code_state = LockCodesDefaults.clear_code_state -local code_deleted = LockCodesDefaults.code_deleted - - -local function get_ongoing_code_set(device) - local code_id - local code_state = device:get_field(constants.CODE_STATE) - if code_state ~= nil then - for key, state in pairs(code_state) do - if state ~= nil then - code_id = key:match("setName(%d)") - end - end - end - return code_id -end +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" local function notification_report_handler(self, device, cmd) local event @@ -39,36 +19,25 @@ local function notification_report_handler(self, device, cmd) if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then event = capabilities.lock.lock.unlocked() elseif event_code == access_control_event.NEW_USER_CODE_ADDED then - local code_id = get_ongoing_code_set(device) - if code_id ~= nil then - device:send(UserCode:Get({user_identifier = code_id})) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and credential_args ~= nil then + device:send(UserCode:Get({ user_identifier = credential_args.credentialIndex })) return end - elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then - local code_id = get_ongoing_code_set(device) - if code_id ~= nil then - event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) - clear_code_state(device, code_id) - end elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then - -- Update Master Code in the same way as in defaults... - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - -- ...and delete rest of them, as lock does - local lock_codes = get_lock_codes(device) - for code_id, _ in pairs(lock_codes) do - if code_id ~= "0" then - code_deleted(device, code_id) - end - end - event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + -- All other codes are deleted when the master code is changed + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") + return end end if event ~= nil then device:emit_event(event) else - LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + zwave_handlers.door_operation_event_handler(self, device, cmd) + zwave_handlers.code_event_handler(self, device, cmd) end end @@ -77,7 +46,6 @@ local function do_configure(self, device) -- taken directly from DTH -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked device:emit_event(capabilities.lock.lock.unlocked()) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) end local samsung_lock = { diff --git a/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua index e9f3cfb84c..d57135920d 100644 --- a/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua @@ -1,12 +1,15 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local function can_handle_schlage_lock(opts, self, device, cmd, ...) - local SCHLAGE_MFR = 0x003B - if device.zwave_manufacturer_id == SCHLAGE_MFR then - return true, require("schlage-lock") +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 then + local SCHLAGE_MFR = 0x003B + if device.zwave_manufacturer_id == SCHLAGE_MFR then + local subdriver = require("schlage-lock") + return true, subdriver + end end return false end - -return can_handle_schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua index 6b22049beb..ee04fe42a0 100644 --- a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua @@ -1,77 +1,22 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" local constants = require "st.zwave.constants" -local json = require "dkjson" local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local user_id_status = UserCode.user_id_status -local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local access_control_event = Notification.event.access_control local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) local Association = (require "st.zwave.CommandClass.Association")({version=1}) -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local cap_handlers = require "lock_handlers.capabilities" local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} -local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds - -local function set_code_length(self, device, cmd) - local length = cmd.args.length - if length >= 4 and length <= 8 then - device:send(Configuration:Set({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, - configuration_value = length, - size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size - })) - end -end - -local function reload_all_codes(self, device, cmd) - LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - if current_code_length == nil then - device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) - end -end - -local function set_code(self, device, cmd) - if (cmd.args.codePIN == "") then - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {cmd.args.codeSlot, cmd.args.codeName}, - }) - else - -- copied from defaults with additional check for Schlage's configuration - if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then - if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end - local code_state = device:get_field(constants.CODE_STATE) - code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName - device:set_field(constants.CODE_STATE, code_state, { persist = true }) - end - local send_set_user_code = function () - device:send(UserCode:Set({ - user_identifier = cmd.args.codeSlot, - user_code = cmd.args.codePIN, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) - ) - end - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - if current_code_length == nil then - device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) - device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) - else - send_set_user_code() - end - end -end - local function do_configure(self, device) device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) @@ -86,15 +31,14 @@ local function configuration_report(self, device, cmd) local parameter_number = cmd.args.parameter_number if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then local reported_code_length = cmd.args.configuration_value - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) if current_code_length ~= nil and current_code_length ~= reported_code_length then - local all_codes_deleted_mocked_command = Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = access_control_event.ALL_USER_CODES_DELETED - }) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + -- when the code length is changed, all the codes have been wiped + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") end - device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(reported_code_length)) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(reported_code_length)) end end @@ -114,49 +58,35 @@ local function is_user_code_report_mfr_specific(device, cmd) end local function user_code_report_handler(self, device, cmd) - local code_id = cmd.args.user_identifier + local credential_index = cmd.args.user_identifier if is_user_code_report_mfr_specific(device, cmd) then local reported_user_id_status = cmd.args.user_id_status - local user_code = cmd.args.user_code - local event - if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 - (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then - local code_name = LockCodesDefaults.get_code_name(device, code_id) - local change_type = LockCodesDefaults.get_change_type(device, code_id) - event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) - event.data = {codeName = code_name} - if code_id ~= 0 then -- ~= MASTER_CODE - LockCodesDefaults.code_set_event(device, code_id, code_name) - end - elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then - local lock_codes = LockCodesDefaults.get_lock_codes(device) - for _code_id, _ in pairs(lock_codes) do - LockCodesDefaults.code_deleted(device, _code_id) - end - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) - else -- user_id_status.STATUS_NOT_AVAILABLE - event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + if credential_index == 0 and reported_user_id_status == user_id_status.AVAILABLE then + -- master code changed, clear all credentials + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") end + else + zwave_handlers.user_code_report(self, device, cmd) + end +end - if event ~= nil then - device:emit_event(event) - end - LockCodesDefaults.clear_code_state(device, code_id) - LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) +local function add_credential_handler(self, device, cmd) + local DEFAULT_COMMANDS_DELAY = 4.2 + local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) + local base_handler = function() + cap_handlers.add_credential(self, device, cmd) + end + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, base_handler) else - LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + base_handler() end end local schlage_lock = { - capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, - [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [capabilities.lockCodes.commands.setCode.NAME] = set_code - } - }, zwave_handlers = { [cc.USER_CODE] = { [UserCode.REPORT] = user_code_report_handler @@ -168,6 +98,11 @@ local schlage_lock = { [Basic.SET] = basic_set_handler } }, + capability_handlers = { + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential_handler + } + }, lifecycle_handlers = { doConfigure = do_configure, }, diff --git a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua index 627fe99514..af5dc4d41f 100644 --- a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua @@ -7,5 +7,6 @@ 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("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 new file mode 100644 index 0000000000..1aa5b753ff --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -0,0 +1,284 @@ +-- 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, +}) + +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" }) + + -- Migrated event emitted for TYPED+lockCodes device + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler: + -- DoorLock:OperationGet, Battery:Get, UserCode:UsersNumberGet (no cached values) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_typed.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_typed.id)) + test.wait_for_events() + + assert( + mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be true after added fires for a TYPED device" + ) + end, + { test_init = make_test_init(mock_device_typed) } +) + +test.register_coroutine_test( + "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", + function() + 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) } +) + +-- ============================================================================ +-- infoChanged (info_changed) +-- Each test uses a per-test fresh device (upvalue pattern) to avoid +-- raw_st_data contamination from generate_info_changed across tests. +-- init is triggered first so the driver loads the device into device_cache, +-- allowing infoChanged to correctly identify the old profile. +-- ============================================================================ + +do + local dev + test.register_coroutine_test( + "infoChanged: switching from non-lockCodes to lockCodes+lockCredentials profile triggers full SLGA migration", + function() + -- Warm up device_cache with the original (lock-battery) profile via init. + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- Switch to base-lock (lockCodes + lockCredentials) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.device_lifecycle:__queue_receive( + dev:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") }) + ) + -- Migration events + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + -- Refresh sends: DoorLock:OperationGet, Battery:Get, UserCode:UsersNumberGet + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(dev.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(dev.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(dev.id)) + test.wait_for_events() + + assert( + dev:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be set after infoChanged profile switch" + ) + + -- Delayed sync_device_state (2 s) sends UserCode:Get(1) + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 1 }):build_test_tx(dev.id)) + test.wait_for_events() + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + zwave_endpoints = zwave_lock_endpoints, + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +do + local dev + test.register_coroutine_test( + "infoChanged: no profile change does nothing", + function() + -- Warm up device_cache + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- infoChanged with no profile change + test.socket.device_lifecycle:__queue_receive(dev:generate_info_changed({})) + test.wait_for_events() + -- No capability events, no z-wave sends expected + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +do + local dev + test.register_coroutine_test( + "infoChanged: profile switched away from lockCodes (to non-lockCodes profile) does nothing", + function() + -- Warm up device_cache with base-lock + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- Switch to lock-battery (no lockCodes) + test.socket.device_lifecycle:__queue_receive( + dev:generate_info_changed({ profile = t_utils.get_profile_definition("lock-battery.yml") }) + ) + test.wait_for_events() + -- profile_switched is true but new profile has no lockCodes → no migration events + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +-- ============================================================================ +-- 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) } +) + +-- ============================================================================ +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua index 09d59f4861..f6554141b0 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua @@ -1,13 +1,17 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local lock_utils = require "lock_utils.constants".DRIVER_STATE + +test.disable_startup_messages() local KEYWE_MANUFACTURER_ID = 0x037B local KEYWE_PRODUCT_TYPE = 0x0002 @@ -24,78 +28,70 @@ local zwave_lock_endpoints = { local mock_device = test.mock_device.build_test_zwave_device( { - profile = t_utils.get_profile_definition("base-lock.yml"), + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + _provisioning_state = "TYPED", zwave_endpoints = zwave_lock_endpoints, zwave_manufacturer_id = KEYWE_MANUFACTURER_ID, zwave_product_type = KEYWE_PRODUCT_TYPE, - zwave_product_id = KEYWE_PRODUCT_ID + zwave_product_id = KEYWE_PRODUCT_ID, } ) local function test_init() test.mock_device.add_test_device(mock_device) end + test.set_test_init_function(test_init) +local function added() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + + test.socket.zwave:__expect_send( + DoorLock:OperationGet({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + Battery:Get({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") +end + test.register_coroutine_test( "Door Lock Operation Reports unlocked should be handled", function() + added() test.socket.zwave:__queue_receive({mock_device.id, DoorLock:OperationReport({door_lock_mode = 0x00}) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked())) - end, - { - min_api_version = 17 - } + end ) test.register_coroutine_test( "Door Lock Operation Reports locked should be handled", function() + added() test.socket.zwave:__queue_receive({mock_device.id, DoorLock:OperationReport({door_lock_mode = 0xFF}) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked())) - end, - { - min_api_version = 17 - } + end ) -test.register_message_test( +test.register_coroutine_test( "Lock notification reporting should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Notification:Report({notification_type = 6, event = 24}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Notification:Report({notification_type = 6, event = 25}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) - } - }, - { - min_api_version = 17 - } + function() + added() + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({notification_type = 6, event = 24}) } ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({notification_type = 6, event = 25}) } ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}}))) + end ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua new file mode 100644 index 0000000000..a5c7aee7d4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua @@ -0,0 +1,101 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + + +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) + +local KEYWE_MANUFACTURER_ID = 0x037B +local KEYWE_PRODUCT_TYPE = 0x0002 +local KEYWE_PRODUCT_ID = 0x0001 + +-- supported comand classes +local zwave_lock_endpoints = { + { + command_classes = { + {value = DoorLock} + } + } +} + +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 = KEYWE_MANUFACTURER_ID, + zwave_product_type = KEYWE_PRODUCT_TYPE, + zwave_product_id = KEYWE_PRODUCT_ID + } +) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Door Lock Operation Reports unlocked should be handled", + function() + test.socket.zwave:__queue_receive({mock_device.id, + DoorLock:OperationReport({door_lock_mode = 0x00}) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Door Lock Operation Reports locked should be handled", + function() + test.socket.zwave:__queue_receive({mock_device.id, + DoorLock:OperationReport({door_lock_mode = 0xFF}) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked())) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Lock notification reporting should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Notification:Report({notification_type = 6, event = 24}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Notification:Report({notification_type = 6, event = 25}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) + } + }, + { + min_api_version = 17 + } +) + +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 new file mode 100644 index 0000000000..1716cf8706 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -0,0 +1,132 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) +local t_utils = require "integration_test.utils" +--- @type st.zwave.constants +local constants = require "st.zwave.constants" +local lock_utils = require "lock_utils.constants".DRIVER_STATE + +local SCHLAGE_MANUFACTURER_ID = 0x003B +local SCHLAGE_PRODUCT_TYPE = 0x0002 +local SCHLAGE_PRODUCT_ID = 0x0469 + +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-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } +) + +local schlage_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 = 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 init_code_slot(slot_number, name, device) + local lock_codes = device.persistent_store[constants.LOCK_CODES] + if lock_codes == nil then + lock_codes = {} + device.persistent_store[constants.LOCK_CODES] = lock_codes + end + lock_codes[tostring(slot_number)] = name +end + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(schlage_mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + init_code_slot(1, "Zach", mock_device) + init_code_slot(5, "Steven", mock_device) + -- setup codes + test.socket.zwave:__queue_receive({mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + 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( + "Migrate new device", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(8, { 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.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(8, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + 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( + "Schlage-Lock device called 'migrate' command, validate codeLength is being properly set", + function() + init_code_slot(1, "Zach", schlage_mock_device) + init_code_slot(5, "Steven", schlage_mock_device) + -- setup codes + test.socket.zwave:__queue_receive({schlage_mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.zwave:__queue_receive({schlage_mock_device.id, Configuration:Report({ parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, configuration_value = 6 })}) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ schlage_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(schlage_mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua new file mode 100644 index 0000000000..96b192a03d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua @@ -0,0 +1,480 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockCredentials capability commands: +-- addCredential, updateCredential, deleteCredential, deleteAllCredentials + +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 table_utils = require "lock_utils.tables" +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 access_control_event = Notification.event.access_control + +test.disable_startup_messages() + + +if table_utils.find_all_entries_by == nil then + function table_utils.find_all_entries_by(device, table_name, key, value) + local entries = table_utils.get_state(device, table_name) or {} + local matches = {} + for _, entry in ipairs(entries) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches + end +end + + +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 mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + mock_device.get_latest_state = function(_, component_id, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(component_id, capability_id, attribute_name)] + if value == nil then + if capability_id == capabilities.lockCredentials.ID and attribute_name == capabilities.lockCredentials.credentials.NAME then + value = mock_device.persistent_store.persistedCredentials + elseif capability_id == capabilities.lockUsers.ID and attribute_name == capabilities.lockUsers.users.NAME then + value = mock_device.persistent_store.persistedUsers + end + end + if value == nil then return default_value end + return value + end + local original_set_field = mock_device.set_field + mock_device.set_field = function(_, key, value, opts) + if opts and opts.persist then + mock_device.persistent_store[key] = value + else + mock_device.transient_store[key] = value + end + if original_set_field then original_set_field(mock_device, key, value, opts) end + end + mock_device.emit_event = function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end +end + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + mock_device.persistent_store = mock_device.persistent_store or {} + mock_device.transient_store = mock_device.transient_store or {} + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function queue_user_code_added(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(user_identifier), + }) }) +end + +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + local event = capabilities.lockUsers.users(so_far, { visibility = { displayed = false } }) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + end + mock_device.persistent_store.persistedUsers = entries + test.wait_for_events() +end + +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + local event = capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } }) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + end + mock_device.persistent_store.persistedCredentials = entries + test.wait_for_events() +end + +-- ============================================================================ +-- addCredential +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", + function() + -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + -- Expect SetPINCode to be sent to the lock + expect_set_pin_code(1, "1234") + test.wait_for_events() + + -- Lock responds with SUCCESS + queue_user_code_added(1) + + -- Handler adds the credential to the credentials table. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + -- commandResult with userIndex and credentialIndex + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits duplicate when the lock returns DUPLICATE_CODE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + expect_set_pin_code(1, "1234") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE, event_parameter = string.char(1) }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: returns busy when another operation is already in progress", + function() + -- Put the device into busy state by starting an addCredential operation + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + expect_set_pin_code(1, "1234") + test.wait_for_events() + + -- Second addCredential while first is still pending → should get busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: returns failure when cached credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin9" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns failure immediately when the credential does not exist in the table", + function() + -- No credentials seeded — credentialIndex 99 does not exist + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + -- No z-wave message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin2222" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: returns failure when credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure when credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure immediately when the credentialIndex does not exist in the table", + function() + -- No credentials seeded — index 5 is unknown + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 5, "pin" } }, + }) + + -- No z-wave messages should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials: sends ClearAllPINCodes and emits success when the lock returns PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: emits busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- First deleteAllCredentials starts the z-wave flow (device is now busy) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + -- deleteAllCredentials sends individual UserCode:Set AVAILABLE commands + test.wait_for_events() + + -- Second deleteAllCredentials while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() 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 new file mode 100644 index 0000000000..0895ff3215 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua @@ -0,0 +1,514 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for lockUsers and lockCredentials commands with state +-- pre-configured before each test. Two users and two credentials are seeded +-- at the start of every test so tests can focus on the various response states +-- produced by the z-wave response handlers in commands.lua. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +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 access_control_event = Notification.event.access_control +local json = require "st.json" + + +test.disable_startup_messages() +if table_utils.find_all_entries_by == nil then + function table_utils.find_all_entries_by(device, table_name, key, value) + local entries = table_utils.get_state(device, table_name) or {} + local matches = {} + for _, entry in ipairs(entries) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches + end +end + + +-- ── Shared device ────────────────────────────────────────────────────────── + +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, +}) + +-- Lightweight mock state so that table_utils functions work on mock_device +-- before the driver has lazily initialised the wrapped device. After the +-- driver processes its first message (wrapped_init), MockDevice.__index +-- delegates every field access to the real driver device, so the overrides +-- below are only ever active during the pre-init seeding phase. +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) + + -- get_state guards with device:supports_capability; always return true here. + rawset(mock_device, "supports_capability", function() return true end) + + -- get_state / get_max_entries use device:get_latest_state to read + -- capability attribute values from the state cache. + rawset(mock_device, "get_latest_state", + function(_, component_id, capability_id, attribute_name, default_value) + local key = mock_state_key(component_id, capability_id, attribute_name) + local value = mock_latest_state[key] + if value == nil then return default_value end + return value + end + ) + + -- add_entry / delete_entry / update_entry all call device:emit_event. + -- Forward to the capability socket so __expect_send checks pass, and + -- keep mock_latest_state in sync so that successive get_state calls + -- within the same seeding loop see the growing list. + rawset(mock_device, "emit_event", function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end) +end + +local function test_init() + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function expect_clear_pin_code(user_identifier) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(mock_device.id) + ) +end + +local function queue_user_code_added(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(user_identifier), + }) }) +end + +local function queue_user_code_deleted(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.SINGLE_USER_CODE_DELETED, + event_parameter = string.char(user_identifier), + }) }) +end + +-- ── Seeding helpers ──────────────────────────────────────────────────────── + +local INITIAL_USERS = { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, +} +local INITIAL_CREDS = { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, +} + +-- Seed a list of entries into a named table, consuming the resulting events. +local function seed_table(attribute_fn, table_name, entries) + local accumulated = {} + for _, entry in ipairs(entries) do + table.insert(accumulated, entry) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + attribute_fn(accumulated, { visibility = { displayed = false } })) + ) + assert( + table_utils.add_entry(mock_device, table_name, entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_table: add_entry failed for " .. table_name + ) + end + test.wait_for_events() +end + +-- Pre-configure each test with 2 users and 2 credentials. +local function setup_state() + seed_table(capabilities.lockUsers.users, "users", INITIAL_USERS) + seed_table(capabilities.lockCredentials.credentials, "credentials", INITIAL_CREDS) +end + +-- ============================================================================ +-- addUser — pre-configured device (2 users already present) +-- ============================================================================ + +test.register_coroutine_test( + "addUser (pre-configured): assigns the next available index (3) when two users already exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser — pre-configured device +-- ============================================================================ + +test.register_coroutine_test( + "updateUser (pre-configured): updates Alice's name and emits success with userIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceRenamed", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceRenamed", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser (pre-configured): returns failure for a userIndex that does not exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { 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 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser with associated credential — exercises clear_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser (pre-configured, PASS): removes both user and credential and emits success for each", + function() + setup_state() + + -- Delete user 1 who has credential 1 → driver injects deleteCredential → ClearPINCode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + expect_clear_pin_code(1) + test.wait_for_events() + + queue_user_code_deleted(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- addCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "addCredential (pre-configured): succeeds for a new slot and emits userIndex + credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03", "Guest 3" } }, + }) + + expect_set_pin_code(3, "pin03") + test.wait_for_events() + + queue_user_code_added(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits duplicate when the lock rejects with DUPLICATE_CODE", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin01" } }, + }) + + expect_set_pin_code(3, "pin01") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE, event_parameter = string.char(1) }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits failure when the lock returns available", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + expect_set_pin_code(3, "pin03") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, UserCode:Report({ user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential (pre-configured): succeeds and emits success with userIndex and credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin1" } }, + }) + + expect_set_pin_code(1, "newPin1") + test.wait_for_events() + + queue_user_code_added(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential (pre-configured): returns failure immediately for a non-existent credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential standalone (lockCredentials.DELETE path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential (pre-configured, PASS): removes credential and emits success with indices", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + expect_clear_pin_code(1) + test.wait_for_events() + + queue_user_code_deleted(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua new file mode 100644 index 0000000000..63f846cf49 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua @@ -0,0 +1,761 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Unit tests for lock_utils/tables.lua +-- Tests directly call table_utils functions and verify both return values +-- and emitted capability events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local json = require "st.json" +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +test.disable_startup_messages() + +-- --------------------------------------------------------------------------- +-- Shared mock device +-- --------------------------------------------------------------------------- + +local zw = require "st.zwave" + +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 mock_latest_state = {} +local mock_persistent_fields = {} +local mock_transient_fields = {} + +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_table_utils_device_mocks() + mock_latest_state = {} + mock_persistent_fields = {} + mock_transient_fields = {} + mock_device.supports_capability = function() return true end + mock_device.log = { error = function() end, warn = function() end } + mock_device.get_latest_state = function(_, component_id, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(component_id, capability_id, attribute_name)] + if value == nil then return default_value end + return value + end + mock_device.emit_event = function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end + mock_device.set_field = function(_, key, value, opts) + if opts and opts.persist then + mock_persistent_fields[key] = value + else + mock_transient_fields[key] = value + end + end + mock_device.get_field = function(_, key) + if mock_transient_fields[key] ~= nil then return mock_transient_fields[key] end + return mock_persistent_fields[key] + end + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) +end + + +local function test_init() + test.mock_device.add_test_device(mock_device) + install_table_utils_device_mocks() +end +test.set_test_init_function(test_init) + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- Seed the users table with `entries` and consume the resulting emit_events. +-- After this call the state cache has those entries and the socket is clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + -- Build the expected post-insert table up to this entry. + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for entry userIndex=" .. tostring(entry.userIndex)) + end +end + +-- Seed the credentials table with `entries` and consume the resulting emit_events. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for entry credentialIndex=" .. tostring(entry.credentialIndex)) + end +end + +-- =========================================================================== +-- add_entry +-- =========================================================================== + +test.register_coroutine_test( + "add_entry: adds a new user entry and emits the updated table", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns OCCUPIED when an entry with the same userIndex already exists", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- Attempt to add a different entry with the same userIndex (match_key) + local duplicate = { userIndex = 1, userType = "unrestricted", userName = "Bob" } + local result = table_utils.add_entry(mock_device, "users", duplicate) + assert(result == constants.COMMAND_RESULT.OCCUPIED, + "Expected OCCUPIED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns RESOURCE_EXHAUSTED when table is at max capacity", + function() + local entries = {} + for i = 1, 20 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local overflow = { userIndex = 21, userType = "guest", userName = "Overflow" } + local result = table_utils.add_entry(mock_device, "users", overflow) + assert(result == constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + "Expected RESOURCE_EXHAUSTED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE when a required key is missing", + function() + -- userType is required for users table + local incomplete = { userIndex = 1 } + local result = table_utils.add_entry(mock_device, "users", incomplete) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.add_entry(mock_device, "nonexistent_table", + { userIndex = 1, userType = "guest" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: adds a credential entry and emits updated credentials table", + function() + local entry = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- update_entry +-- =========================================================================== + +test.register_coroutine_test( + "update_entry: updates an existing user entry and emits updated table", + function() + local original = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ original }) + + local expected_after_update = { + { userIndex = 1, userType = "adminMember", userName = "Alice_Updated" } + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_after_update, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 1, + { userName = "Alice_Updated", userType = "adminMember" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.update_entry(mock_device, "users", 99, + { userName = "Ghost" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: only updates the specified fields, leaves others intact", + function() + local entry1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local entry2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ entry1, entry2 }) + + -- Update only userName of entry 2; userType should remain "guest" + local expected = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 2, userType = "guest", userName = "Bob_Renamed" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 2, { userName = "Bob_Renamed" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.update_entry(mock_device, "bad_table", 1, { userName = "X" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_entry +-- =========================================================================== + +test.register_coroutine_test( + "delete_entry: deletes an existing entry and returns COMMAND_RESULT.SUCCESS", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- After deletion the table is empty + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 1) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.delete_entry(mock_device, "users", 99) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: remaining entries are intact after a deletion", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + -- Delete the middle entry; expect e1 and e3 remain + local expected_remaining = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 3, userType = "guest", userName = "Carol" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_remaining, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 2) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_entry(mock_device, "bad_table", 1) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_all_entries +-- =========================================================================== + +test.register_coroutine_test( + "delete_all_entries: emits an empty users table and returns SUCCESS", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: returns SUCCESS even when the table is already empty", + function() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_all_entries(mock_device, "bad_table") + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: emits an empty credentials table and returns SUCCESS", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + seed_credentials({ cred }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "credentials") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- find_entry +-- =========================================================================== + +test.register_coroutine_test( + "find_entry: returns the matching entry when it exists", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry(mock_device, "users", 2) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + assert(result.userName == "Bob", + "Expected userName == 'Bob', got: " .. tostring(result.userName)) + end +) + +test.register_coroutine_test( + "find_entry: returns nil when no entry matches the value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry(mock_device, "users", 99) + assert(result == nil, + "Expected nil for missing entry, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry: finds a credential entry by credentialIndex", + function() + local cred = { userIndex = 1, credentialIndex = 5, credentialType = "pin" } + seed_credentials({ cred }) + + local result = table_utils.find_entry(mock_device, "credentials", 5) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.credentialIndex == 5, + "Expected credentialIndex == 5, got: " .. tostring(result.credentialIndex)) + end +) + +-- =========================================================================== +-- find_entry_by +-- =========================================================================== + +test.register_coroutine_test( + "find_entry_by: returns the entry matching an arbitrary key", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "adminMember", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Bob") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns nil when no entry matches", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Nobody") + assert(result == nil, + "Expected nil for unmatched key/value, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns the first matching entry when multiple entries share the same value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userType", "guest") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 1, + "Expected first match userIndex == 1, got: " .. tostring(result.userIndex)) + end +) + +-- =========================================================================== +-- next_index +-- =========================================================================== + +test.register_coroutine_test( + "next_index: returns 1 when the table is empty", + function() + local result = table_utils.next_index(mock_device, "users") + assert(result == 1, + "Expected 1 for empty table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns the next sequential index after a contiguous range", + function() + local entries = {} + for i = 1, 3 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 4, + "Expected 4 after indices 1-3, got: " .. tostring(result)) + end +) + +-- this should not happen during normal operation. +test.register_coroutine_test( + "next_index: returns the lowest gap when indices are non-contiguous", + function() + -- Insert entries at indices 1 and 3, leaving a gap at 2 + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e3 }) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 2, + "Expected 2 as the lowest gap, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- get_max_entries +-- =========================================================================== + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "users") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 for the credentials table when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "credentials") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- Persistence — mutations write to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: add_entry writes the new users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "users", entry) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 1 and persisted[1].userName == "Alice", + "Persisted user data does not match the added entry") + end +) + +test.register_coroutine_test( + "persist: update_entry writes the updated users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userType = "adminMember", userName = "Alice" } }, + { visibility = { displayed = false } })) + ) + table_utils.update_entry(mock_device, "users", 1, { userType = "adminMember" }) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after update") + assert(persisted[1].userType == "adminMember", + "Persisted user type was not updated, got: " .. tostring(persisted[1].userType)) + end +) + +test.register_coroutine_test( + "persist: delete_entry removes the entry from the persistent store immediately", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ e2 }, { visibility = { displayed = false } })) + ) + table_utils.delete_entry(mock_device, "users", 1) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after delete, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 2, + "Expected remaining user to have userIndex == 2, got: " .. tostring(persisted[1].userIndex)) + end +) + +test.register_coroutine_test( + "persist: delete_all_entries writes an empty table to the persistent store", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + table_utils.delete_all_entries(mock_device, "users") + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 0, + "Expected empty persistent store after delete_all, got: " .. tostring(persisted and #persisted)) + end +) + +test.register_coroutine_test( + "persist: add_entry writes the new credentials table to the persistent store immediately", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "credentials", cred) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedCredentials")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted credential, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].credentialIndex == 1, + "Persisted credential index does not match") + end +) + +-- =========================================================================== +-- Persistence — get_state falls back to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: get_state returns data from the persistent store when capability state cache is absent", + function() + -- The device was loaded with a pre-seeded persistent field (set in test_init + -- below), but no capability event has been emitted for users, so get_latest_state + -- returns nil. get_state must fall back to the persistent store. + local state = table_utils.get_state(mock_device, "users") + assert(type(state) == "table" and #state == 1, + "Expected 1 user from persistent-store fallback, got: " .. tostring(state and #state)) + assert(state[1].userIndex == 1 and state[1].userName == "Alice", + "Fallback data does not match pre-seeded persistent entry") + end, + { + test_init = function() + -- Pre-seed persistent store BEFORE add_test_device so that wrapped_init + -- copies the field into the device's persistent_store on startup. + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +-- =========================================================================== +-- Persistence — restore_from_persistent_store re-emits stored tables +-- =========================================================================== + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits users capability event for stored data", + function() + local user = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ user }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + end, + { + test_init = function() + install_table_utils_device_mocks() + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits credentials capability event for stored data", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + end, + { + test_init = function() + install_table_utils_device_mocks() + mock_device:set_field( + "persistedCredentials", + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store is a no-op when the persistent store is empty", + function() + -- No capability events expected when there is nothing in the persistent store. + table_utils.restore_from_persistent_store(mock_device) + end +) + +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 new file mode 100644 index 0000000000..da39c8c692 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua @@ -0,0 +1,705 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockUsers capability commands: +-- addUser, updateUser, deleteUser, deleteAllUsers + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local json = require "st.json" + + +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, +}) + +-- ── helpers ──────────────────────────────────────────────────────────────── + +-- Lightweight mock state so that table_utils functions work on mock_device +-- before the driver has lazily initialised the wrapped device. After the +-- driver processes its first message (wrapped_init), MockDevice.__index +-- delegates every field access to the real driver device, so the overrides +-- below are only ever active during the pre-init seeding phase. +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) + + -- get_state guards with device:supports_capability; always return true here. + rawset(mock_device, "supports_capability", function() return true end) + + -- get_state / get_max_entries use device:get_latest_state to read + -- capability attribute values from the state cache. + rawset(mock_device, "get_latest_state", + function(_, component_id, capability_id, attribute_name, default_value) + local key = mock_state_key(component_id, capability_id, attribute_name) + local value = mock_latest_state[key] + if value == nil then return default_value end + return value + end + ) + + -- add_entry / delete_entry / update_entry all call device:emit_event. + -- Forward to the capability socket so __expect_send checks pass, and + -- keep mock_latest_state in sync so that successive get_state calls + -- within the same seeding loop see the growing list. + rawset(mock_device, "emit_event", function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end) +end + +local function test_init() + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function expect_clear_pin_code(user_identifier) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(mock_device.id) + ) +end + +-- Directly insert users into the device state via table_utils (mirrors test_lock_tables.lua). +-- Consumes the resulting capability events so the socket queue stays clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Directly insert credentials into the device state. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- Set totalUsersSupported (and pinUsersSupported) by injecting the Z-Wave +-- UsersNumberReport that the real users_number_report handler processes. +-- Using the Z-Wave path is essential: it goes through the driver's normal +-- emit_component_event path and populates the driver device's state cache, +-- which is what get_max_entries reads when enforcing the slot limit. +local function set_total_users_supported(n) + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:UsersNumberReport({ supported_users = n }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(n, { state_change = true, visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(n, { state_change = true, visibility = { displayed = false } })) + ) + test.wait_for_events() +end + +-- ============================================================================ +-- addUser +-- ============================================================================ + +test.register_coroutine_test( + "addUser: assigns userIndex 1 for the first user and emits a success commandResult", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Alice", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: assigns the next sequential userIndex when users already exist", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "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" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: fills a gap left by a deleted user rather than appending beyond max", + function() + -- Seed indices 1 and 3; index 2 is the expected gap to fill + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: returns resourceExhausted when totalUsersSupported has been reached", + function() + set_total_users_supported(2) + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser +-- ============================================================================ + +test.register_coroutine_test( + "updateUser: updates an existing user and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceUpdated", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceUpdated", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: creates user when userIndex does not exist", + function() + -- empty table — nothing to update + test.socket.capability:__queue_receive({ + mock_device.id, + { 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 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: can change a user's type as well as name", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Alice", "adminMember" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "adminMember" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser (no associated credential — pure local delete) +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser: removes a user with no credential and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser: returns failure when the target userIndex is not in the users table", + function() + -- empty table + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 99 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (injects deleteAllCredentials → ClearAllPINCodes z-wave flow) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers: sends ClearAllPINCodes and emits success for both users and credentials on PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(0.5, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(8, "oneshot") + test.mock_time.advance_time(0) + expect_clear_pin_code(1) + test.wait_for_events() + test.mock_time.advance_time(0.5) + expect_clear_pin_code(2) + test.wait_for_events() + + -- The legacy subdriver consumes deletion notifications for this mock profile; advance + -- to the driver's cleanup timer, which clears both tables and reports success. + test.mock_time.advance_time(8) + 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.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, 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.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers: emits busy when another lock command is in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- State-consistency: add → delete → re-add (indices 1, 2, 3 lifecycle) +-- ============================================================================ +-- +-- These tests verify that the users and credentials tables stay in sync +-- through a full lifecycle: populate three slots, remove the middle one, +-- then re-add a user and credential into the freed slot. The goal is to +-- confirm there is no stale index state that would cause duplicate entries, +-- wrong slot assignment, or mismatched user↔credential links. + + +test.register_coroutine_test( + "State-consistency: add users 1-3, deleteUser 2 (no credential), re-add user reclaims index 2", + function() + -- Populate three user slots directly (no credentials, so deleteUser will take + -- the no-Z-Wave path and delete the user entry locally). + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + -- Delete user at index 2; no credential is linked so this is a pure local delete. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3}, so it assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "State-consistency: add users+credentials 1-3, deleteUser 2 (with credential), re-add user+credential reclaims index 2 cleanly", + function() + -- Populate three user and credential slots. + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }) + + -- Delete user at index 2. The handler finds a linked credential and injects + -- deleteCredential, which sends ClearPINCode to the lock. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + expect_clear_pin_code(2) + test.wait_for_events() + + -- Lock acknowledges the deletion. + -- The legacy subdriver handles deletion notifications for this mock profile, so clear local + -- state directly to model the intended post-delete state before testing index reuse. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.delete_entry(mock_device, "users", 2) == constants.COMMAND_RESULT.SUCCESS) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.delete_entry(mock_device, "credentials", 2) == constants.COMMAND_RESULT.SUCCESS) + test.wait_for_events() + mock_device:set_field(constants.DRIVER_STATE.BUSY, false, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil, {}) + + -- Re-add a user. next_index sees occupied = {1, 3} and assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a credential for the new user at slot 2. The old credential at + -- credentialIndex 2 was cleanly removed, so add_entry must succeed without + -- returning OCCUPIED or any stale-state error. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "9999" } }, + }) + expect_set_pin_code(2, "9999") + test.wait_for_events() + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.add_entry(mock_device, "credentials", { + userIndex = 2, + credentialIndex = 2, + credentialType = "pin", + credentialName = "Guest 2", + }) == constants.COMMAND_RESULT.SUCCESS) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua index 81ba1df2ad..532e1da1a9 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua @@ -1,15 +1,17 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local test = require "integration_test" local capabilities = require "st.capabilities" -local json = require "dkjson" local zw_test_utils = require "integration_test.zwave_test_utils" local t_utils = require "integration_test.utils" local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({version=1}) +local Battery = (require "st.zwave.CommandClass.Battery")({version=1}) local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local constants = require "st.zwave.constants" +local lock_utils = require "lock_utils.constants".DRIVER_STATE + +test.disable_startup_messages() local SAMSUNG_MANUFACTURER_ID = 0x022E local SAMSUNG_PRODUCT_TYPE = 0x0001 @@ -18,47 +20,53 @@ local SAMSUNG_PRODUCT_ID = 0x0001 local mock_device = test.mock_device.build_test_zwave_device( { profile = t_utils.get_profile_definition("base-lock.yml"), + _provisioning_state = "TYPED", zwave_manufacturer_id = SAMSUNG_MANUFACTURER_ID, zwave_product_type = SAMSUNG_PRODUCT_TYPE, - zwave_product_id = SAMSUNG_PRODUCT_ID + zwave_product_id = SAMSUNG_PRODUCT_ID, } ) local function test_init() test.mock_device.add_test_device(mock_device) end + test.set_test_init_function(test_init) -local function init_code_slot(slot_number, name, device) - local lock_codes = device.persistent_store[constants.LOCK_CODES] - if lock_codes == nil then - lock_codes = {} - device.persistent_store[constants.LOCK_CODES] = lock_codes - end - lock_codes[tostring(slot_number)] = name +local function added() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + + test.socket.zwave:__expect_send( + DoorLock:OperationGet({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + Battery:Get({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") end test.register_coroutine_test( "When the device is added an unlocked event should be sent", function() + added() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"}), { visibility = { displayed = false } })) - ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end, - { - min_api_version = 17 - } + end ) test.register_coroutine_test( "Setting a user code name should be handled", function() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + added() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234"} } }) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -89,95 +97,121 @@ test.register_coroutine_test( }) }) test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }) - )) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { visibility = { displayed = false } }) + ) ) - end, - { - min_api_version = 17 - } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1, userIndex = 1}, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end ) test.register_coroutine_test( "Notification about correctly added code should be handled", function() - mock_device.persistent_store["_code_state"] = {["setName2"] = "Code 2"} - test.socket.zwave:__queue_receive({ mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE - }) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 failed", { state_change = true }))) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Notification about duplicated code should be handled", - function() - mock_device.persistent_store["_code_state"] = {["setName2"] = "Code 2"} + added() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234"} } }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + ) + test.wait_for_events() test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = string.char(1) }) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged(2 .. " failed", { state_change = true }))) - end, - { - min_api_version = 17 - } + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:Get({user_identifier = 1}) + ) + ) + end ) test.register_coroutine_test( "All user codes should be reported as deleted upon changing Master Code", function() - init_code_slot(0, "Master Code", mock_device) - init_code_slot(1, "Code 1", mock_device) - init_code_slot(2, "Code 2", mock_device) - init_code_slot(3, "Code 3", mock_device) - test.socket.zwave:__queue_receive({ + added() + mock_device:set_field("persistedUsers", { + { userIndex = 1, userName = "Code 1", userType = "guest" }, + { userIndex = 2, userName = "Code 2", userType = "guest" }, + { userIndex = 3, userName = "Code 3", userType = "guest" }, + }, { persist = true }) + mock_device:set_field("persistedCredentials", { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, { persist = true }) + test.socket.capability:__queue_receive({ mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, - event_parameter = "" } - ) + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = {1, "new name", "guest" } + }, }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) ) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "Code 1"}, state_change = true }) + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({ + { userIndex = 1, userName = "new name", userType = "guest" }, + { userIndex = 2, userName = "Code 2", userType = "guest" }, + { userIndex = 3, userName = "Code 3", userType = "guest" } + }, + { visibility = { displayed = false } }) ) ) + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, + event_parameter = "" } + ) + }) + test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("2 deleted", { data = { codeName = "Code 2"}, state_change = true }) + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, + { visibility = { displayed = false } }) ) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("3 deleted", { data = { codeName = "Code 3"}, state_change = true }) + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, + { visibility = { displayed = false } }) ) ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } }) - )) - end, - { - min_api_version = 17 - } + end ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua new file mode 100644 index 0000000000..81ba1df2ad --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua @@ -0,0 +1,183 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local json = require "dkjson" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local constants = require "st.zwave.constants" + +local SAMSUNG_MANUFACTURER_ID = 0x022E +local SAMSUNG_PRODUCT_TYPE = 0x0001 +local SAMSUNG_PRODUCT_ID = 0x0001 + +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_manufacturer_id = SAMSUNG_MANUFACTURER_ID, + zwave_product_type = SAMSUNG_PRODUCT_TYPE, + zwave_product_id = SAMSUNG_PRODUCT_ID + } +) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function init_code_slot(slot_number, name, device) + local lock_codes = device.persistent_store[constants.LOCK_CODES] + if lock_codes == nil then + lock_codes = {} + device.persistent_store[constants.LOCK_CODES] = lock_codes + end + lock_codes[tostring(slot_number)] = name +end + +test.register_coroutine_test( + "When the device is added an unlocked event should be sent", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"}), { visibility = { displayed = false } })) + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code name should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = "" } + ) + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:Get({user_identifier = 1}) + ) + ) + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }) + )) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Notification about correctly added code should be handled", + function() + mock_device.persistent_store["_code_state"] = {["setName2"] = "Code 2"} + test.socket.zwave:__queue_receive({ mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE + }) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 failed", { state_change = true }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Notification about duplicated code should be handled", + function() + mock_device.persistent_store["_code_state"] = {["setName2"] = "Code 2"} + test.socket.zwave:__queue_receive({ mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE + }) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged(2 .. " failed", { state_change = true }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "All user codes should be reported as deleted upon changing Master Code", + function() + init_code_slot(0, "Master Code", mock_device) + init_code_slot(1, "Code 1", mock_device) + init_code_slot(2, "Code 2", mock_device) + init_code_slot(3, "Code 3", mock_device) + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION, + event_parameter = "" } + ) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "Code 1"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("2 deleted", { data = { codeName = "Code 2"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("3 deleted", { data = { codeName = "Code 3"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } }) + )) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua index 38dabbd9dd..90d919c27e 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua @@ -1,18 +1,21 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local test = require "integration_test" local capabilities = require "st.capabilities" local zw = require "st.zwave" -local json = require "dkjson" 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 @@ -33,10 +36,11 @@ local zwave_lock_endpoints = { local mock_device = test.mock_device.build_test_zwave_device( { profile = t_utils.get_profile_definition("base-lock.yml"), + _provisioning_state = "TYPED", zwave_endpoints = zwave_lock_endpoints, zwave_manufacturer_id = SCHLAGE_MANUFACTURER_ID, zwave_product_type = SCHLAGE_PRODUCT_TYPE, - zwave_product_id = SCHLAGE_PRODUCT_ID + zwave_product_id = SCHLAGE_PRODUCT_ID, } ) @@ -45,13 +49,32 @@ 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.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + 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, @@ -69,41 +92,29 @@ test.register_coroutine_test( 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.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }) - )) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) - ) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Setting a code length should be handled", - function() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCodeLength", args = { 6 } } }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Configuration:Set({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, - configuration_value = 6, - size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size - }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { visibility = { displayed = false } }) + ) ) - ) - end, - { - min_api_version = 17 - } + 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({ @@ -112,17 +123,18 @@ test.register_coroutine_test( }) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6)) + mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6)) ) - end, - { - min_api_version = 17 - } + 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({ @@ -131,8 +143,12 @@ test.register_coroutine_test( }) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6)) + 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({ @@ -140,21 +156,34 @@ test.register_coroutine_test( configuration_value = 4 }) }) + test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}})) + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, + { visibility = { displayed = false } }) + ) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(4)) + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, + { visibility = { displayed = false } }) + ) ) - end, - { - min_api_version = 17 - } + 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 () + function() + added() test.socket.zwave:__queue_receive({ mock_device.id, UserCode:Report({ @@ -162,55 +191,28 @@ test.register_coroutine_test( 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.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}})) + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, + { visibility = { displayed = false } }) + ) ) - end, - { - min_api_version = 17 - } -) - -local expect_reload_all_codes_messages = function() - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_device, - UserCode:UsersNumberGet({}) - )) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_device, - UserCode:Get({ user_identifier = 1 }) - )) -end - -test.register_coroutine_test( - "Reload all codes should complete as expected", - function () - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {}, component = "main"} - }) - expect_reload_all_codes_messages() - 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.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, + { visibility = { displayed = false } }) ) ) - end, - { - min_api_version = 17 - } + 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( @@ -230,15 +232,13 @@ test.register_coroutine_test( ) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end, - { - min_api_version = 17 - } + end ) test.register_coroutine_test( "Basic Sets should result in an Association remove", - function () + function() + added() test.socket.zwave:__queue_receive({ mock_device.id, Basic:Set({ @@ -257,10 +257,7 @@ test.register_coroutine_test( }) ) ) - end, - { - min_api_version = 17 - } + end ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua new file mode 100644 index 0000000000..38dabbd9dd --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua @@ -0,0 +1,266 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local json = require "dkjson" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" + +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local 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 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} + } + } +} + +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 = 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) + +test.register_coroutine_test( + "Setting a user code should result in the named code changed event firing", + function() + test.timer.__create_and_queue_test_time_advance_timer(4.2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + 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.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }) + )) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a code length should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCodeLength", args = { 6 } } }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = 6, + size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configuration report should be handled", + function() + 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.lockCodes.codeLength(6)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configuration report indicating code deletion should be handled", + function() + 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.lockCodes.codeLength(6)) + ) + test.socket.zwave:__queue_receive({ + mock_device.id, + Configuration:Report({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = 4 + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(4)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "User code report indicating master code is available should indicate code deletion", + function () + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 0, + user_id_status = UserCode.user_id_status.AVAILABLE + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +local expect_reload_all_codes_messages = function() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:UsersNumberGet({}) + )) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:Get({ user_identifier = 1 }) + )) +end + +test.register_coroutine_test( + "Reload all codes should complete as expected", + function () + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {}, component = "main"} + }) + expect_reload_all_codes_messages() + 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 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device should send appropriate configuration messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Get({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Association:Set({ + grouping_identifier = 2, + node_ids = {} + }) + ) + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Basic Sets should result in an Association remove", + function () + test.socket.zwave:__queue_receive({ + mock_device.id, + Basic:Set({ + value = 0x00 + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({})) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Association:Remove({ + grouping_identifier = 1, + node_ids = {} + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua deleted file mode 100644 index 1eb2d093e5..0000000000 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua +++ /dev/null @@ -1,251 +0,0 @@ --- Copyright 2022 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - - --- Mock out globals -local test = require "integration_test" -local capabilities = require "st.capabilities" -local zw = require "st.zwave" ---- @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 t_utils = require "integration_test.utils" -local zw_test_utils = require "integration_test.zwave_test_utils" -local utils = require "st.utils" - -local mock_datastore = require "integration_test.mock_env_datastore" - -local json = require "dkjson" - -local zwave_lock_endpoints = { - { - command_classes = { - { value = zw.BATTERY }, - { value = zw.DOOR_LOCK }, - { value = zw.USER_CODE }, - { value = zw.NOTIFICATION } - } - } -} - -local lockCodes = { - ["1"] = "Zach", - ["2"] = "Steven" -} - -local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints, - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } -) - -local mock_device_no_data = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - data = {} - } -) - -local expect_reload_all_codes_messages = function(dev, lc) - test.socket.capability:__expect_send(dev:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode(lc), { visibility = { displayed = false } }) - )) - test.socket.zwave:__expect_send( UserCode:UsersNumberGet({}):build_test_tx(dev.id) ) - test.socket.capability:__expect_send(dev:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(dev.id) ) -end - -test.register_coroutine_test( - "Device added data lock codes population", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Device added without data should function", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - expect_reload_all_codes_messages(mock_device_no_data,{}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Device init after added shouldn't change the datastores", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Device init after added with no data should update the datastores", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - -- This should happen as the data is empty at this point - expect_reload_all_codes_messages(mock_device_no_data, {}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - test.socket.device_lifecycle():__queue_receive(mock_device_no_data:generate_info_changed( - { - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } - )) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", true) - end, - { - min_api_version = 17 - } -) - - -test.register_coroutine_test( - "Device added data lock codes population, should not reload all codes", - function() - test.timer.__create_and_queue_test_time_advance_timer(31, "oneshot") - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.wait_for_events() - test.mock_time.advance_time(35) - -- Nothing should happen - end, - { - min_api_version = 17 - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua similarity index 97% rename from drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua rename to drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua index c798feca08..66038d0760 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua @@ -33,10 +33,10 @@ local zwave_lock_endpoints = { } local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints - } + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } ) local function test_init() @@ -53,31 +53,6 @@ local expect_reload_all_codes_messages = function() test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device.id) ) end -test.register_coroutine_test( - "When the device is added it should be set up and start reading codes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - - expect_reload_all_codes_messages() - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end, - { - min_api_version = 17 - } -) - test.register_coroutine_test( "Door Lock Operation Reports should be handled", function() @@ -502,7 +477,7 @@ test.register_coroutine_test( Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, - event_parameter = "" + event_parameter = "\x01" }) } ) @@ -824,6 +799,12 @@ test.register_coroutine_test( Battery:Get({}) ) ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:UsersNumberGet({}) + ) + ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, { diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua new file mode 100644 index 0000000000..2cf9349b78 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -0,0 +1,366 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Tests for lock_handlers/zwave_responses.lua +-- Covers: +-- • user_code_report: sync-codes-from-lock flow (advances slot, completes sync) +-- • user_code_report: out-of-band ENABLED code creates user+credential entries +-- • user_code_event_handler (via Notification:Report): NEW_USER_CODE_ADDED out-of-band +-- • user_code_event_handler: multi-byte event_parameter (byte 3 carries user_id) +-- • door_operation_event_handler: KEYPAD_UNLOCK with user info lookup +-- • door_operation_event_handler: KEYPAD_UNLOCK with unknown credential +-- • door_operation_event_handler: AUTO_LOCK_LOCKED_OPERATION → locked/auto +-- • DoorLock:OperationReport DOOR_SECURED / DOOR_UNSECURED → locked / unlocked +-- • UserCode:UsersNumberReport → pinUsersSupported + totalUsersSupported +-- +-- Removed: alarm tests — z-wave alarm decoding is only in the legacy-handlers sub-driver +-- for non-SLGA devices; not applicable to the main SLGA driver path. +-- Removed: max/min PIN code length tests — no z-wave equivalent attribute. +-- Removed: NOT_FULLY_LOCKED lock_state test — no clean z-wave OperationReport equivalent. +-- Removed: NumberOfPINUsersSupported profile-migration tests — that logic lives in the +-- zigbee legacy sub-driver; users_number_report in z-wave only emits events. + +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.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) + +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 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) + +-- ============================================================================ +-- user_code_report: sync codes from lock +-- During sync (CODES_FROM_LOCK busy state), ENABLED slots advance the index +-- without adding table entries; only AVAILABLE slots remove local entries. +-- ============================================================================ + +test.register_coroutine_test( + "user_code_report: sync advances to next code slot when slot is occupied", + 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, {}) + + -- Receive UserCode:Report for slot 1 (occupied) + 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 = "1234", + }), + }) + + -- No table entries added during sync for ENABLED slots; + -- driver requests the next code slot. + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "user_code_report: sync completes when the last slot is reached", + function() + -- get_max_entries defaults to 20 when the attribute is missing + 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, 20, {}) + + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 20, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + + -- No next-slot request; sync is complete + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil, + "CODE_INDEX must be nil after sync completes") + assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false, + "BUSY must be false after sync completes") + end +) + +test.register_coroutine_test( + "user_code_report: out-of-band ENABLED code creates user and credential entries", + function() + -- command_in_progress == nil → out-of-band path + 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 = "1234", + }), + }) + + -- User entry added + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + -- Credential entry added + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- user_code_event_handler (ACCESS_CONTROL Notification:Report) +-- ============================================================================ + +test.register_coroutine_test( + "user_code_event: NEW_USER_CODE_ADDED out-of-band creates user and credential entries", + function() + -- event_parameter = single byte encodes user_id = 1 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = string.char(1), + }), + }) + + test.socket.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.wait_for_events() + end +) + +test.register_coroutine_test( + "user_code_event: multi-byte event_parameter uses byte 3 to extract user_id", + function() + -- 3-byte event_parameter: bytes are (0, 0, 2) → byte 3 = 2 → user_id = 2 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = string.char(0, 0, 2), + }), + }) + + 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 = 2, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- door_operation_event_handler (ACCESS_CONTROL Notification:Report) +-- ============================================================================ + +test.register_coroutine_test( + "door_operation_event: KEYPAD_UNLOCK_OPERATION includes user info when credential exists", + function() + -- Seed persisted user/credential tables so the driver's lookup succeeds. + mock_device:set_field("persistedUsers", { + { userIndex = 1, userName = "John Doe", userType = "guest" }, + }, { persist = true }) + mock_device:set_field("persistedCredentials", { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }, { persist = true }) + + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, + event_parameter = string.char(1), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 1, + userName = "John Doe", + userType = "guest", + }, + }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "door_operation_event: KEYPAD_UNLOCK_OPERATION with unknown credential sets userIndex from event_parameter", + function() + -- No credential seeded for slot 99 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, + event_parameter = string.char(99), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 99, + }, + }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "door_operation_event: AUTO_LOCK_LOCKED_OPERATION emits locked with auto method", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.AUTO_LOCK_LOCKED_OPERATION, + event_parameter = "", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- DoorLock:OperationReport → lock state +-- Removed: NOT_FULLY_LOCKED — no clean z-wave OperationReport equivalent. +-- ============================================================================ + +test.register_coroutine_test( + "OperationReport: DOOR_SECURED emits locked event", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED }), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "OperationReport: DOOR_UNSECURED emits unlocked event", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_UNSECURED }), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- users_number_report: UserCode:UsersNumberReport +-- ============================================================================ + +test.register_coroutine_test( + "users_number_report: emits pinUsersSupported and totalUsersSupported", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:UsersNumberReport({ supported_users = 20 }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported( + 20, { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported( + 20, { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua index 7bb54f23f2..42a80c6f66 100644 --- a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua @@ -1,11 +1,14 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local function can_handle_v1_alarm(opts, driver, device, cmd, ...) - if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 then - return true, require("zwave-alarm-v1-lock") +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + if slga_migrated then + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 then + local subdriver = require("zwave-alarm-v1-lock") + return true, subdriver + end end return false end - -return can_handle_v1_alarm 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 d7c862f22a..fa9836498b 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 @@ -1,7 +1,6 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -9,9 +8,10 @@ local cc = require "st.zwave.CommandClass" local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) --- @type st.zwave.CommandClass.Battery local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.defaults.lockCodes -local lock_code_defaults = require "st.zwave.defaults.lockCodes" -local json = require "dkjson" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" local METHOD = { KEYPAD = "keypad", @@ -20,12 +20,6 @@ local METHOD = { AUTO = "auto" } ---- Determine whether the passed command is a V1 alarm command ---- ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @return boolean true if the device is smoke co alarm - --- Default handler for alarm command class reports, these were largely OEM-defined --- --- This converts alarm V1 reports to correct lock events @@ -35,84 +29,66 @@ local METHOD = { --- @param cmd st.zwave.CommandClass.Alarm.Report local function alarm_report_handler(driver, device, cmd) local alarm_type = cmd.args.alarm_type - local event = nil - local lock_codes = lock_code_defaults.get_lock_codes(device) - local code_id = nil - if (cmd.args.alarm_level ~= nil) then - code_id = tostring(cmd.args.alarm_level) - end + local credential_index = cmd.args.alarm_level + local event if (alarm_type == 9 or alarm_type == 17) then event = capabilities.lock.lock.unknown() elseif (alarm_type == 16 or alarm_type == 19) then event = capabilities.lock.lock.unlocked() - if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + if (credential_index ~= nil) then + local credential = tables.find_entry(device, "credentials", credential_index) + local user_id = credential and credential.userIndex or nil + event.data = { userIndex = user_id, method = METHOD.KEYPAD} end elseif (alarm_type == 18) then event = capabilities.lock.lock.locked() - if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + if (credential_index ~= nil) then + local credential = tables.find_entry(device, "credentials", credential_index) + local user_id = credential and credential.userIndex or nil + event.data = { userIndex = user_id, method = METHOD.KEYPAD} end elseif (alarm_type == 21) then event = capabilities.lock.lock.locked() - if (cmd.args.alarm_level == 2) then - event["data"] = {method = METHOD.MANUAL} - else - event["data"] = {method = METHOD.KEYPAD} - end + event.data = {method = (cmd.args.alarm_level == 2) and METHOD.MANUAL or METHOD.KEYPAD} elseif (alarm_type == 22) then event = capabilities.lock.lock.unlocked() - event["data"] = {method = METHOD.MANUAL} + event.data = {method = METHOD.MANUAL} elseif (alarm_type == 23) then event = capabilities.lock.lock.unknown() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 24) then event = capabilities.lock.lock.locked() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 25) then event = capabilities.lock.lock.unlocked() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 26) then event = capabilities.lock.lock.unknown() - event["data"] = {method = METHOD.AUTO} + event.data = {method = METHOD.AUTO} elseif (alarm_type == 27) then event = capabilities.lock.lock.locked() - event["data"] = {method = METHOD.AUTO} + event.data = {method = METHOD.AUTO} elseif (alarm_type == 32) then - -- all user codes deleted - for code_id, _ in pairs(lock_codes) do - lock_code_defaults.code_deleted(device, code_id) - end - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + -- all credentials have been deleted + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") elseif (alarm_type == 33) then - -- user code deleted - if (code_id ~= nil) then - lock_code_defaults.clear_code_state(device, code_id) - if (lock_codes[code_id] ~= nil) then - lock_code_defaults.code_deleted(device, code_id) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) - end - end + -- credential has been deleted + lock_utils.delete_credential_report_helper(device, credential_index) + elseif (alarm_type == 13 or alarm_type == 112) then -- user code changed/set - if (code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - local change_type = lock_code_defaults.get_change_type(device, code_id) - local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) - code_changed_event["data"] = { codeName = code_name} - lock_code_defaults.code_set_event(device, code_id, code_name) - lock_code_defaults.clear_code_state(device, code_id) - device:emit_event(code_changed_event) - end + lock_utils.set_credential_report_helper(device, credential_index) + elseif (alarm_type == 34 or alarm_type == 113) then - -- duplicate lock code - if (code_id ~= nil) then - local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) - lock_code_defaults.clear_code_state(device, code_id) - device:emit_event(code_changed_event) + -- duplicate lock code. Log duplicate error + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + command_in_progress, consts.COMMAND_RESULT.DUPLICATE) + lock_utils.clear_busy_state(device) end + elseif (alarm_type == 130) then -- batteries replaced if (device:is_cc_supported(cc.BATTERY)) then @@ -146,7 +122,7 @@ local zwave_lock = { } }, NAME = "Z-Wave lock alarm V1", - can_handle = require("zwave-alarm-v1-lock.can_handle"), + can_handle = require("zwave-alarm-v1-lock.can_handle") } return zwave_lock From 962a0a60dd8c9fc1fcea1598598b6d60be863e5d Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Thu, 2 Jul 2026 11:07:50 -0500 Subject: [PATCH 2/2] remove bad test --- .../src/test/test_init_lifecycle_handlers.lua | 29 ------------------- 1 file changed, 29 deletions(-) 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 1aa5b753ff..d352bbfbcf 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 @@ -207,35 +207,6 @@ do ) end -do - local dev - test.register_coroutine_test( - "infoChanged: profile switched away from lockCodes (to non-lockCodes profile) does nothing", - function() - -- Warm up device_cache with base-lock - test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) - test.wait_for_events() - - -- Switch to lock-battery (no lockCodes) - test.socket.device_lifecycle:__queue_receive( - dev:generate_info_changed({ profile = t_utils.get_profile_definition("lock-battery.yml") }) - ) - test.wait_for_events() - -- profile_switched is true but new profile has no lockCodes → no migration events - end, - { - test_init = function() - test.disable_startup_messages() - dev = test.mock_device.build_test_zwave_device({ - profile = t_utils.get_profile_definition("base-lock.yml"), - zwave_endpoints = zwave_lock_endpoints, - }) - test.mock_device.add_test_device(dev) - end, - } - ) -end - -- ============================================================================ -- init (LockLifecycle.init) -- ============================================================================