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 925452c431..259b2cbcbc 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -2,140 +2,282 @@ -- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" +local LockUsers = capabilities.lockUsers +local LockCredentials = capabilities.lockCredentials +local lock_utils = require "zwave_lock_utils" +local utils = require "st.utils" --- @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.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local log = require "log" +local TamperDefaults = require "st.zwave.defaults.tamperAlert" --- @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.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" - -local SCAN_CODES_CHECK_INTERVAL = 30 -local MIGRATION_COMPLETE = "migrationComplete" -local MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped" - -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 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))} +-- Helper methods +local reload_all_codes = function(device) + local max_codes = device:get_latest_state("main", + LockCredentials.ID, LockCredentials.pinUsersSupported.NAME) + if (max_codes == nil) then + device:send(UserCode:UsersNumberGet({})) + end - device:set_field(MIGRATION_COMPLETE, true, { persist = true }) + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then + device:set_field(lock_utils.CHECKING_CODE, 1) end + + device:send(UserCode:Get({user_identifier = device:get_field(lock_utils.CHECKING_CODE)})) end ---- 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 }) +local do_refresh = function(self, device) + local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) + local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) + device:send(DoorLock:OperationGet({})) + device:send(Battery:Get({})) +end + +-- Lifecycle handlers +local added_handler = function(driver, device) + if device:supports_capability_by_id(capabilities.lockCodes.ID) 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(lock_utils.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore end + lock_utils.reload_tables(device) + device.thread:call_with_delay(2, function () + reload_all_codes(device) + end) + local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) + local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) device:send(DoorLock:OperationGet({})) device:send(Battery:Get({})) if (device:supports_capability(capabilities.tamperAlert)) then device:emit_event(capabilities.tamperAlert.tamper.clear()) end + device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) 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 }) +local init_handler = function(driver, device) + lock_utils.reload_tables(device) + device.thread:call_with_delay(10, function () + reload_all_codes(device) + end) end -local do_refresh = function(self, device) - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) +-- Lock Users commands +local add_user_handler = function(driver, device, command) + if lock_utils.busy_check_and_set(device, {name = lock_utils.ADD_USER, type = lock_utils.LOCK_USERS}) then + return + end + local available_index = lock_utils.get_available_user_index(device) + local status = lock_utils.STATUS_SUCCESS + if available_index == nil then + status = lock_utils.STATUS_RESOURCE_EXHAUSTED + else + device:set_field(lock_utils.ACTIVE_CREDENTIAL, { userIndex = available_index}) + lock_utils.create_user(device, command.args.userName, command.args.userType, available_index) + end + + if status == lock_utils.STATUS_SUCCESS then + lock_utils.send_events(device, lock_utils.LOCK_USERS) + end + + lock_utils.clear_busy_state(device, status) +end + +local update_user_handler = function(driver, device, command) + if lock_utils.busy_check_and_set(device, {name = lock_utils.UPDATE_USER, type = lock_utils.LOCK_USERS}) then + return + end + + local user_name = command.args.userName + local user_type = command.args.userType + local user_index = tonumber(command.args.userIndex) + local current_users = lock_utils.get_users(device) + local status = lock_utils.STATUS_FAILURE + + for _, user in pairs(current_users) do + if user.userIndex == user_index then + device:set_field(lock_utils.ACTIVE_CREDENTIAL, { userIndex = user_index }) + user.userName = user_name + user.userType = user_type + device:set_field(lock_utils.LOCK_USERS, current_users) + lock_utils.send_events(device, lock_utils.LOCK_USERS) + status = lock_utils.STATUS_SUCCESS + break + end + end + + lock_utils.clear_busy_state(device, status) +end + +local delete_user_handler = function(driver, device, command) + if lock_utils.busy_check_and_set(device, {name = lock_utils.DELETE_USER, type = lock_utils.LOCK_USERS}, command.override_busy_check) then + return + end + local status = lock_utils.STATUS_SUCCESS + local user_index = tonumber(command.args.userIndex) + if lock_utils.get_user(device, user_index) ~= nil then + + if command.override_busy_check == nil then + device:set_field(lock_utils.ACTIVE_CREDENTIAL, { userIndex = user_index }) + end + + local associated_credential = lock_utils.get_credential_by_user_index(device, user_index) + if associated_credential ~= nil then + -- if there is an associated credential with this user then delete the credential + -- this command also handles the user deletion + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { associated_credential.credentialIndex, "pin" }, + override_busy_check = true + }) + else + lock_utils.delete_user(device, user_index) + lock_utils.send_events(device, lock_utils.LOCK_USERS) + lock_utils.clear_busy_state(device, status, command.override_busy_check) + end + else + status = lock_utils.STATUS_FAILURE + lock_utils.clear_busy_state(device, status, command.override_busy_check) + end end ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @param cmd table -local function update_codes(driver, device, cmd) +local delete_all_users_handler = function(driver, device, command) + if lock_utils.busy_check_and_set(device, {name = lock_utils.DELETE_ALL_USERS, type = lock_utils.LOCK_USERS}) then + return + end + local status = lock_utils.STATUS_SUCCESS + local current_users = lock_utils.get_users(device) + 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 + for _, user in pairs(current_users) do + device.thread:call_with_delay(delay, function() + driver:inject_capability_command(device, { + capability = capabilities.lockUsers.ID, + command = capabilities.lockUsers.commands.deleteUser.NAME, + args = {user.userIndex}, + override_busy_check = true + }) + end) + delay = delay + 2 + end + + device.thread:call_with_delay(delay + 4, function() + lock_utils.clear_busy_state(device, status) + end) +end + +--- Lock Credentials Commands + +local add_credential_handler = lock_utils.add_credential_handler + +local update_credential_handler = function(driver, device, command) + if lock_utils.busy_check_and_set(device, {name = lock_utils.UPDATE_CREDENTIAL, type = lock_utils.LOCK_CREDENTIALS}) then + return + end + local credential_index = tonumber(command.args.credentialIndex) + local credential_data = command.args.credentialData + local credential = lock_utils.get_credential(device, credential_index) + + if credential ~= nil then + device:set_field(lock_utils.ACTIVE_CREDENTIAL, + { userIndex = credential.userIndex, credentialType = credential.credentialType, credentialIndex = credential.credentialIndex }) + device:send(UserCode:Set({ + user_identifier = credential_index, + user_code = credential_data, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) + -- clearing busy state handled in user_code_report_handler + else + lock_utils.clear_busy_state(device, lock_utils.STATUS_FAILURE) + end +end + +local delete_credential_handler = function(driver, device, command) + if lock_utils.busy_check_and_set(device, {name = lock_utils.DELETE_CREDENTIAL, type = lock_utils.LOCK_CREDENTIALS}, command.override_busy_check) then + return + end + + local credential_index = tonumber(command.args.credentialIndex) + local credential = lock_utils.get_credential(device, credential_index) + if credential ~= nil then + if command.override_busy_check == nil then + device:set_field(lock_utils.ACTIVE_CREDENTIAL, + { userIndex = credential.userIndex, credentialType = credential.credentialType, credentialIndex = credential.credentialIndex }) end + device:send(UserCode:Set({ + user_identifier = credential.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + -- clearing busy state handled in user_code_report_handler + else + lock_utils.clear_busy_state(device, lock_utils.STATUS_FAILURE, command.override_busy_check) end end +local delete_all_credentials_handler = function(driver, device, command) + if lock_utils.busy_check_and_set(device, {name = lock_utils.DELETE_ALL_CREDENTIALS, type = lock_utils.LOCK_CREDENTIALS}) then + return + end + local credentials = lock_utils.get_credentials(device) + local status = lock_utils.STATUS_SUCCESS + local delay = 0 + for _, credential in pairs(credentials) do + local credential_index = tonumber(credential.credentialIndex) + device:send(UserCode:Set({ + user_identifier = credential_index, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + delay = delay + 2 + end + + device.thread:call_with_delay(delay + 4, function() + lock_utils.clear_busy_state(device, status) + end) +end + +-- Z-Wave Message Handlers + +local user_code_report_handler = lock_utils.user_code_report_handler + +local notification_report_handler = function(driver, device, cmd) + ------------ USER CODE PROGRAMMING EVENTS ------------ + lock_utils.base_driver_code_event_handler(driver, device, cmd) + + ------------ LOCK OPERATION EVENTS ------------ + lock_utils.door_operation_event_handler(driver, device, cmd) + + ------------ TAMPER EVENTS ------------ + -- We have to load and call this manually since we're now overriding notfication handling + -- in this driver + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) +end + +local users_number_report_handler = function(driver, device, cmd) + -- these are the same for Z-Wave + device:emit_event(LockUsers.totalUsersSupported(cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) + device:emit_event(LockCredentials.pinUsersSupported(cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) +end + +-- Leave this here for logging purposes, it can be removed once lock migration is complete +local migrate = function(driver, device, value) + log.error_with({ hub_logs = true }, "\n--- PK -- CURRENT USERS ---- \n" .. + "\n" ..utils.stringify_table(lock_utils.get_users(device)).."\n" .. + "\n--- PK -- CURRENT CREDENTIALS ---- \n" .. + "\n" ..utils.stringify_table(lock_utils.get_credentials(device)).."\n" .. + "\n --------------------------------- \n") +end + local function 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({ @@ -151,6 +293,8 @@ local driver_template = { supported_capabilities = { capabilities.lock, capabilities.lockCodes, + capabilities.lockUsers, + capabilities.lockCredentials, capabilities.battery, capabilities.tamperAlert }, @@ -159,16 +303,35 @@ local driver_template = { init = init_handler, }, capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes - }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh - } + }, + [LockUsers.ID] = { + [LockUsers.commands.addUser.NAME] = add_user_handler, + [LockUsers.commands.updateUser.NAME] = update_user_handler, + [LockUsers.commands.deleteUser.NAME] = delete_user_handler, + [LockUsers.commands.deleteAllUsers.NAME] = delete_all_users_handler, + }, + [LockCredentials.ID] = { + [LockCredentials.commands.addCredential.NAME] = add_credential_handler, + [LockCredentials.commands.updateCredential.NAME] = update_credential_handler, + [LockCredentials.commands.deleteCredential.NAME] = delete_credential_handler, + [LockCredentials.commands.deleteAllCredentials.NAME] = delete_all_credentials_handler, + }, + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.migrate.NAME] = migrate, + }, }, zwave_handlers = { [cc.TIME] = { - [Time.GET] = time_get_handler -- used by DanaLock + [0x01] = time_get_handler -- used by DanaLock + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report_handler, + [UserCode.USERS_NUMBER_REPORT] = users_number_report_handler, } }, sub_drivers = require("sub_drivers"), @@ -177,3 +340,4 @@ local driver_template = { defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) local lock = ZwaveDriver("zwave_lock", driver_template) lock:run() +return driver_template diff --git a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua index a51af26e00..d563abe4d6 100644 --- a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua @@ -9,8 +9,6 @@ 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 @@ -39,8 +37,18 @@ 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) + local lock_utils = require "zwave_lock_utils" + local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + if not slga_migrated then + local LockDefaults = require "st.zwave.defaults.lock" + local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + else + lock_utils.door_operation_event_handler(self, device, cmd) + lock_utils.base_driver_code_event_handler(self, device, cmd) + end + 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..3d69c340b7 --- /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 lock_utils = require("zwave_lock_utils") + local slga_migrated = device:get_field(lock_utils.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..37d3d255b4 --- /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 lock_utils = require("zwave_lock_utils") + device:set_field(lock_utils.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/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..058b0f41a9 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua @@ -0,0 +1,10 @@ +-- 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"), +} +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/samsung-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua index e9222cb8fb..8cd03d2d9d 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 lock_utils = require("zwave_lock_utils") + local slga_migrated = device:get_field(lock_utils.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..66c86e3b6b 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,7 @@ 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 lock_utils = require "zwave_lock_utils" local function notification_report_handler(self, device, cmd) local event @@ -39,36 +17,27 @@ 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 active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + if command ~= nil and command.name == lock_utils.ADD_CREDENTIAL and active_credential ~= nil then + device:send(UserCode:Get({ user_identifier = active_credential.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 + -- All other codes are deleted when the master code is changed + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) end - event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + lock_utils.send_events(device) + 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) + lock_utils.door_operation_event_handler(self, device, cmd) + lock_utils.base_driver_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..d2ff9cb3e8 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 lock_utils = require("zwave_lock_utils") + local slga_migrated = device:get_field(lock_utils.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..83cc2f183f 100644 --- a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua @@ -1,77 +1,20 @@ --- 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 lock_utils = require "zwave_lock_utils" 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 +29,16 @@ 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 + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) + end + lock_utils.send_events(device) 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,37 @@ 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) + if credential_index == 0 and reported_user_id_status == user_id_status.AVAILABLE then + -- master code changed, clear all credentials + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) 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 }) + lock_utils.send_events(device) end + else + lock_utils.user_code_report_handler(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() + lock_utils.add_credential_handler(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 +100,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 46700ce154..1558b7440a 100644 --- a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua @@ -8,5 +8,6 @@ local sub_drivers = { lazy_load_if_possible("samsung-lock"), lazy_load_if_possible("keywe-lock"), lazy_load_if_possible("apiv6_bugfix"), + lazy_load_if_possible("legacy-handlers"), } return sub_drivers 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..f5168d9c7b 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 "zwave_lock_utils" + +test.disable_startup_messages() local KEYWE_MANUFACTURER_ID = 0x037B local KEYWE_PRODUCT_TYPE = 0x0002 @@ -24,78 +28,105 @@ 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.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + 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.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = {displayed = false}}))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + for i = 1, 8 do + test.socket.zwave:__expect_send( + UserCode:Get({user_identifier = i}):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ + user_identifier = i, + user_id_status = UserCode.user_id_status.AVAILABLE + })}) + end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.wait_for_events() +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"}}))) + -- not a special case for this lock, should be handled as usual + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({notification_type = 6, event = 6, event_parameter = "\x01"}) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="keypad"}}))) + 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_samsung_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua index 81ba1df2ad..05f901619f 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 "zwave_lock_utils" + +test.disable_startup_messages() local SAMSUNG_MANUFACTURER_ID = 0x022E local SAMSUNG_PRODUCT_TYPE = 0x0001 @@ -18,47 +20,101 @@ 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 added() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + 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.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = {displayed = false}}))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + for i = 1, 8 do + test.socket.zwave:__expect_send( + UserCode:Get({user_identifier = i}):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ + user_identifier = i, + user_id_status = UserCode.user_id_status.AVAILABLE + })}) + end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.wait_for_events() +end + 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 + local credentials = device.transient_store[lock_utils.LOCK_CREDENTIALS] + local users = device.transient_store[lock_utils.LOCK_USERS] + if credentials == nil then + credentials = {} + device.transient_store[lock_utils.LOCK_CREDENTIALS] = credentials end - lock_codes[tostring(slot_number)] = name + if users == nil then + users = {} + device.transient_store[lock_utils.LOCK_USERS] = users + end + table.insert(credentials, { userIndex = slot_number, credentialIndex = slot_number, credentialType = "pin" }) + table.insert(users, { userIndex = slot_number, userName = name, userType = "guest" }) 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 +145,123 @@ 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.lockUsers.users({{ userIndex = 1, userName = "Guest1", userType = "guest" }}, + { state_change = true, visibility = { displayed = true } }) + ) ) - end, - { - min_api_version = 17 - } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { state_change = true, visibility = { displayed = true } }) + ) + ) + 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 = true } } + ) + ) + ) + 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 }) }) - 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.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate", credentialIndex = 1, userIndex = 1}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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) + added() 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({ + 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 = true } } + ) ) ) 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" } + }, + { state_change = true, visibility = { displayed = true } }) ) ) + 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({}, + { state_change = true, visibility = { displayed = true } }) ) ) 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({}, + { state_change = true, visibility = { displayed = 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 - } + 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..4a6d1b0a77 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 "zwave_lock_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 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,64 @@ 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.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + 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.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = {displayed = false}}))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + for i = 1, 8 do + test.socket.zwave:__expect_send( + UserCode:Get({user_identifier = i}):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ + user_identifier = i, + user_id_status = UserCode.user_id_status.AVAILABLE + })}) + end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.wait_for_events() +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 +124,36 @@ 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.lockUsers.users({{ userIndex = 1, userName = "Guest1", userType = "guest" }}, + { state_change = true, visibility = { displayed = true } }) + ) ) - ) - end, - { - min_api_version = 17 - } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { state_change = true, visibility = { displayed = true } }) + ) + ) + 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 = true } } + ) + ) + ) + end ) test.register_coroutine_test( "Configuration report should be handled", function() + added() test.socket.zwave:__queue_receive({ mock_device.id, Configuration:Report({ @@ -112,17 +162,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 +182,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 +195,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({}, + { state_change = true, visibility = { displayed = true } }) + ) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(4)) + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, + { state_change = true, visibility = { displayed = true } }) + ) ) - 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 +230,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({}, + { state_change = true, visibility = { displayed = true } }) + ) ) - 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({}, + { state_change = true, visibility = { displayed = true } }) ) ) - 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 +271,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 +296,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.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua index c798feca08..06fb34d81b 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua @@ -1,24 +1,24 @@ -- 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" ---- @type st.zwave.constants -local constants = require "st.zwave.constants" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) --- @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 }) +--- @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.Alarm local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) local t_utils = require "integration_test.utils" -local zw_test_utils = require "integration_test.zwave_test_utils" +local access_control_event = Notification.event.access_control +local lock_utils = require "zwave_lock_utils" + +test.disable_startup_messages() -- supported comand classes local zwave_lock_endpoints = { @@ -31,804 +31,727 @@ local zwave_lock_endpoints = { } } } +local test_credential_index = 1 +local test_credentials = {} +local test_users = {} 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"), + _provisioning_state = "TYPED", + zwave_endpoints = zwave_lock_endpoints + } ) +-- if user_index is 0 it creates a new user. +local function add_credential(user_index) + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { user_index, "guest", "pin", "123" .. test_credential_index } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = test_credential_index, + user_code = "123" .. test_credential_index, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + local payload = "\x70\x01\x00\xFF\x06\x0E\x00\x00" + payload = payload:sub(1, 1) .. string.char(test_credential_index) .. payload:sub(3) + 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, + payload = payload + }) + }) + table.insert(test_users, { userIndex = test_credential_index, userName = "Guest" .. test_credential_index, userType = "guest" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(test_users, + { state_change = true, visibility = { displayed = true } }) + ) + ) + table.insert(test_credentials, { userIndex = test_credential_index, credentialIndex = test_credential_index, credentialType = "pin" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials(test_credentials, + { state_change = true, visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = test_credential_index, userIndex = test_credential_index }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.wait_for_events() + test_credential_index = test_credential_index + 1 +end + local function test_init() test.mock_device.add_test_device(mock_device) -end -test.set_test_init_function(test_init) -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( UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device.id) ) + -- reset these globals + test_credential_index = 1 + test_credentials = {} + test_users = {} 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" }) +test.set_test_init_function(test_init) - expect_reload_all_codes_messages() +local function added() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + 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.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = {displayed = false}}))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + for i = 1, 8 do test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) + UserCode:Get({user_identifier = i}):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ + user_identifier = i, + user_id_status = UserCode.user_id_status.AVAILABLE + })}) + end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { }, + { state_change = true, visibility = { displayed = true } } ) ) - 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.lockCredentials.credentials( + { }, + { state_change = true, visibility = { displayed = true } } ) ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end, - { - min_api_version = 17 - } -) + ) + test.wait_for_events() +end test.register_coroutine_test( - "Door Lock Operation Reports should be handled", + "Add user should succeed", function() - test.socket.zwave:__queue_receive({mock_device.id, - DoorLock:OperationReport({door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED}) + added() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 1", "guest" } + }, }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked())) - end, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Battery percentage report should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { mock_device.id, Battery:Report({ battery_level = 0x63 }) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) - } - }, - { - 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 = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.MANUAL_LOCK_OPERATION - }) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Code set reports should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { mock_device.id, - UserCode:Report({ - user_identifier = 2, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS - }) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"}), { visibility = { displayed = false } }) ) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 set", - { data = { codeName = "Code 2"}, state_change = true })) - } - }, - { - inner_block_ordering = "relaxed", - min_api_version = 17 - } -) - -test.register_message_test( - "Alarm tamper events should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.KEYPAD_TEMPORARY_DISABLED - }) - } - }, - - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) - } - }, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Sending the lock command should be handled", - function() - test.timer.__create_and_queue_test_time_advance_timer(4.2, "oneshot") - test.socket.capability:__queue_receive({mock_device.id, - { capability = "lock", component = "main", command = "lock", args = {} } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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 = true } } + ) + ) + ) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 2", "guest" } + }, }) - test.socket.zwave:__expect_send(DoorLock:OperationSet({door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED}):build_test_tx(mock_device.id)) - test.wait_for_events() - test.mock_time.advance_time(4.2) - test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) - end, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Max user code number report should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { mock_device.id, UserCode:UsersNumberReport({ supported_users = 16 }) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false } })) - } - }, - { - min_api_version = 17 - } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }, {userIndex = 2, userType = "guest", userName = "TestUser 2" }}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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 = true } } + ) + ) + ) + end ) test.register_coroutine_test( - "Reloading all codes of an unconfigured lock should generate correct attribute checks", + "Add credential should succeed", function() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) - expect_reload_all_codes_messages() - end, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Requesting a user code should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = capabilities.lockCodes.ID, command = "requestCode", args = { 1 } } } - }, - { - channel = "zwave", - direction = "send", - message = UserCode:Get({user_identifier = 1}):build_test_tx(mock_device.id) - } - }, - { - min_api_version = 17 - } + added() + -- these all should succeed + add_credential(0) + add_credential(0) + add_credential(0) + end ) test.register_coroutine_test( - "Deleting a user code should be handled", + "Add credential for existing user should succeed", 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 = "deleteCode", args = { 1 } } }) - test.socket.zwave:__expect_send(UserCode:Set( {user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE}):build_test_tx(mock_device.id)) + added() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "Guest1", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "Guest1" }}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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 = true } } + ) + ) + ) test.wait_for_events() - test.mock_time.advance_time(4.2) - test.socket.zwave:__expect_send(UserCode:Get( {user_identifier = 1}):build_test_tx(mock_device.id)) - end, - { - min_api_version = 17 - } + -- add credential with the new users index (1). + add_credential(1) + end ) test.register_coroutine_test( - "Setting a user code should result in the named code changed event firing", + "Update user should succeed", function() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) - test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id) ) + added() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 1", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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 = true } } + ) + ) + ) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 2", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }, {userIndex = 2, userType = "guest", userName = "TestUser 2" }}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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 = true } } + ) + ) + ) test.wait_for_events() - test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({user_identifier = 1, 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 - } -) - -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 + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = {1, "new name", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "new name" }, {userIndex = 2, userType = "guest", userName = "TestUser 2" }}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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 = true } } + ) + ) + ) end - lock_codes[tostring(slot_number)] = name -end +) test.register_coroutine_test( - "Setting a user code name should be handled", + "Delete user should succeed", function() - init_code_slot(1, "initialName", mock_device) - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "nameSlot", args = { 1, "foo" } } }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"} ), { visibility = { displayed = false } }) - )) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", - {state_change = true}))) - end, - { - min_api_version = 17 - } -) + added() + -- add credential + add_credential(0) -test.register_coroutine_test( - "Calling updateCodes should send properly spaced commands", - function () - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.socket.zwave:__set_channel_ordering("relaxed") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "updateCodes", args = {{code1 = "1234", code2 = "2345", code3 = "3456", code4 = ""}}}}) - test.mock_time.advance_time(2) - test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id)) - test.mock_time.advance_time(2) - test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 2, user_code = "2345", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id)) - test.mock_time.advance_time(2) - test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 3, user_code = "3456", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id)) - test.mock_time.advance_time(2) - test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 4, user_id_status = UserCode.user_id_status.AVAILABLE}):build_test_tx(mock_device.id)) - test.mock_time.advance_time(2) - test.socket.zwave:__expect_send(UserCode:Get({user_identifier = 4}):build_test_tx(mock_device.id)) + -- delete the user which should also delete the credential + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { 1 } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) test.wait_for_events() - end, - { - min_api_version = 17 - } -) -test.register_message_test( - "Master code programming event should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { mock_device.id, Notification:Report({ + 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 - })} - }, - - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) + event = access_control_event.SINGLE_USER_CODE_DELETED, + payload = "\x21\x01\x00\xFF\x06\x0D\x00\x00" -- delete payload + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {}, + { state_change = true, visibility = { displayed = true } } + ) ) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "The lock reporting a single code has been set should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - UserCode:Report({ user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_identifier = 1}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }) ) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "Code 1"}, state_change = true })) - } - }, - { - inner_block_ordering = "relaxed", - min_api_version = 17 - } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {}, + { state_change = true, visibility = { displayed = true } }) + ) + ) + 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 = true } } + ) + ) + ) + test.wait_for_events() + end ) test.register_coroutine_test( - "The lock reporting a code has been deleted should be handled", + "Update credential should succeed", function() - init_code_slot(1, "Code 1", mock_device) - test.socket.zwave:__queue_receive( + added() + -- add credential + add_credential(0) + + -- update the credential + test.socket.capability:__queue_receive({mock_device.id, { - mock_device.id, - UserCode:Report({user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE}) - } + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { 1, 1, "pin", "3456" } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "3456", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) ) + 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_ADDED, + payload = "\x70\x01\x00\xFF\x06\x0E\x00\x00" -- update payload + }) + }) 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 = "Guest1", userType = "guest" }}, { state_change = true, visibility = { displayed = true } }) ) ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) - )) - end, - { - min_api_version = 17 - } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { state_change = true, visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end ) test.register_coroutine_test( - "The lock reporting that all codes have been deleted should be handled", + "Delete credential should succeed", function() - 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() + -- add the credential + add_credential(0) + + -- -- delete the credential + test.socket.capability:__queue_receive({mock_device.id, { - mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.ALL_USER_CODES_DELETED - }) - } + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { 1, "pin" } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) ) + test.wait_for_events() - test.socket.capability:__set_channel_ordering("relaxed") + 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, + payload = "\x21\x01\x00\xFF\x06\x0D\x00\x00" -- delete payload + }) + }) 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( + {}, + { state_change = true, visibility = { displayed = true } } + ) ) ) - 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.lockCredentials.credentials( + {}, + { state_change = true, visibility = { displayed = true } }) ) ) - 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.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1, }, + { state_change = true, visibility = { displayed = true } } + ) ) ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) - )) - end, - { - min_api_version = 17 - } + test.wait_for_events() + end ) test.register_coroutine_test( - "The lock reporting unlock via code should include the code info in the report", + "Delete all users should succeed", function() - init_code_slot(1, "Superb Owl", mock_device) - test.socket.zwave:__queue_receive( + added() + -- add credential + add_credential(0) + -- add second credential + add_credential(0) + + -- delete all users. This should also delete the two associated credentials + test.socket.capability:__queue_receive({mock_device.id, { - mock_device.id, - Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, - event_parameter = "" - }) - } + capability = capabilities.lockUsers.ID, + command = "deleteAllUsers", + args = {} + }, + }) + + -- 3 timers: deleteUser(1) at delay=0, deleteUser(2) at delay=2, clear_busy_state at delay=8 + 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) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) ) + test.wait_for_events() + test.mock_time.advance_time(0.5) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + -- credential 1 deletion acknowledged: user 1 removed, user 2 and credential 2 still present + 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, + payload = "\x21\x01\x00\xFF\x06\x0D\x00\x00" + }) + }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Superb Owl" } }) + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Guest2", userType = "guest" } }, + { state_change = true, visibility = { displayed = true } }) ) ) - end, - { - min_api_version = 17 - } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { state_change = true, visibility = { displayed = true } }) + ) + ) + -- commandResult must NOT be emitted here; command is still in progress + test.wait_for_events() + + -- credential 2 deletion acknowledged: both user 2 and credential 2 now removed + 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, + payload = "\x21\x02\x00\xFF\x06\x0D\x00\x00" + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } }) + ) + ) + test.wait_for_events() + + -- final timer fires, emitting commandResult only after all operations complete + test.mock_time.advance_time(8) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success"}, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.wait_for_events() + end ) test.register_coroutine_test( - "The lock reporting unlock via code should include the code number as the name if no name is set", + "The lock reporting unlock via code should include the code number", function() - init_code_slot(1, nil, mock_device) + added() + -- add credential + add_credential(0) + -- send unlock 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 = "" + event_parameter = "\x01" }) } ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", - capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + capabilities.lock.lock.unlocked({ data = { method = "keypad", userIndex = 1 } }) ) ) - end, - { - min_api_version = 17 - } + end ) test.register_coroutine_test( - "Getting all lock codes should advance as expected", + "Creating a credential should succeed if the lock responds with a user code report", function() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) - expect_reload_all_codes_messages() + added() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 0, "guest", "pin", "123" .. test_credential_index } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = test_credential_index, + user_code = "123" .. test_credential_index, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) test.wait_for_events() - 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 } }))) - for i = 1, 4 do - if (i ~= 1) then - test.socket.zwave:__expect_send(UserCode:Get({user_identifier = i}):build_test_tx(mock_device.id)) - end - test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ - user_identifier = i, - user_id_status = UserCode.user_id_status.AVAILABLE - })}) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged(i.." unset", { state_change = true }) - ) - ) - end - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.scanCodes("Complete", { visibility = { displayed = false } }) - )) - end, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Lock alarm reporting should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 22, alarm_level = 1}) - } - }, - { - 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, - Alarm:Report({alarm_type = 9}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 19, alarm_level = 3}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="keypad", codeName = "Code 3", codeId="3"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 18, alarm_level=0}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="keypad", codeName = "Master Code", codeId="0"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 21, alarm_level = 2}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 21, alarm_level = 1}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="keypad"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 23}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown({data={method="command"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 24}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="command"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 25}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="command"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 26}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown({data={method="auto"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 27}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="auto"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 32}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } })) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 13, alarm_level = 5}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["5"] = "Code 5"}), { visibility = { displayed = false } })) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("5 set", {data={codeName="Code 5"}, state_change = true })) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 34, alarm_level = 2}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 failed", { state_change = true })) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 161}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 168}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Alarm:Report({alarm_type = 169}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) - } - }, - { - min_api_version = 17 - } -) -test.register_coroutine_test( - "Setting a user code should result in the named code changed event firing when notified via Notification CC", - function() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) - test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id) ) + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })}) + table.insert(test_users, { userIndex = test_credential_index, userName = "Guest" .. test_credential_index, userType = "guest" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(test_users, + { state_change = true, visibility = { displayed = true } }) + ) + ) + table.insert(test_credentials, { userIndex = test_credential_index, credentialIndex = test_credential_index, credentialType = "pin" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials(test_credentials, + { state_change = true, visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = test_credential_index, userIndex = test_credential_index }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) 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, - v1_alarm_level = 1, - event_parameter = "" - }) }) - 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_credential_index = test_credential_index + 1 + end ) test.register_coroutine_test( - "When the device is added it should be set up and start reading codes", + "Lock alarm reporting should be handled", 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, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end, - { - min_api_version = 17 - } + added() + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 22, alarm_level = 1})}) + 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, Alarm:Report({alarm_type = 9})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unknown())) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 19, alarm_level = 3})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="keypad"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 18, alarm_level=0})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="keypad"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 21, alarm_level = 2})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 21, alarm_level = 1})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="keypad"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 23})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unknown({data={method="command"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 24})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="command"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 25})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="command"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 26})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unknown({data={method="auto"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 27})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="auto"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 32})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } }))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 13, alarm_level = 5})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockUsers.users({ { userIndex = 1, userName = "Guest1", userType = "guest"} }, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( { {userIndex = 1, credentialIndex = 5, credentialType = "pin" } }, { state_change = true, visibility = { displayed = true } }))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 34, alarm_level = 2})}) + -- no op because we have no active operation + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 161})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected())) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 168})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(1))) + test.socket.zwave:__queue_receive({ mock_device.id, Alarm:Report({alarm_type = 169})}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) + end ) -test.run_registered_tests() +test.run_registered_tests() \ No newline at end of file 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_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_slga_migration.lua new file mode 100644 index 0000000000..a71b817037 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_slga_migration.lua @@ -0,0 +1,132 @@ +-- Copyright 2025 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 "zwave_lock_utils" + +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_zwave_lock_credentials.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_credentials.lua new file mode 100644 index 0000000000..8f1591301d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_credentials.lua @@ -0,0 +1,632 @@ +-- 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" +--- @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 }) +--- @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 }) +local t_utils = require "integration_test.utils" +local access_control_event = Notification.event.access_control +local lock_utils = require "zwave_lock_utils" + +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-tamper.yml"), + _provisioning_state = "TYPED", + zwave_endpoints = zwave_lock_endpoints +}) + +-- Tracks expected users and credentials across test helpers +local test_credential_index +local test_credentials +local test_users + +local function test_init() + test.mock_device.add_test_device(mock_device) + test_credential_index = 1 + test_credentials = {} + test_users = {} +end + +test.set_test_init_function(test_init) + +-- Simulate the device being added (runs lifecycle, loads initial state) +local function added() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + 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.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tamperAlert.tamper.clear())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) + for i = 1, 8 do + test.socket.zwave:__expect_send(UserCode:Get({user_identifier = i}):build_test_tx(mock_device.id)) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ + user_identifier = i, + user_id_status = UserCode.user_id_status.AVAILABLE + })}) + end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } }))) + test.wait_for_events() +end + +-- Helper: add a credential (user_index 0 = auto-create guest user). +-- Uses a Notification report (the primary confirmation path for add). +-- Updates test_users and test_credentials tracking tables. +local function add_credential(user_index) + local expected_user_index = (user_index == 0) and test_credential_index or user_index + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { user_index, "guest", "pin", "1234" } + }}) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = test_credential_index, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + -- Notification confirms the code was added; v1_alarm_level encodes the credential index + local payload = "\x70\x01\x00\xFF\x06\x0E\x00\x00" + payload = payload:sub(1, 1) .. string.char(test_credential_index) .. payload:sub(3) + 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, + payload = payload + }) + }) + + if user_index == 0 then + table.insert(test_users, { + userIndex = expected_user_index, + userName = "Guest" .. expected_user_index, + userType = "guest" + }) + end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users(test_users, { state_change = true, visibility = { displayed = true } }))) + + table.insert(test_credentials, { + userIndex = expected_user_index, + credentialIndex = test_credential_index, + credentialType = "pin" + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(test_credentials, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", + credentialIndex = test_credential_index, userIndex = expected_user_index }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + test_credential_index = test_credential_index + 1 +end + +-- Helper: add a named user and return userIndex +local function add_user(user_name) + local user_index = #test_users + 1 + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { user_name, "guest" } + }}) + table.insert(test_users, { userIndex = user_index, userType = "guest", userName = user_name }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users(test_users, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = user_index }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + return user_index +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- addCredential tests +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "addCredential: auto-creates guest user and assigns sequential credential indices", + function() + added() + add_credential(0) -- credential 1, user 1 (Guest1) + add_credential(0) -- credential 2, user 2 (Guest2) + add_credential(0) -- credential 3, user 3 (Guest3) + -- All three should exist in state + assert(lock_utils.get_credential(mock_device, 1) ~= nil, "credential 1 should exist") + assert(lock_utils.get_credential(mock_device, 2) ~= nil, "credential 2 should exist") + assert(lock_utils.get_credential(mock_device, 3) ~= nil, "credential 3 should exist") + assert(lock_utils.get_user(mock_device, 1) ~= nil, "user 1 should exist") + assert(lock_utils.get_user(mock_device, 2) ~= nil, "user 2 should exist") + assert(lock_utils.get_user(mock_device, 3) ~= nil, "user 3 should exist") + end +) + +test.register_coroutine_test( + "addCredential: adding second credential for existing user returns STATUS_OCCUPIED", + function() + added() + local user_index = add_user("TestUser1") + -- add the first credential for this user, should succeed + add_credential(user_index) + + -- attempt to add a second credential for the same user (should fail with OCCUPIED) + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { user_index, "guest", "pin", "9999" } + }}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "occupied" }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + + -- only one credential should be present for this user + local count = 0 + for _, cred in pairs(lock_utils.get_credentials(mock_device)) do + if cred.userIndex == user_index then count = count + 1 end + end + assert(count == 1, "user should have exactly one credential, got " .. count) + end +) + +test.register_coroutine_test( + "addCredential: adding credential for non-existent user returns STATUS_FAILURE", + function() + added() + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 99, "guest", "pin", "1234" } -- user 99 does not exist + }}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- updateCredential tests +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "updateCredential: updates code in-place without creating a duplicate credential entry", + function() + added() + add_credential(0) -- credential 1, user 1 + + -- update credential 1 with a new pin + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { 1, 1, "pin", "9999" } + }}) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "9999", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + -- lock acknowledges code update at index 1 (v1_alarm_level = 1) + local payload = "\x70\x01\x00\xFF\x06\x0E\x00\x00" -- v1_alarm_level byte = 1 + 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, + payload = payload + }) + }) + -- credential count must remain 1 (no duplicates) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users( + {{ userIndex = 1, userName = "Guest1", userType = "guest" }}, + { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + {{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", + credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + + -- verify only one credential exists in driver state + local credentials = lock_utils.get_credentials(mock_device) + local count = 0 + for _ in pairs(credentials) do count = count + 1 end + assert(count == 1, "should have exactly 1 credential after update, got " .. count) + assert(credentials[1].credentialIndex == 1, "credential index should still be 1") + end +) + +test.register_coroutine_test( + "updateCredential: returns failure when credential does not exist", + function() + added() + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { 99, 1, "pin", "9999" } -- credential index 99 does not exist + }}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- deleteCredential tests +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "deleteCredential: deletes the correct credential by credentialIndex", + function() + added() + add_credential(0) -- credential 1, user 1 + add_credential(0) -- credential 2, user 2 + + -- delete credential 1, leaving credential 2 intact + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { 1, "pin" } + }}) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + -- lock confirms deletion of credential index 1 (v1_alarm_level = 1) + local payload = "\x21\x01\x00\xFF\x06\x0D\x00\x00" + 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, + payload = payload + }) + }) + -- user 1 deleted, user 2 remains + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users( + {{ userIndex = 2, userName = "Guest2", userType = "guest" }}, + { state_change = true, visibility = { displayed = true } }))) + -- credential 1 deleted, credential 2 remains + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + {{ userIndex = 2, credentialIndex = 2, credentialType = "pin" }}, + { state_change = true, visibility = { displayed = true } }))) + 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 = true } } + ))) + test.wait_for_events() + + assert(lock_utils.get_credential(mock_device, 1) == nil, "credential 1 should be deleted") + assert(lock_utils.get_credential(mock_device, 2) ~= nil, "credential 2 should remain") + assert(lock_utils.get_user(mock_device, 1) == nil, "user 1 should be deleted") + assert(lock_utils.get_user(mock_device, 2) ~= nil, "user 2 should remain") + end +) + +test.register_coroutine_test( + "deleteCredential: deletes the correct credential when deleting the second of two", + function() + added() + add_credential(0) -- credential 1, user 1 + add_credential(0) -- credential 2, user 2 + + -- delete credential 2, leaving credential 1 intact + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { 2, "pin" } + }}) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + -- lock confirms deletion of credential index 2 (v1_alarm_level = 2) + local payload = "\x21\x02\x00\xFF\x06\x0D\x00\x00" + 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, + payload = payload + }) + }) + -- user 2 deleted, user 1 remains + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users( + {{ userIndex = 1, userName = "Guest1", userType = "guest" }}, + { state_change = true, visibility = { displayed = true } }))) + -- credential 2 deleted, credential 1 remains + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + {{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, + { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", + credentialIndex = 2, userIndex = 2 }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + + assert(lock_utils.get_credential(mock_device, 1) ~= nil, "credential 1 should remain") + assert(lock_utils.get_credential(mock_device, 2) == nil, "credential 2 should be deleted") + assert(lock_utils.get_user(mock_device, 1) ~= nil, "user 1 should remain") + assert(lock_utils.get_user(mock_device, 2) == nil, "user 2 should be deleted") + end +) + +test.register_coroutine_test( + "deleteCredential: also deletes the associated guest user", + function() + added() + add_credential(0) -- creates guest user 1 + credential 1 + + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { 1, "pin" } + }}) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + + local payload = "\x21\x01\x00\xFF\x06\x0D\x00\x00" + 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, + payload = payload + }) + }) + -- both user and credential lists should now be empty + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } }))) + 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 = true } } + ))) + test.wait_for_events() + + assert(lock_utils.get_user(mock_device, 1) == nil, "associated user should also be deleted") + assert(lock_utils.get_credential(mock_device, 1) == nil, "credential should be deleted") + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure for non-existent credential index", + function() + added() + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { 99, "pin" } + }}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- deleteAllCredentials tests +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "deleteAllCredentials: deletes all credentials and all associated users", + function() + added() + add_credential(0) -- credential 1, user 1 + add_credential(0) -- credential 2, user 2 + + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {} + }}) + -- Both Z-wave Set commands are sent immediately (no timer delay between them) + test.socket.zwave:__expect_send( + UserCode:Set({ user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send( + UserCode:Set({ user_identifier = 2, user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id)) + -- A clear_busy_state timer is set for (delay + 4) = (2+2+4) = 8 seconds + test.timer.__create_and_queue_test_time_advance_timer(8, "oneshot") + test.wait_for_events() + + -- Lock acknowledges deletion of credential 1 (v1_alarm_level = 1) + 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, + payload = "\x21\x01\x00\xFF\x06\x0D\x00\x00" + }) + }) + -- user 1 and credential 1 deleted; user 2 and credential 2 still present + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users( + {{ userIndex = 2, userName = "Guest2", userType = "guest" }}, + { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + {{ userIndex = 2, credentialIndex = 2, credentialType = "pin" }}, + { state_change = true, visibility = { displayed = true } }))) + -- commandResult must NOT be emitted here (command is still in progress) + test.wait_for_events() + + -- Lock acknowledges deletion of credential 2 (v1_alarm_level = 2) + 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, + payload = "\x21\x02\x00\xFF\x06\x0D\x00\x00" + }) + }) + -- Now both user 2 and credential 2 are deleted + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } }))) + -- commandResult still must NOT be emitted here (timer hasn't fired yet) + test.wait_for_events() + + -- Timer fires -> commandResult emitted + test.mock_time.advance_time(8) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + + -- Verify final state: everything deleted + assert(lock_utils.get_credential(mock_device, 1) == nil, "credential 1 should be deleted") + assert(lock_utils.get_credential(mock_device, 2) == nil, "credential 2 should be deleted") + assert(lock_utils.get_user(mock_device, 1) == nil, "user 1 should be deleted") + assert(lock_utils.get_user(mock_device, 2) == nil, "user 2 should be deleted") + end +) + +test.register_coroutine_test( + "deleteAllCredentials: handles ALL_USER_CODES_DELETED notification from lock", + function() + added() + add_credential(0) -- credential 1, user 1 + add_credential(0) -- credential 2, user 2 + + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {} + }}) + test.socket.zwave:__expect_send( + UserCode:Set({ user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send( + UserCode:Set({ user_identifier = 2, user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id)) + test.timer.__create_and_queue_test_time_advance_timer(8, "oneshot") + test.wait_for_events() + + -- Some locks respond with ALL_USER_CODES_DELETED instead of individual events + -- ALL_USER_CODES_DELETED = 0x0C = 12 + test.socket.zwave:__queue_receive({mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED, + payload = "\x00\x00\x00\xFF\x06\x0C\x00\x00" + }) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } }))) + test.wait_for_events() + + test.mock_time.advance_time(8) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: no-op when there are no credentials", + function() + added() + test.socket.capability:__queue_receive({mock_device.id, { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {} + }}) + -- No Z-wave sends since there are no credentials + -- Timer fires immediately (delay = 0, so call_with_delay(4, ...)) + 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.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = true } } + ))) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua new file mode 100644 index 0000000000..d94fce2a1d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua @@ -0,0 +1,809 @@ +-- 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" +--- @type st.zwave.constants +local constants = require "st.zwave.constants" +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @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 }) +--- @type st.zwave.CommandClass.Alarm +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +local t_utils = require "integration_test.utils" +local zw_test_utils = require "integration_test.zwave_test_utils" + +-- 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-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } +) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +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( UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device.id) ) +end + +test.register_coroutine_test( + "Door Lock Operation Reports should be handled", + 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())) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, Battery:Report({ battery_level = 0x63 }) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) + } + }, + { + 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 = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.MANUAL_LOCK_OPERATION + }) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Code set reports should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, + UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"}), { visibility = { displayed = false } }) ) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 set", + { data = { codeName = "Code 2"}, state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Alarm tamper events should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_TEMPORARY_DISABLED + }) + } + }, + + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Sending the lock command should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(4.2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { capability = "lock", component = "main", command = "lock", args = {} } + }) + test.socket.zwave:__expect_send(DoorLock:OperationSet({door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED}):build_test_tx(mock_device.id)) + test.wait_for_events() + test.mock_time.advance_time(4.2) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max user code number report should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, UserCode:UsersNumberReport({ supported_users = 16 }) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false } })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reloading all codes of an unconfigured lock should generate correct attribute checks", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) + expect_reload_all_codes_messages() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Requesting a user code should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = capabilities.lockCodes.ID, command = "requestCode", args = { 1 } } } + }, + { + channel = "zwave", + direction = "send", + message = UserCode:Get({user_identifier = 1}):build_test_tx(mock_device.id) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Deleting a user code should be handled", + 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 = "deleteCode", args = { 1 } } }) + test.socket.zwave:__expect_send(UserCode:Set( {user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE}):build_test_tx(mock_device.id)) + test.wait_for_events() + + test.mock_time.advance_time(4.2) + test.socket.zwave:__expect_send(UserCode:Get( {user_identifier = 1}):build_test_tx(mock_device.id)) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code should result in the named code changed event firing", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id) ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({user_identifier = 1, 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 + } +) + +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( + "Setting a user code name should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "nameSlot", args = { 1, "foo" } } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"} ), { visibility = { displayed = false } }) + )) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", + {state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Calling updateCodes should send properly spaced commands", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "updateCodes", args = {{code1 = "1234", code2 = "2345", code3 = "3456", code4 = ""}}}}) + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id)) + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 2, user_code = "2345", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id)) + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 3, user_code = "3456", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id)) + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 4, user_id_status = UserCode.user_id_status.AVAILABLE}):build_test_tx(mock_device.id)) + test.mock_time.advance_time(2) + test.socket.zwave:__expect_send(UserCode:Get({user_identifier = 4}):build_test_tx(mock_device.id)) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Master code programming event should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { 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 + })} + }, + + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "The lock reporting a single code has been set should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + UserCode:Report({ user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, user_identifier = 1}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }) ) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "Code 1"}, state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The lock reporting a code has been deleted should be handled", + function() + init_code_slot(1, "Code 1", mock_device) + 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.lockCodes.codeChanged("1 deleted", { data = { codeName = "Code 1"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) + )) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The lock reporting that all codes have been deleted should be handled", + function() + 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.ALL_USER_CODES_DELETED + }) + } + ) + + test.socket.capability:__set_channel_ordering("relaxed") + 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({} ), { visibility = { displayed = false } }) + )) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The lock reporting unlock via code should include the code info in the report", + function() + init_code_slot(1, "Superb Owl", mock_device) + 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 = "" + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Superb Owl" } }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The lock reporting unlock via code should include the code number as the name if no name is set", + function() + init_code_slot(1, nil, mock_device) + 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 = "\x01" + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Getting all lock codes should advance as expected", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) + expect_reload_all_codes_messages() + test.wait_for_events() + 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 } }))) + for i = 1, 4 do + if (i ~= 1) then + test.socket.zwave:__expect_send(UserCode:Get({user_identifier = i}):build_test_tx(mock_device.id)) + end + test.socket.zwave:__queue_receive({mock_device.id, UserCode:Report({ + user_identifier = i, + user_id_status = UserCode.user_id_status.AVAILABLE + })}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged(i.." unset", { state_change = true }) + ) + ) + end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.scanCodes("Complete", { visibility = { displayed = false } }) + )) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Lock alarm reporting should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 22, alarm_level = 1}) + } + }, + { + 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, + Alarm:Report({alarm_type = 9}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 19, alarm_level = 3}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="keypad", codeName = "Code 3", codeId="3"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 18, alarm_level=0}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="keypad", codeName = "Master Code", codeId="0"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 21, alarm_level = 2}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 21, alarm_level = 1}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="keypad"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 23}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown({data={method="command"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 24}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="command"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 25}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="command"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 26}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown({data={method="auto"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 27}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="auto"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 32}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } })) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 13, alarm_level = 5}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["5"] = "Code 5"}), { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("5 set", {data={codeName="Code 5"}, state_change = true })) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 34, alarm_level = 2}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 failed", { state_change = true })) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 161}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 168}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Alarm:Report({alarm_type = 169}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code should result in the named code changed event firing when notified via Notification CC", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zwave:__expect_send(UserCode:Set({user_identifier = 1, user_code = "1234", user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}):build_test_tx(mock_device.id) ) + test.wait_for_events() + 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, + v1_alarm_level = 1, + event_parameter = "" + }) }) + 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( + "When the device is added it should be set up and start reading codes", + 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, + DoorLock:OperationGet({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Battery:Get({}) + ) + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +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..0d790992e0 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 lock_utils = require("zwave_lock_utils") + local slga_migrated = device:get_field(lock_utils.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..ee1d92fcce 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,7 @@ 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 lock_utils = require "zwave_lock_utils" local METHOD = { KEYPAD = "keypad", @@ -20,12 +17,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 @@ -36,24 +27,31 @@ local METHOD = { 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 + local credential_index = nil if (cmd.args.alarm_level ~= nil) then - code_id = tostring(cmd.args.alarm_level) + credential_index = 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} + if (credential_index ~= nil) then + local user_id = nil + local credential = lock_utils.get_credential(device, credential_index) + if (credential ~= nil) then + user_id = credential.userIndex + end + 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 user_id = nil + local credential = lock_utils.get_credential(device, credential_index) + if (credential ~= nil) then + user_id = credential.userIndex + end + event.data = { userIndex = user_id, method = METHOD.KEYPAD} end elseif (alarm_type == 21) then event = capabilities.lock.lock.locked() @@ -81,37 +79,62 @@ local function alarm_report_handler(driver, device, cmd) 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) + -- all credentials have been deleted + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) end - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + lock_utils.send_events(device) 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 + -- credential has been deleted. + if lock_utils.get_credential(device, credential_index) ~= nil then + lock_utils.delete_credential(device, credential_index) + lock_utils.send_events(device) 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) + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + if command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then + -- create credential if not already present. + if lock_utils.get_credential(device, credential_index) == nil then + lock_utils.add_credential(device, + active_credential.userIndex, + active_credential.credentialType, + credential_index) + lock_utils.send_events(device) + end + elseif command ~= nil and command.name == lock_utils.UPDATE_CREDENTIAL then + -- update credential + local credential = lock_utils.get_credential(device, credential_index) + if credential ~= nil then + lock_utils.update_credential(device, credential.credentialIndex, credential.userIndex, credential.credentialType) + lock_utils.send_events(device) + end + else + -- out-of-band update. Don't add if already in table. + if lock_utils.get_credential(device, credential_index) == nil then + local new_user_index = lock_utils.get_available_user_index(device) + if new_user_index ~= nil then + lock_utils.create_user(device, nil, "guest", new_user_index) + lock_utils.add_credential(device, + new_user_index, + lock_utils.CREDENTIAL_TYPE, + credential_index) + lock_utils.send_events(device) + else + if command ~= nil and command.name ~= lock_utils.DELETE_ALL_CREDENTIALS and command.name ~= lock_utils.DELETE_ALL_USERS then + lock_utils.clear_busy_state(device, lock_utils.STATUS_RESOURCE_EXHAUSTED) + end + end + end 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) + -- adding credential failed since code already exists. + -- remove the created user if one got made. There is no associated credential. + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + if active_credential ~= nil then lock_utils.delete_user(device, active_credential.userIndex) end + if command ~= nil and command.name ~= lock_utils.DELETE_ALL_CREDENTIALS and command.name ~= lock_utils.DELETE_ALL_USERS then + lock_utils.clear_busy_state(device, lock_utils.STATUS_DUPLICATE) end elseif (alarm_type == 130) then -- batteries replaced @@ -146,7 +169,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 diff --git a/drivers/SmartThings/zwave-lock/src/zwave_lock_utils.lua b/drivers/SmartThings/zwave-lock/src/zwave_lock_utils.lua new file mode 100644 index 0000000000..0c5e2c61c7 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/zwave_lock_utils.lua @@ -0,0 +1,595 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local INITIAL_INDEX = 1 + +local lock_utils = { + -- Constants + ADD_CREDENTIAL = "addCredential", + ADD_USER = "addUser", + BUSY = "busy", + COMMAND_IN_PROGRESS = "commandInProgress", + CREDENTIAL_TYPE = "pin", + CHECKING_CODE = "checkingCode", + DELETE_ALL_CREDENTIALS = "deleteAllCredentials", + DELETE_ALL_USERS = "deleteAllUsers", + DELETE_CREDENTIAL = "deleteCredential", + DELETE_USER = "deleteUser", + LOCK_CREDENTIALS = "lockCredentials", + LOCK_USERS = "lockUsers", + ACTIVE_CREDENTIAL = "pendingCredential", + STATUS_BUSY = "busy", + STATUS_DUPLICATE = "duplicate", + STATUS_FAILURE = "failure", + STATUS_INVALID_COMMAND = "invalidCommand", + STATUS_OCCUPIED = "occupied", + STATUS_RESOURCE_EXHAUSTED = "resourceExhausted", + STATUS_SUCCESS = "success", + TABLES_LOADED = "tablesLoaded", + UPDATE_CREDENTIAL = "updateCredential", + UPDATE_USER = "updateUser", + USER_INDEX = "userIndex", + USER_NAME = "userName", + USER_TYPE = "userType", + SLGA_MIGRATED = "slgaMigrated" +} + +local DEFAULT_SUPPORTED_PIN_SLOTS = 8 + +-- check if we are currently busy performing a task. +-- if we aren't then set as busy. +lock_utils.busy_check_and_set = function (device, command, override_busy_check) + if override_busy_check then + -- the function was called by an injected command. + return false + end + + local c_time = os.time() + local busy_state = device:get_field(lock_utils.BUSY) or false + + if busy_state == false or c_time - busy_state > 10 then + device:set_field(lock_utils.COMMAND_IN_PROGRESS, command) + device:set_field(lock_utils.BUSY, c_time) + return false + else + local command_result_info = { + commandName = command.name, + statusCode = lock_utils.STATUS_BUSY + } + if command.type == lock_utils.LOCK_USERS then + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, { state_change = true, visibility = { displayed = true } } + )) + else + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, { state_change = true, visibility = { displayed = true } } + )) + end + return true + end +end + +lock_utils.clear_busy_state = function(device, status, override_busy_check) + if override_busy_check then + return + end + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + if command ~= nil then + local command_result_info = { + commandName = command.name, + statusCode = status + } + if command.type == lock_utils.LOCK_USERS then + if active_credential ~= nil and active_credential.userIndex ~= nil then + command_result_info.userIndex = active_credential.userIndex + end + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, { state_change = true, visibility = { displayed = true } } + )) + else + if active_credential ~= nil and active_credential.userIndex ~= nil then + command_result_info.userIndex = active_credential.userIndex + end + if active_credential ~= nil and active_credential.credentialIndex ~= nil then + command_result_info.credentialIndex = active_credential.credentialIndex + end + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, { state_change = true, visibility = { displayed = true } } + )) + end + end + + device:set_field(lock_utils.ACTIVE_CREDENTIAL, nil) + device:set_field(lock_utils.COMMAND_IN_PROGRESS, nil) + device:set_field(lock_utils.BUSY, false) +end + +lock_utils.reload_tables = function(device) + local users = device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.users.NAME, {}) + local credentials = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.credentials.NAME, {}) + if next(users) ~= nil then + device:set_field(lock_utils.LOCK_USERS, users) + end + if next(credentials) ~= nil then + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) + end + + device:set_field(lock_utils.TABLES_LOADED, true) +end + +lock_utils.get_users = function(device) + if not device:get_field(lock_utils.TABLES_LOADED) then + lock_utils.reload_tables(device) + end + + local users = utils.deep_copy(device:get_field(lock_utils.LOCK_USERS)) + return users ~= nil and users or {} +end + +lock_utils.get_user = function(device, user_index) + for _, user in pairs(lock_utils.get_users(device)) do + if user.userIndex == user_index then + return user + end + end + + return nil +end + +lock_utils.get_available_user_index = function(device) + local max = device:get_latest_state("main", capabilities.lockUsers.ID, + capabilities.lockUsers.totalUsersSupported.NAME, DEFAULT_SUPPORTED_PIN_SLOTS) + local current_users = lock_utils.get_users(device) + local available_index = nil + local used_index = {} + for _, user in pairs(current_users) do + used_index[user.userIndex] = true + end + if current_users ~= {} then + for index = 1, max do + if used_index[index] == nil then + available_index = index + break + end + end + else + available_index = INITIAL_INDEX + end + return available_index +end + +lock_utils.get_credentials = function(device) + if not device:get_field(lock_utils.TABLES_LOADED) then + lock_utils.reload_tables(device) + end + + local credentials = utils.deep_copy(device:get_field(lock_utils.LOCK_CREDENTIALS)) + return credentials ~= nil and credentials or {} +end + +lock_utils.get_credential = function(device, credential_index) + for _, credential in pairs(lock_utils.get_credentials(device)) do + if credential.credentialIndex == credential_index then + return credential + end + end + return nil +end + +lock_utils.get_credential_by_user_index = function(device, user_index) + for _, credential in pairs(lock_utils.get_credentials(device)) do + if credential.userIndex == user_index then + return credential + end + end + + return nil +end + +lock_utils.get_available_credential_index = function(device) + local max = device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME, DEFAULT_SUPPORTED_PIN_SLOTS) + local current_credentials = lock_utils.get_credentials(device) + local available_index = nil + local used_index = {} + for _, credential in pairs(current_credentials) do + used_index[credential.credentialIndex] = true + end + if current_credentials ~= {} then + for index = 1, max do + if used_index[index] == nil then + available_index = index + break + end + end + else + available_index = INITIAL_INDEX + end + return available_index +end + +lock_utils.create_user = function(device, user_name, user_type, user_index) + if user_name == nil then + user_name = "Guest" .. user_index + end + + local current_users = lock_utils.get_users(device) + table.insert(current_users, { userIndex = user_index, userType = user_type, userName = user_name }) + device:set_field(lock_utils.LOCK_USERS, current_users) +end + +lock_utils.delete_user = function(device, user_index) + local current_users = lock_utils.get_users(device) + local status_code = lock_utils.STATUS_FAILURE + + for index, user in pairs(current_users) do + if user.userIndex == user_index then + -- table.remove causes issues if we are removing while iterating. + -- instead set the value as nil and let `prep_table` handle removing it. + current_users[index] = nil + device:set_field(lock_utils.LOCK_USERS, current_users) + status_code = lock_utils.STATUS_SUCCESS + break + end + end + return status_code +end + +lock_utils.add_credential = function(device, user_index, credential_type, credential_index) + local credentials = lock_utils.get_credentials(device) + table.insert(credentials, + { userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type }) + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) + return lock_utils.STATUS_SUCCESS +end + +lock_utils.delete_credential = function(device, credential_index) + local credentials = lock_utils.get_credentials(device) + local status_code = lock_utils.STATUS_FAILURE + + for index, credential in pairs(credentials) do + if credential.credentialIndex == credential_index then + lock_utils.delete_user(device, credential.userIndex) + -- table.remove causes issues if we are removing while iterating. + -- instead set the value as nil and let `prep_table` handle removing it. + credentials[index] = nil + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) + status_code = lock_utils.STATUS_SUCCESS + break + end + end + + return status_code +end + +lock_utils.update_credential = function(device, credential_index, user_index, credential_type) + local credentials = lock_utils.get_credentials(device) + local status_code = lock_utils.STATUS_FAILURE + + for _, credential in pairs(credentials) do + if credential.credentialIndex == credential_index then + credential.credentialType = credential_type + credential.userIndex = user_index + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) + status_code = lock_utils.STATUS_SUCCESS + break + end + end + return status_code +end + +-- emit_event doesn't like having `nil` values in the table. Remove any if they are present. +lock_utils.prep_table = function(data) + local clean_table = {} + for _, value in pairs(data) do + if value ~= nil then + clean_table[#clean_table + 1] = value -- Append to the end of the new array + end + end + return clean_table +end + +lock_utils.send_events = function(device, type) + if type == nil or type == lock_utils.LOCK_USERS then + local current_users = lock_utils.prep_table(lock_utils.get_users(device)) + device:emit_event(capabilities.lockUsers.users(current_users, + {state_change = true, visibility = { displayed = true } })) + end + if type == nil or type == lock_utils.LOCK_CREDENTIALS then + local credentials = lock_utils.prep_table(lock_utils.get_credentials(device)) + device:emit_event(capabilities.lockCredentials.credentials(credentials, + { state_change = true, visibility = { displayed = true } })) + end +end + +lock_utils.get_code_id_from_notification_event = function(event_params, v1_alarm_level) + -- some locks do not properly include the code ID in the event params, but do encode it + -- in the 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 + +-- This is the part of the notifcation event handler code from the base driver +-- that deals with lock code programming events +lock_utils.base_driver_code_event_handler = function(driver, device, cmd) + local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) + local access_control_event = Notification.event.access_control + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + 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)) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + local status = lock_utils.STATUS_SUCCESS + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + local emit_event = false + + if (event == access_control_event.ALL_USER_CODES_DELETED) then + -- all credentials have been deleted + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) + emit_event = true + end + elseif (event == access_control_event.SINGLE_USER_CODE_DELETED) then + -- credential has been deleted. + if lock_utils.get_credential(device, credential_index) ~= nil then + lock_utils.delete_credential(device, credential_index) + emit_event = true + end + elseif (event == access_control_event.NEW_USER_CODE_ADDED) then + if command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then + -- create credential if not already present. + if lock_utils.get_credential(device, credential_index) == nil then + lock_utils.add_credential(device, + active_credential.userIndex, + active_credential.credentialType, + credential_index) + emit_event = true + end + elseif command ~= nil and command.name == lock_utils.UPDATE_CREDENTIAL then + -- update credential + local credential = lock_utils.get_credential(device, credential_index) + if credential ~= nil then + lock_utils.update_credential(device, credential.credentialIndex, credential.userIndex, credential.credentialType) + emit_event = true + end + else + -- out-of-band update. Don't add if already in table. + if lock_utils.get_credential(device, credential_index) == nil then + local new_user_index = lock_utils.get_available_user_index(device) + if new_user_index ~= nil then + lock_utils.create_user(device, nil, "guest", new_user_index) + lock_utils.add_credential(device, + new_user_index, + lock_utils.CREDENTIAL_TYPE, + credential_index) + emit_event = true + else + status = lock_utils.STATUS_RESOURCE_EXHAUSTED + end + end + end + elseif (event == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE) then + -- adding credential failed since code already exists. + -- remove the created user if one got made. There is no associated credential. + status = lock_utils.STATUS_DUPLICATE + if active_credential ~= nil then lock_utils.delete_user(device, active_credential.userIndex) end + elseif (event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION) then + -- master code changed -- should we send an index with this? + device:emit_event(capabilities.lockCredentials.commandResult( + {commandName = lock_utils.UPDATE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS}, + { state_change = true, visibility = { displayed = true } } + )) + end + + -- handle emitting events if any changes occured. + if emit_event then + lock_utils.send_events(device) + end + -- clear the busy state and handle the commandStatus + -- ignore handling the busy state for some commands, they are handled within their own handlers + if command ~= nil and command.name ~= lock_utils.DELETE_ALL_CREDENTIALS and command.name ~= lock_utils.DELETE_ALL_USERS then + lock_utils.clear_busy_state(device, status) + end + end +end + +lock_utils.door_operation_event_handler = function(driver, device, cmd) + local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) + local access_control_event = Notification.event.access_control + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event = cmd.args.event + if (event >= access_control_event.MANUAL_LOCK_OPERATION and event <= access_control_event.LOCK_JAMMED) then + local event_to_send + + local METHOD = { + KEYPAD = "keypad", + MANUAL = "manual", + COMMAND = "command", + AUTO = "auto" + } + + local DELAY_LOCK_EVENT = "_delay_lock_event" + local DELAY_LOCK_EVENT_TIMER = "_delay_lock_event_timer" + local MAX_DELAY = 10 + + if ((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()} + event_to_send = events[event & 1] + elseif (event >= access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION and + event <= access_control_event.LOCK_JAMMED) then + event_to_send = capabilities.lock.lock.unknown() + end + + if (event_to_send ~= nil) then + local method_map = { + [access_control_event.MANUAL_UNLOCK_OPERATION] = METHOD.MANUAL, + [access_control_event.MANUAL_LOCK_OPERATION] = METHOD.MANUAL, + [access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION] = METHOD.MANUAL, + [access_control_event.RF_LOCK_OPERATION] = METHOD.COMMAND, + [access_control_event.RF_UNLOCK_OPERATION] = METHOD.COMMAND, + [access_control_event.RF_NOT_FULLY_LOCKED_OPERATION] = METHOD.COMMAND, + [access_control_event.KEYPAD_LOCK_OPERATION] = METHOD.KEYPAD, + [access_control_event.KEYPAD_UNLOCK_OPERATION] = METHOD.KEYPAD, + [access_control_event.AUTO_LOCK_LOCKED_OPERATION] = METHOD.AUTO, + [access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION] = METHOD.AUTO + } + + event_to_send["data"] = {method = method_map[event]} + + -- SPECIAL CASES: + if (event == access_control_event.MANUAL_UNLOCK_OPERATION and cmd.args.event_parameter == 2) then + -- functionality from DTH, some locks can distinguish being manually locked via keypad + event_to_send.data.method = METHOD.KEYPAD + elseif (event == access_control_event.KEYPAD_LOCK_OPERATION or event == access_control_event.KEYPAD_UNLOCK_OPERATION) then + local code_id = cmd.args.v1_alarm_level + if cmd.args.event_parameter ~= nil and string.len(cmd.args.event_parameter) ~= 0 then + local event_params = { cmd.args.event_parameter:byte(1, -1) } + code_id = (#event_params == 1) and event_params[1] or event_params[3] + end + local user_id = nil + local credential = lock_utils.get_credential(device, code_id) + if (credential ~= nil) then + user_id = credential.userIndex + end + if user_id ~= nil then event_to_send["data"] = { userIndex = user_id, method = event_to_send["data"].method } end + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + if device:get_latest_state( + "main", + capabilities.lock.ID, + capabilities.lock.lock.ID) == event_to_send.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local socket = require "socket" + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + local timer = device:get_field(DELAY_LOCK_EVENT_TIMER) + if timer ~= nil then + device.thread:cancel_timer(timer) + device:set_field(DELAY_LOCK_EVENT_TIMER, nil) + end + + device:emit_event(event_to_send) + end + end + end +end + +function lock_utils.add_credential_handler(driver, device, command) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + if lock_utils.busy_check_and_set(device, {name = lock_utils.ADD_CREDENTIAL, type = lock_utils.LOCK_CREDENTIALS}) then + return + end + local user_index = tonumber(command.args.userIndex) + local user_type = command.args.userType + local credential_type = command.args.credentialType + local credential_data = command.args.credentialData + local status = lock_utils.STATUS_SUCCESS + + local credential_index = lock_utils.get_available_credential_index(device) + if credential_index == nil then + status = lock_utils.STATUS_RESOURCE_EXHAUSTED + elseif user_index ~= 0 and lock_utils.get_credential_by_user_index(device, user_index) then + status = lock_utils.STATUS_OCCUPIED + elseif user_index ~= 0 and lock_utils.get_user(device, user_index) == nil then + status = lock_utils.STATUS_FAILURE + end + + if user_index == 0 then + user_index = lock_utils.get_available_user_index(device) + if user_index ~= nil then + lock_utils.create_user(device, nil, user_type, user_index) + else + status = lock_utils.STATUS_RESOURCE_EXHAUSTED + end + end + + if status == lock_utils.STATUS_SUCCESS then + device:set_field(lock_utils.ACTIVE_CREDENTIAL, + { userIndex = user_index, userType = user_type, credentialType = credential_type, credentialIndex = credential_index }) + device:send(UserCode:Set({ + user_identifier = credential_index, + user_code = credential_data, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) + -- clearing busy state handled in user_code_report_handler + else + lock_utils.clear_busy_state(device, status) + end +end + +function lock_utils.user_code_report_handler(driver, device, cmd) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + local credential_index = cmd.args.user_identifier + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + local user_id_status = cmd.args.user_id_status + local emit_events = false + + if (user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or + (user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code)) then + if lock_utils.get_credential(device, credential_index) == nil and command == nil then + local user_index = lock_utils.get_available_user_index(device) + if user_index ~= nil then + lock_utils.create_user(device, nil, "guest", user_index) + lock_utils.add_credential(device, user_index, lock_utils.CREDENTIAL_TYPE, credential_index) + emit_events = true + end + elseif command ~= nil then + if command.name == lock_utils.ADD_CREDENTIAL and lock_utils.get_credential(device, credential_index) == nil then + lock_utils.add_credential(device, + active_credential.userIndex, + active_credential.credentialType, + credential_index) + emit_events = true + elseif command.name == lock_utils.UPDATE_CREDENTIAL then + local credential = lock_utils.get_credential(device, credential_index) + if credential ~= nil then + lock_utils.update_credential(device, credential.credentialIndex, credential.userIndex, credential.credentialType) + emit_events = true + end + end + end + elseif user_id_status == UserCode.user_id_status.AVAILABLE then + if lock_utils.get_credential(device, credential_index) ~= nil then + lock_utils.delete_credential(device, credential_index) + emit_events = true + end + end + + if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then + local last_slot = device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME, DEFAULT_SUPPORTED_PIN_SLOTS) + if (credential_index >= last_slot) then + device:set_field(lock_utils.CHECKING_CODE, nil) + emit_events = true + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(UserCode:Get({user_identifier = checkingCode})) + end + end + + if emit_events then + lock_utils.send_events(device) + end + + if command ~= nil and command.name ~= lock_utils.DELETE_ALL_CREDENTIALS and command.name ~= lock_utils.DELETE_ALL_USERS then + lock_utils.clear_busy_state(device, lock_utils.STATUS_SUCCESS) + end +end + +return lock_utils