From 6a807a2dc953eb5270c7391937f95d5ffa57f3e5 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 30 Apr 2026 12:55:03 -0500 Subject: [PATCH 01/33] CHAD-16364: Update Zigbee lock capabilities --- .../zigbee-lock/profiles/base-lock.yml | 4 + drivers/SmartThings/zigbee-lock/src/init.lua | 323 +-------- .../src/lock-without-codes/can_handle.lua | 14 - .../zigbee-lock/src/lock_utils.lua | 3 +- .../zigbee-lock/src/new_lock_utils.lua | 316 +++++++++ .../zigbee-lock/src/sub_drivers.lua | 6 +- .../test/test_zigbee_lock_code_migration.lua | 6 +- .../test_zigbee_lock_code_slga_migration.lua | 84 +++ .../test_zigbee_lock_new_capabilities.lua | 625 ++++++++++++++++++ .../test_zigbee_yale-fingerprint-lock.lua | 37 +- .../test_zigbee_yale-new_capabilities.lua | 553 ++++++++++++++++ .../src/using-new-capabilities/can_handle.lua | 13 + .../src/using-new-capabilities/init.lua | 570 ++++++++++++++++ .../lock-without-codes/can_handle.lua | 17 + .../lock-without-codes/init.lua | 87 +++ .../samsungsds/can_handle.lua | 7 +- .../samsungsds/init.lua | 118 ++++ .../using-new-capabilities/sub_drivers.lua | 11 + .../yale-fingerprint-lock/can_handle.lua | 19 + .../yale-fingerprint-lock/init.lua | 40 ++ .../yale/can_handle.lua | 7 +- .../src/using-new-capabilities/yale/init.lua | 171 +++++ .../yale/sub_drivers.lua | 8 + .../yale-bad-battery-reporter/can_handle.lua | 21 + .../yale/yale-bad-battery-reporter/init.lua | 34 + .../src/using-old-capabilities/can_handle.lua | 13 + .../src/using-old-capabilities/init.lua | 413 ++++++++++++ .../lock-without-codes/can_handle.lua | 13 + .../lock-without-codes/fingerprints.lua | 0 .../lock-without-codes/init.lua | 5 +- .../samsungsds/can_handle.lua | 10 + .../samsungsds/init.lua | 2 +- .../using-old-capabilities/sub_drivers.lua | 11 + .../yale-fingerprint-lock/can_handle.lua | 13 + .../yale-fingerprint-lock/fingerprints.lua | 0 .../yale-fingerprint-lock/init.lua | 5 +- .../yale/can_handle.lua | 10 + .../yale/init.lua | 4 +- .../yale/sub_drivers.lua | 2 +- .../yale-bad-battery-reporter/can_handle.lua | 13 + .../fingerprints.lua | 0 .../yale/yale-bad-battery-reporter/init.lua | 5 +- .../src/yale-fingerprint-lock/can_handle.lua | 14 - .../yale-bad-battery-reporter/can_handle.lua | 14 - 44 files changed, 3256 insertions(+), 385 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-lock/src/lock-without-codes/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua rename drivers/SmartThings/zigbee-lock/src/{ => using-new-capabilities}/samsungsds/can_handle.lua (55%) create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua rename drivers/SmartThings/zigbee-lock/src/{ => using-new-capabilities}/yale/can_handle.lua (62%) create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/can_handle.lua rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/lock-without-codes/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/lock-without-codes/init.lua (96%) create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/samsungsds/init.lua (98%) create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/can_handle.lua rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/yale-fingerprint-lock/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/yale-fingerprint-lock/init.lua (90%) create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/yale/init.lua (97%) rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/yale/sub_drivers.lua (69%) create mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/can_handle.lua rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/yale/yale-bad-battery-reporter/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-lock/src/{ => using-old-capabilities}/yale/yale-bad-battery-reporter/init.lua (86%) delete mode 100644 drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/can_handle.lua diff --git a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml b/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml index 159e6939f3..c9c98898c6 100644 --- a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zigbee-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: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 94f5adc0c4..3b72804fac 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -4,14 +4,12 @@ -- Zigbee Driver utilities local defaults = require "st.zigbee.defaults" -local device_management = require "st.zigbee.device_management" local ZigbeeDriver = require "st.zigbee" -- Zigbee Spec Utils local clusters = require "st.zigbee.zcl.clusters" local Alarm = clusters.Alarms local LockCluster = clusters.DoorLock -local PowerConfiguration = clusters.PowerConfiguration -- Capabilities local capabilities = require "st.capabilities" @@ -19,34 +17,13 @@ local Battery = capabilities.battery local Lock = capabilities.lock local LockCodes = capabilities.lockCodes --- Enums -local UserStatusEnum = LockCluster.types.DrlkUserStatus -local UserTypeEnum = LockCluster.types.DrlkUserType -local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode - local socket = require "cosock.socket" local lock_utils = require "lock_utils" +local new_lock_utils = require "new_lock_utils" local DELAY_LOCK_EVENT = "_delay_lock_event" local MAX_DELAY = 10 -local reload_all_codes = function(driver, device, command) - -- starts at first user code index then iterates through all lock codes as they come in - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MinPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - end - if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end - device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) - device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) -end - local refresh = function(driver, device, cmd) device:refresh() device:send(LockCluster.attributes.LockState:read(device)) @@ -61,30 +38,6 @@ local refresh = function(driver, device, cmd) end end -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - - device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) - device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - - device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) - device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - -- Don't send a reload all codes if this is a part of migration - if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then - device.thread:call_with_delay(2, function(d) - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end) - else - device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) - end -end - local alarm_handler = function(driver, device, zb_mess) local ALARM_REPORT = { [0] = Lock.lock.unknown(), @@ -96,193 +49,21 @@ local alarm_handler = function(driver, device, zb_mess) end end -local get_pin_response_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {codeName = lock_utils.get_code_name(device, code_slot)} - if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then - -- Code slot is occupied - event.value = code_slot .. lock_utils.get_change_type(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - else - -- Code slot is unoccupied - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - -- Code has been deleted - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - else - -- Code is unset - event.value = code_slot .. " unset" - device:emit_event(event) - end - end - - code_slot = tonumber(code_slot) - if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then - -- the code we're checking has arrived - local last_slot = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - 1 - if (code_slot >= last_slot) then - device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) - device:set_field(lock_utils.CHECKING_CODE, nil) - else - local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 - device:set_field(lock_utils.CHECKING_CODE, checkingCode) - device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) - end - end -end - -local programming_event_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {} - if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - -- Master code changed - event.value = "0 set" - event.data = {codeName = "Master Code"} - device:emit_event(event) - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (zb_mess.body.zcl_body.user_id.value == 0xFF) then - -- All codes deleted - for cs, _ in pairs(lock_utils.get_lock_codes(device)) do - lock_utils.code_deleted(device, cs) - end - lock_utils.lock_codes_event(device, {}) +-- this command should now trigger setting the migrated field and reinjecting the command. +-- this is so we can start using the new capbilities from now on. +local function device_added(driver, device) + -- this variable should only be present for test cases trying to test the old capabilities. + if device.useOldCapabilityForTesting == nil then + if device:supports_capability_by_id(LockCodes.ID) then + device:emit_event(LockCodes.migrated(true, { state_change = true, visibility = { displayed = true } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + new_lock_utils.reload_tables(device) else - -- One code deleted - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - end - end - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then - -- Code added or changed - local change_type = lock_utils.get_change_type(device, code_slot) - local code_name = lock_utils.get_code_name(device, code_slot) - event.value = code_slot .. change_type - event.data = {codeName = code_name} - device:emit_event(event) - if (change_type == " set") then - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = code_name - lock_utils.lock_codes_event(device, lock_codes) - end - end -end - -local handle_max_codes = function(driver, device, value) - if value.value ~= 0 then - -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, - -- but it gave us a non-zero number of pin users, so we want to switch the profile - if not device:supports_capability_by_id(LockCodes.ID) then - device:try_update_metadata({profile = "base-lock"}) -- switch to a lock with codes - lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes - if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes - -- so we'll load them manually - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end - end - device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) - end -end - -local handle_max_code_length = function(driver, device, value) - device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) -end - -local handle_min_code_length = function(driver, device, value) - device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) -end - -local update_codes = function(driver, device, command) - local delay = 0 - -- args.codes is json - for name, code in pairs(command.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 - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.SetPINCode(device, - code_slot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - code)) - end) - delay = delay + 2 - else - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) - end) - delay = delay + 2 - end - device.thread:call_with_delay(delay, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) - end) - delay = delay + 2 + lock_utils.populate_state_from_data(device) end - end -end - -local delete_code = function(driver, device, command) - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) - device.thread:call_with_delay(2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) -end - -local request_code = function(driver, device, command) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) -end - -local set_code = function(driver, device, command) - if (command.args.codePIN == "") then - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {command.args.codeSlot, command.args.codeName} - }) else - device:send(LockCluster.server.commands.SetPINCode(device, - command.args.codeSlot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - command.args.codePIN) - ) - if (command.args.codeName ~= nil) then - -- wait for confirmation from the lock to commit this to memory - -- Groovy driver has a lot more info passed here as a description string, may need to be investigated - local codeState = device:get_field(lock_utils.CODE_STATE) or {} - codeState["setName"..command.args.codeSlot] = command.args.codeName - device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) - end - - device.thread:call_with_delay(4, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) + lock_utils.populate_state_from_data(device) end -end - -local name_slot = function(driver, device, command) - local code_slot = tostring(command.args.codeSlot) - local lock_codes = lock_utils.get_lock_codes(device) - if (lock_codes[code_slot] ~= nil) then - lock_codes[code_slot] = command.args.codeName - device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) - lock_utils.lock_codes_event(device, lock_codes) - end -end - -local function device_added(driver, device) - lock_utils.populate_state_from_data(device) driver:inject_capability_command(device, { capability = capabilities.refresh.ID, @@ -321,69 +102,6 @@ local lock_state_handler = function(driver, device, value, zb_rx) end end -local lock_operation_event_handler = function(driver, device, zb_rx) - local event_code = zb_rx.body.zcl_body.operation_event_code.value - local source = zb_rx.body.zcl_body.operation_event_source.value - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" - } - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() - } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" - else - event.data.method = METHOD[source] - end - if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad - local code_id = zb_rx.body.zcl_body.user_id.value - local code_name = "Code "..code_id - local lock_codes = device:get_field("lockCodes") - if (lock_codes ~= nil and - lock_codes[code_id] ~= nil) then - code_name = lock_codes[code_id] - end - event.data = {method = METHOD[0], codeId = code_id .. "", codeName = code_name} - 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( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) - end - end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) - end -end - local function lock(driver, device, command) device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) end @@ -403,30 +121,14 @@ local zigbee_lock_driver = { [Alarm.ID] = { [Alarm.client.commands.Alarm.ID] = alarm_handler }, - [LockCluster.ID] = { - [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, - [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, - [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler - } }, attr = { [LockCluster.ID] = { [LockCluster.attributes.LockState.ID] = lock_state_handler, - [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, - [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, - [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes } } }, capability_handlers = { - [LockCodes.ID] = { - [LockCodes.commands.updateCodes.NAME] = update_codes, - [LockCodes.commands.deleteCode.NAME] = delete_code, - [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [LockCodes.commands.requestCode.NAME] = request_code, - [LockCodes.commands.setCode.NAME] = set_code, - [LockCodes.commands.nameSlot.NAME] = name_slot, - }, [Lock.ID] = { [Lock.commands.lock.NAME] = lock, [Lock.commands.unlock.NAME] = unlock, @@ -437,7 +139,6 @@ local zigbee_lock_driver = { }, sub_drivers = require("sub_drivers"), lifecycle_handlers = { - doConfigure = do_configure, added = device_added, init = init, }, diff --git a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/lock-without-codes/can_handle.lua deleted file mode 100644 index 543e43a8b1..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/can_handle.lua +++ /dev/null @@ -1,14 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local function can_handle_lock_without_codes(opts, driver, device) - local FINGERPRINTS = require("lock-without-codes.fingerprints") - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("lock-without-codes") - end - end - return false -end - -return can_handle_lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils.lua index a02a59963c..d9c06f41f6 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils.lua @@ -9,11 +9,12 @@ local LockCodes = capabilities.lockCodes local lock_utils = { -- Constants LOCK_CODES = "lockCodes", + LOCK_USERS = "lockUsers", CHECKING_CODE = "checkingCode", CODE_STATE = "codeState", MIGRATION_COMPLETE = "migrationComplete", MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped", - CHECKED_CODE_SUPPORT = "checkedCodeSupport" + CHECKED_CODE_SUPPORT = "checkedCodeSupport", } lock_utils.get_lock_codes = function(device) diff --git a/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua new file mode 100644 index 0000000000..9d5848b21b --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua @@ -0,0 +1,316 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local INITIAL_INDEX = 1 + +local new_lock_utils = { + -- Constants + ADD_CREDENTIAL = "addCredential", + ADD_USER = "addUser", + BUSY = "busy", + COMMAND_NAME = "commandName", + 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 = "activeCredential", + 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" +} + +-- check if we are currently busy performing a task. +-- if we aren't then set as busy. +new_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(new_lock_utils.BUSY) or false + + if busy_state == false or c_time - busy_state > 10 then + device:set_field(new_lock_utils.COMMAND_NAME, command) + device:set_field(new_lock_utils.BUSY, c_time) + return false + else + local command_result_info = { + commandName = command.name, + statusCode = new_lock_utils.STATUS_BUSY + } + if command.type == new_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 + +new_lock_utils.clear_busy_state = function(device, status, override_busy_check) + if override_busy_check then + return + end + local command = device:get_field(new_lock_utils.COMMAND_NAME) + local active_credential = device:get_field(new_lock_utils.ACTIVE_CREDENTIAL) + if command ~= nil then + local command_result_info = { + commandName = command.name, + statusCode = status + } + if command.type == new_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(new_lock_utils.ACTIVE_CREDENTIAL, nil) + device:set_field(new_lock_utils.COMMAND_NAME, nil) + device:set_field(new_lock_utils.BUSY, false) +end + +new_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(new_lock_utils.LOCK_USERS, users) + end + if next(credentials) ~= nil then + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + end + device:set_field(new_lock_utils.TABLES_LOADED, true) +end + +new_lock_utils.get_users = function(device) + if not device:get_field(new_lock_utils.TABLES_LOADED) then + new_lock_utils.reload_tables(device) + end + + local users = utils.deep_copy(device:get_field(new_lock_utils.LOCK_USERS)) + return users ~= nil and users or {} +end + +new_lock_utils.get_user = function(device, user_index) + for _, user in pairs(new_lock_utils.get_users(device)) do + if user.userIndex == user_index then + return user + end + end + + return nil +end + +new_lock_utils.get_available_user_index = function(device) + local max = device:get_latest_state("main", capabilities.lockUsers.ID, + capabilities.lockUsers.totalUsersSupported.NAME, 0) + local current_users = new_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 + +new_lock_utils.get_credentials = function(device) + if not device:get_field(new_lock_utils.TABLES_LOADED) then + new_lock_utils.reload_tables(device) + end + + local credentials = utils.deep_copy(device:get_field(new_lock_utils.LOCK_CREDENTIALS)) + return credentials ~= nil and credentials or {} +end + +new_lock_utils.get_credential = function(device, credential_index) + for _, credential in pairs(new_lock_utils.get_credentials(device)) do + if credential.credentialIndex == credential_index then + return credential + end + end + return nil +end + +new_lock_utils.get_credential_by_user_index = function(device, user_index) + for _, credential in pairs(new_lock_utils.get_credentials(device)) do + if credential.userIndex == user_index then + return credential + end + end + + return nil +end + +new_lock_utils.get_available_credential_index = function(device) + local max = device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME, 0) + local current_credentials = new_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 + +new_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 = new_lock_utils.get_users(device) + table.insert(current_users, { userIndex = user_index, userType = user_type, userName = user_name }) + device:set_field(new_lock_utils.LOCK_USERS, current_users, { persist = true }) +end + +new_lock_utils.delete_user = function(device, user_index) + local current_users = new_lock_utils.get_users(device) + local status_code = new_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(new_lock_utils.LOCK_USERS, current_users) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + return status_code +end + +new_lock_utils.add_credential = function(device, user_index, credential_type, credential_index) + local credentials = new_lock_utils.get_credentials(device) + table.insert(credentials, + { userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type }) + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) + return new_lock_utils.STATUS_SUCCESS +end + +new_lock_utils.delete_credential = function(device, credential_index) + local credentials = new_lock_utils.get_credentials(device) + local status_code = new_lock_utils.STATUS_FAILURE + + for index, credential in pairs(credentials) do + if credential.credentialIndex == credential_index then + new_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(new_lock_utils.LOCK_CREDENTIALS, credentials) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + + return status_code +end + +new_lock_utils.update_credential = function(device, credential_index, user_index, credential_type) + local credentials = new_lock_utils.get_credentials(device) + local status_code = new_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(new_lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) + status_code = new_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. +new_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 + +new_lock_utils.send_events = function(device, type) + if type == nil or type == new_lock_utils.LOCK_USERS then + local current_users = new_lock_utils.prep_table(new_lock_utils.get_users(device)) + device:set_field(new_lock_utils.LOCK_USERS, current_users, { persist = true }) + device:emit_event(capabilities.lockUsers.users(current_users, + {state_change = true, visibility = { displayed = true } })) + end + if type == nil or type == new_lock_utils.LOCK_CREDENTIALS then + local credentials = new_lock_utils.prep_table(new_lock_utils.get_credentials(device)) + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) + device:emit_event(capabilities.lockCredentials.credentials(credentials, + { state_change = true, visibility = { displayed = true } })) + end +end + +return new_lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua index ff4bf8980d..e7ed53082b 100644 --- a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua @@ -3,9 +3,7 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { - lazy_load_if_possible("samsungsds"), - lazy_load_if_possible("yale"), - lazy_load_if_possible("yale-fingerprint-lock"), - lazy_load_if_possible("lock-without-codes"), + lazy_load_if_possible("using-old-capabilities"), + lazy_load_if_possible("using-new-capabilities"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua index d4ee3f770f..bc1c2b54ef 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua @@ -23,14 +23,16 @@ local mock_device = test.mock_device.build_test_zigbee_device( ["1"] = "Zach", ["2"] = "Steven" }) - } + }, + useOldCapabilityForTesting = true } ) local mock_device_no_data = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("base-lock.yml"), - data = {} + data = {}, + useOldCapabilityForTesting = true } ) zigbee_test_utils.prepare_zigbee_env_info() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua new file mode 100644 index 0000000000..d0de6d6824 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua @@ -0,0 +1,84 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local PowerConfiguration = clusters.PowerConfiguration +local DoorLock = clusters.DoorLock +local Alarm = clusters.Alarms +local capabilities = require "st.capabilities" + +local json = require "st.json" + +local mock_datastore = require "integration_test.mock_env_datastore" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + data = { + lockCodes = json.encode({ + ["1"] = "Zach", + ["5"] = "Steven" + }), + }, + useOldCapabilityForTesting = true + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init()end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) + test.wait_for_events() + -- Validate lockCodes field + mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["5"] = "Steven" }) + -- Validate migration complete flag + mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) + + -- Set min/max code length attributes + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 5) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 10) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate `migrate` command functionality. + test.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(5, { 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.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.lockUsers.totalUsersSupported(4, { 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() + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua new file mode 100644 index 0000000000..d23423983d --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua @@ -0,0 +1,625 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local capabilities = require "st.capabilities" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType + +local test_credential_index = 1 +local test_credentials = {} +local test_users = {} +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init_new_capabilities() + test_credential_index = 1 + test_credentials = {} + test_users = {} + test.mock_device.add_test_device(mock_device) +end + +local function init_migration() + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( + mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report( + mock_device, 8) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.maxCodeLength(8, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported + :build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + 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(8, { 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({}, { 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.users({}, { 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.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() +end + +local function add_default_users() + local user_list = {} + for i = 1, 4 do + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "Guest" .. i, "guest" } + }, + }) + -- add to the user list that is now expected + table.insert(user_list, { userIndex = i, userType = "guest", userName = "Guest" .. i }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + user_list, + { 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 = i }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end +end + +local function add_credential(user_index, credential_data) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { user_index, "guest", "pin", credential_data } + }, + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + test_credential_index, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + credential_data + ) + } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, test_credential_index) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + test_credential_index, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + credential_data + ) + } + ) + table.insert(test_credentials, + { userIndex = test_credential_index, credentialIndex = test_credential_index, credentialType = "pin" }) + 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 } }) + ) + ) + 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 + +test.set_test_init_function(test_init_new_capabilities) + +test.register_coroutine_test( + "Add User command received and commandResult is success until totalUsersSupported reached", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial max users + add_default_users() + + -- 5th addUser call - totalUsersSupported is passsed and now commandResult should be resourceExhausted + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Update User command reports a commandResult of success unless user index doesn't exist", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial users + add_default_users() + + -- success + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = { "2", "ChangeUserName", "guest" } + }, + }) + + local users = { + { userIndex = 1, userName = "Guest1", userType = "guest" }, + { userIndex = 2, userName = "ChangeUserName", userType = "guest" }, + { userIndex = 3, userName = "Guest3", userType = "guest" }, + { userIndex = 4, userName = "Guest4", userType = "guest" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(users, { 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 = 2 }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + + -- failure - try updating non existent userIndex + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = { "6", "ChangeUserName", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Delete User command reports a commandResult of success unless user index doesn't exist", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial users + add_default_users() + + -- success + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { "3" } + }, + }) + + local users = { + { userIndex = 1, userName = "Guest1", userType = "guest" }, + { userIndex = 2, userName = "Guest2", userType = "guest" }, + { userIndex = 4, userName = "Guest4", userType = "guest" }, + } + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(users, { 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 = 3 }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + + -- failure - try updating non existent userIndex + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { "3" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end +) + + +test.register_coroutine_test( + "addCredential command received and commandResult is success", + function() + init_migration() + add_credential(0, "abc123") + end +) + +test.register_coroutine_test( + "updateCredential command received and commandResult is success", + function() + init_migration() + add_credential(0, "abc123") + + -- try to update the wrong credentialIndex (4) first and expect a failure + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "4", "4", "pin", "abc123" } + }, + }) + 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() + + -- try to update the right credential + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "1", "1", "pin", "changedPin123" } + }, + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "changedPin123" + ) + } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "abc123" + ) + } + ) + 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.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() + end +) + +test.register_coroutine_test( + "deleteCredential command received and commandResult is success", + function() + init_migration() + add_credential(0, "abc123") + add_credential(0, "test123") + add_credential(0, "321test") + + -- try to delete credential with wrong index and expect a failure + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { "4", "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() + + -- try to delete credential with correct index + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { "1", "pin" } + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + "" + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + { userIndex = 2, userType = "guest", userName = "Guest2" }, + { userIndex = 3, userType = "guest", userName = "Guest3" } + }, + { 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" }, + { userIndex = 3, credentialIndex = 3, 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() + end +) + +test.register_coroutine_test( + "deleteAllCredentials command received and commandResult is success", + function() + init_migration() + add_credential(0, "abc123") + add_credential(0, "test123") + add_credential(0, "321test") + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {} + }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) + }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + "" + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + { userIndex = 2, userType = "guest", userName = "Guest2" }, + { userIndex = 3, userType = "guest", userName = "Guest3" } + }, + { 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" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" } + }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua index 4e9c92bafe..7e1f9ed9c6 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua @@ -16,7 +16,18 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device)end -test.set_test_init_function(test_init) +local function test_init_new_capabilities() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(8, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(0, { 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.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(0, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) +end test.register_message_test( "Max user code number report should be handled", @@ -34,8 +45,32 @@ test.register_message_test( } }, { + test_init = test_init, min_api_version = 17 } ) +test.register_message_test( + "Max user code number report should be handled for migrated locks", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(30)) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(30)) + } + }, + { test_init = test_init_new_capabilities } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua new file mode 100644 index 0000000000..73030a792e --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua @@ -0,0 +1,553 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local PowerConfiguration = clusters.PowerConfiguration +local Alarm = clusters.Alarms + +local DoorLock = clusters.DoorLock +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode + + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints = { + [1] = { id = 1, manufacturer = "Yale", server_clusters = { 0x0001 } } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init_default() + test.mock_device.add_test_device(mock_device) + 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, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read( + mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) +end + +local function test_init_add_device() + test.mock_device.add_test_device(mock_device) + 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, { state_change = true, visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read( + mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) + + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( + mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported + :build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) +end + +test.set_test_init_function(test_init_default) + +local expect_reload_all_codes_messages = function() + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfTotalUsersSupported:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) +end + +test.register_coroutine_test( + "Configure should configure all necessary attributes and begin reading codes", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining + :configure_reporting(mock_device, + 600, + 21600, + 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + DoorLock.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, + 0, + 3600, + 0) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + Alarm.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, + 0, + 21600, + 0) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + test.mock_time.advance_time(2) + expect_reload_all_codes_messages() + end +) + +test.register_coroutine_test( + "Adding a credential should succeed and report users, credentials, and command result.", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { 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_init = function() + test_init_add_device() + end + } +) + +test.register_coroutine_test( + "Updating a credential should succeed and report users, credentials, and command result.", + function() + -- add credential first + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { 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 } } + ) + ) + ) + + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.wait_for_events() + + -- update the credential + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "1", "1", "pin", "changedPin123" } + }, + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "changedPin123" + ) + } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "abc123" + ) + } + ) + 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.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() + end, + { + test_init = function() + test_init_add_device() + end + } +) + +test.register_message_test( + "The lock reporting a single code has been set and then deleted should be handled", + { + -- add credential + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockUsers.users({ { userIndex = 1, userName = "Guest1", userType = "guest" } }, + { state_change = true, visibility = { displayed = true } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { state_change = true, visibility = { displayed = true } })) + }, + + -- delete the credential + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, + { state_change = true, visibility = { displayed = true } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, + { state_change = true, visibility = { displayed = true } })) + } + }, + { test_init = test_init_add_device } +) + +test.register_message_test( + "The lock reporting master code changed", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.MASTER_CODE_CHANGED + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult({ commandName = "updateCredential", statusCode = "success" }, + { state_change = true, visibility = { displayed = true } })) + } + } +) + +test.register_message_test( + "The lock reporting all codes have been deleted should be handled", + { + -- add a credential + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockUsers.users({ { userIndex = 1, userName = "Guest1", userType = "guest" } }, + { state_change = true, visibility = { displayed = true } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { state_change = true, visibility = { displayed = true } })) + }, + + -- delete all credentials + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 0xFFFF + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, + { state_change = true, visibility = { displayed = true } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, + { state_change = true, visibility = { displayed = true } })) + } + }, + { test_init = test_init_add_device } +) + +test.register_coroutine_test( + "Out of band get pin call should add credential if it doesn't exist (happens during reload all codes).", + function() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { state_change = true, visibility = { displayed = true } }) + ) + ) + end, + { + test_init = function() + test_init_add_device() + end + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua new file mode 100644 index 0000000000..5618b59b30 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local capabilities = require "st.capabilities" + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if lock_codes_migrated then + local subdriver = require("using-new-capabilities") + return true, subdriver + end + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua new file mode 100644 index 0000000000..4b3c6a4096 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua @@ -0,0 +1,570 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Zigbee Driver utilities +local device_management = require "st.zigbee.device_management" +local log = require "log" +local utils = require "st.utils" + + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +-- Capabilities +local capabilities = require "st.capabilities" +local Battery = capabilities.battery +local Lock = capabilities.lock +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode + +local socket = require "cosock.socket" +local lock_utils = require "new_lock_utils" + +local DELAY_LOCK_EVENT = "_delay_lock_event" +local MAX_DELAY = 10 + +local reload_all_codes = function(device) + -- starts at first user code index then iterates through all lock codes as they come in + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + end + if (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfTotalUsersSupported:read(device)) + end + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then + device:set_field(lock_utils.CHECKING_CODE, 1) + end + + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) +end + +local init = function(driver, device) + lock_utils.reload_tables(device) + device.thread:call_with_delay(15, function(d) + reload_all_codes(device) + end) +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + device.thread:call_with_delay(2, function(d) + reload_all_codes(device) + end) +end + +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, { persist = true }) + 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 + +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 + 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 + +local add_credential_handler = function(driver, device, command) + 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 + -- set the pin code and then validate it was successful when the GetPINCode response is received. + -- the credential creation and events will also be handled in that response. + device:set_field(lock_utils.ACTIVE_CREDENTIAL, + { userIndex = user_index, userType = user_type, credentialType = credential_type, credentialIndex = credential_index }) + device:send(LockCluster.server.commands.SetPINCode(device, + credential_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + credential_data) + ) + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + else + lock_utils.clear_busy_state(device, status) + end +end + +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(LockCluster.server.commands.SetPINCode(device, + credential_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + credential_data) + ) + device.thread:call_with_delay(4, function() + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + 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(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + 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.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) + end) + device.thread:call_with_delay(delay + 2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + delay = delay + 2 + end + + device.thread:call_with_delay(delay + 4, function() + lock_utils.clear_busy_state(device, status) + end) +end + +local max_code_length_handler = function(driver, device, value) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +local min_code_length_handler = function(driver, device, value) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +local max_codes_handler = function(driver, device, value) + device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) +end + +local get_pin_response_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + local command = device:get_field(lock_utils.COMMAND_NAME) + local status = lock_utils.STATUS_SUCCESS + local emit_event = false + + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) 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 + -- Called by reloading the codes. 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 zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then + -- tried to add a code that already is in use. + -- remove the created user if one got made. There is no associated credential. + status = lock_utils.STATUS_DUPLICATE + lock_utils.delete_user(device, active_credential.userIndex) + else + if lock_utils.get_credential(device, credential_index) ~= nil then + -- Credential has been deleted. + lock_utils.delete_credential(device, credential_index) + emit_event = true + end + end + + if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then + -- the credential we're checking has arrived + local last_slot = device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME) + if (credential_index >= last_slot) then + device:set_field(lock_utils.CHECKING_CODE, nil) + emit_event = true + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end + + if emit_event then + lock_utils.send_events(device) + end + -- ignore handling the busy state for these commands, they are handled within their own handlers + if command ~= nil and command ~= lock_utils.DELETE_ALL_CREDENTIALS and command ~= lock_utils.DELETE_ALL_USERS then + lock_utils.clear_busy_state(device, status) + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + local command = device:get_field(lock_utils.COMMAND_NAME) + local emit_events = false + + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code updated + device:emit_event(capabilities.lockCredentials.commandResult( + {commandName = lock_utils.UPDATE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS}, + { state_change = true, visibility = { displayed = true } } + )) + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (zb_mess.body.zcl_body.user_id.value == 0xFF) then + -- All credentials deleted + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) + emit_events = true + end + else + -- One credential deleted + if (lock_utils.get_credential(device, credential_index) ~= nil) then + lock_utils.delete_credential(device, credential_index) + emit_events = true + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) 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 + end + end + + if emit_events then + lock_utils.send_events(device) + end +end + +-- REMOVE THIS AFTER DONE WITH TESTING +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 lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local user = lock_utils.get_user(device, code_id) + if user ~= nil then + code_name = user.userName + end + + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + 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( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + + +local new_capabilities_driver = { + NAME = "Lock Driver Using New Capabilities", + supported_capabilities = { + Lock, + LockCredentials, + LockUsers, + Battery, + }, + zigbee_handlers = { + cluster = { + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler, + } + }, + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.MaxPINCodeLength.ID] = max_code_length_handler, + [LockCluster.attributes.MinPINCodeLength.ID] = min_code_length_handler, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = max_codes_handler, + } + } + }, + capability_handlers = { + [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] = { -- REMOVE THIS WHEN DONE WITH TESTING + [capabilities.lockCodes.commands.migrate.NAME] = migrate, + }, + }, + sub_drivers = { + require("using-new-capabilities.sub_drivers") + }, + health_check = false, + lifecycle_handlers = { + init = init, + doConfigure = do_configure + }, + can_handle = require("using-new-capabilities.can_handle") +} + +return new_capabilities_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua new file mode 100644 index 0000000000..21984e938b --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local LOCK_WITHOUT_CODES_FINGERPRINTS = { + { model = "E261-KR0B0Z0-HA" }, + { mfr = "Danalock", model = "V3-BTZB" } +} + +return function(opts, driver, device, cmd) + for _, fingerprint in ipairs(LOCK_WITHOUT_CODES_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("using-new-capabilities.lock-without-codes") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua new file mode 100644 index 0000000000..8363377bf9 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua @@ -0,0 +1,87 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local configurationMap = require "configurations" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local DoorLock = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +local function device_init(driver, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end + end +end + +local function handle_lock(driver, device, cmd) + device:send(DoorLock.commands.LockDoor(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) +end + +local function handle_unlock(driver, device, cmd) + device:send(DoorLock.commands.UnlockDoor(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) +end + +local function do_refresh(driver, device) + device:refresh() +end + +local function do_configure(driver, device) + device:configure() +end + +local function handle_lock_door(driver, device, zb_rx) + local function query_device() + device:send(DoorLock.attributes.LockState:read(device)) + end + device.thread:call_with_delay(5, query_device) +end + +local lock_without_codes = { + NAME = "Zigbee Lock Without Codes", + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure + }, + capability_handlers = { + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = handle_lock, + [capabilities.lock.commands.unlock.NAME] = handle_unlock + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zigbee_handlers = { + cluster = { + [DoorLock.ID] = { + [DoorLock.commands.LockDoorResponse.ID] = handle_lock_door, + [DoorLock.commands.UnlockDoorResponse.ID] = handle_lock_door, + } + }, + attr = { + [DoorLock.ID] = { + [DoorLock.attributes.NumberOfPINUsersSupported.ID] = function() end -- just to make sure we don't switch profiles + } + } + }, + can_handle = require("using-new-capabilities.lock-without-codes.can_handle") +} + +return lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/can_handle.lua similarity index 55% rename from drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/can_handle.lua index c483b2fe27..f9ff67fcf5 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/can_handle.lua @@ -1,11 +1,10 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local function samsungsds_can_handle(opts, driver, device, ...) +return function(opts, driver, device) if device:get_manufacturer() == "SAMSUNG SDS" then - return true, require("samsungsds") + local subdriver = require("using-new-capabilities.samsungsds") + return true, subdriver end return false end - -return samsungsds_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua new file mode 100644 index 0000000000..ed61935e4b --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua @@ -0,0 +1,118 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local device_management = require "st.zigbee.device_management" +local clusters = require "st.zigbee.zcl.clusters" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local PowerConfiguration = clusters.PowerConfiguration +local DoorLock = clusters.DoorLock +local Lock = capabilities.lock +local lock_utils = require "new_lock_utils" + +local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F +local SAMSUNG_SDS_MFR_CODE = 0x0003 + +local function handle_lock_state(driver, device, value, zb_rx) + if value.value == DoorLock.attributes.LockState.LOCKED then + device:emit_event(Lock.lock.locked()) + elseif value.value == DoorLock.attributes.LockState.UNLOCKED then + device:emit_event(Lock.lock.unlocked()) + end +end + +local function mfg_lock_door_handler(driver, device, zb_rx) + local cmd = zb_rx.body.zcl_body.body_bytes:byte(1) + if cmd == 0x00 then + device:emit_event(Lock.lock.unlocked()) + end +end + +local function unlock_cmd_handler(driver, device, command) + device:send(cluster_base.build_manufacturer_specific_command( + device, + DoorLock.ID, + SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND, + SAMSUNG_SDS_MFR_CODE, + "\x10\x04\x31\x32\x33\x35")) +end + +local function lock_cmd_handler(driver, device, command) + -- do nothing in lock command handler +end + +local refresh = function(driver, device, cmd) + -- do nothing in refresh capability handler +end + +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +local device_added = function(self, device) + lock_utils.reload_tables(device) + emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) + device:emit_event(capabilities.battery.battery(100)) +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, DoorLock.ID, self.environment_info.hub_zigbee_eui)) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0)) +end + +local battery_init = battery_defaults.build_linear_voltage_init(4.0, 6.0) + +local device_init = function(driver, device, event) + battery_init(driver, device, event) + device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) + device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) + lock_utils.reload_tables(device) +end + +local samsung_sds_driver = { + NAME = "SAMSUNG SDS Lock Driver", + zigbee_handlers = { + cluster = { + [DoorLock.ID] = { + [SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND] = mfg_lock_door_handler + } + }, + attr = { + [DoorLock.ID] = { + [DoorLock.attributes.LockState.ID] = handle_lock_state + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh + }, + [capabilities.lock.ID] = { + [capabilities.lock.commands.unlock.NAME] = unlock_cmd_handler, + [capabilities.lock.commands.lock.NAME] = lock_cmd_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure, + added = device_added, + init = device_init + }, + can_handle = require("using-new-capabilities.samsungsds.can_handle") +} + +return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua new file mode 100644 index 0000000000..8167f51d98 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("using-new-capabilities.samsungsds"), + lazy_load_if_possible("using-new-capabilities.yale-fingerprint-lock"), + lazy_load_if_possible("using-new-capabilities.yale"), + lazy_load_if_possible("using-new-capabilities.lock-without-codes") +} +return sub_drivers \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua new file mode 100644 index 0000000000..6f206ed9b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local YALE_FINGERPRINT_LOCK = { + { mfr = "ASSA ABLOY iRevo", model = "iZBModule01" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "0700000001" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +return function(opts, driver, device) + for _, fingerprint in ipairs(YALE_FINGERPRINT_LOCK) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("using-new-capabilities.yale-fingerprint-lock") + return true, subdriver + end + end + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua new file mode 100644 index 0000000000..d0ab156b65 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua @@ -0,0 +1,40 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local LockCluster = clusters.DoorLock +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers + +local YALE_FINGERPRINT_MAX_CODES = 0x1E + +local handle_max_codes = function(driver, device, value) + device:emit_event(LockCredentials.pinUsersSupported(YALE_FINGERPRINT_MAX_CODES)) + device:emit_event(LockUsers.totalUsersSupported(YALE_FINGERPRINT_MAX_CODES)) +end + +local yale_fingerprint_lock_driver = { + NAME = "YALE Fingerprint Lock", + zigbee_handlers = { + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes + } + } + }, + can_handle = require("using-new-capabilities.yale-fingerprint-lock.can_handle") +} + +return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/yale/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/can_handle.lua similarity index 62% rename from drivers/SmartThings/zigbee-lock/src/yale/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/can_handle.lua index 54340c7811..ecefc56b4c 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/can_handle.lua @@ -1,11 +1,10 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local function yale_can_handle(opts, driver, device, ...) +return function(opts, driver, device) if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then - return true, require("yale") + local subdriver = require("using-new-capabilities.yale") + return true, subdriver end return false end - -return yale_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua new file mode 100644 index 0000000000..2e88e950f1 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua @@ -0,0 +1,171 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local LockCluster = clusters.DoorLock + +-- Capabilities +local capabilities = require "st.capabilities" + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode + +local SHIFT_INDEX_CHECK = 256 +local YALE_MAX_USERS_OVERRIDE = 10 -- yale supports 250 codes... we're not going to iterate through all that. + +local lock_utils = (require "new_lock_utils") + +local get_pin_response_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + local command = device:get_field(lock_utils.COMMAND_NAME) + local status = lock_utils.STATUS_SUCCESS + local emit_event = false + + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) 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 + -- Called by reloading the codes. 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 zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then + -- tried to add a code that already is in use. + -- remove the created user if one got made. There is no associated credential. + status = lock_utils.STATUS_DUPLICATE + lock_utils.delete_user(device, active_credential.userIndex) + else + if lock_utils.get_credential(device, credential_index) ~= nil then + -- Credential has been deleted. + lock_utils.delete_credential(device, credential_index) + emit_event = true + end + end + + if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then + -- the credential we're checking has arrived + local last_slot = YALE_MAX_USERS_OVERRIDE + if (credential_index >= last_slot) then + device:set_field(lock_utils.CHECKING_CODE, nil) + emit_event = true + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end + + if emit_event then + lock_utils.send_events(device) + end + -- ignore handling the busy state for these commands, they are handled within their own handlers + if command ~= nil and command ~= lock_utils.DELETE_ALL_CREDENTIALS and command ~= lock_utils.DELETE_ALL_USERS then + lock_utils.clear_busy_state(device, status) + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + local command = device:get_field(lock_utils.COMMAND_NAME) + local emit_events = false + + if credential_index >= SHIFT_INDEX_CHECK then + -- Index is wonky, shift it to get proper value + credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) >> 8 + end + + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code updated + device:emit_event(capabilities.lockCredentials.commandResult( + {commandName = lock_utils.UPDATE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS}, + { state_change = true, visibility = { displayed = true } } + )) + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (zb_mess.body.zcl_body.user_id.value == 0xFFFF) then + -- All credentials deleted + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) + emit_events = true + end + else + -- One credential deleted + if (lock_utils.get_credential(device, credential_index) ~= nil) then + lock_utils.delete_credential(device, credential_index) + emit_events = true + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) 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 + end + end + + if emit_events then + lock_utils.send_events(device) + end +end + +local yale_door_lock_driver = { + NAME = "Yale Door Lock", + zigbee_handlers = { + cluster = { + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + } + } + }, + + sub_drivers = { require("using-new-capabilities.yale.sub_drivers") }, + can_handle = require("using-new-capabilities.yale.can_handle") +} + +return yale_door_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua new file mode 100644 index 0000000000..19d92e6f3e --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua @@ -0,0 +1,8 @@ +-- 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("using-new-capabilities.yale.yale-bad-battery-reporter"), +} +return sub_drivers \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua new file mode 100644 index 0000000000..39fffc666c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local BAD_YALE_LOCK_FINGERPRINTS = { + { mfr = "Yale", model = "YRD220/240 TSDB" }, + { mfr = "Yale", model = "YRL220 TS LL" }, + { mfr = "Yale", model = "YRD210 PB DB" }, + { mfr = "Yale", model = "YRL210 PB LL" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +return function(opts, driver, device) + for _, fingerprint in ipairs(BAD_YALE_LOCK_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("using-new-capabilities.yale.yale-bad-battery-reporter") + return true, subdriver + end + end + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua new file mode 100644 index 0000000000..0a1bcaceb3 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua @@ -0,0 +1,34 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local battery_report_handler = function(driver, device, value) + device:emit_event(capabilities.battery.battery(value.value)) +end + +local bad_yale_driver = { + NAME = "YALE BAD Lock Driver", + zigbee_handlers = { + attr = { + [clusters.PowerConfiguration.ID] = { + [clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_report_handler + } + } + }, + can_handle = require("using-new-capabilities.yale.yale-bad-battery-reporter.can_handle") +} + +return bad_yale_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/can_handle.lua new file mode 100644 index 0000000000..1fe9815bb9 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local capabilities = require "st.capabilities" + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if not lock_codes_migrated then + local subdriver = require("using-old-capabilities") + return true, subdriver + end + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua new file mode 100644 index 0000000000..f8fcae368f --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua @@ -0,0 +1,413 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Zigbee Driver utilities +local device_management = require "st.zigbee.device_management" + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +-- Capabilities +local capabilities = require "st.capabilities" +local Battery = capabilities.battery +local Lock = capabilities.lock +local LockCodes = capabilities.lockCodes +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode + +local socket = require "cosock.socket" +local lock_utils = require "lock_utils" + +local DELAY_LOCK_EVENT = "_delay_lock_event" +local MAX_DELAY = 10 + +local reload_all_codes = function(driver, device, command) + -- starts at first user code index then iterates through all lock codes as they come in + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + end + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end + device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) +end + +local get_pin_response_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = { codeName = lock_utils.get_code_name(device, code_slot) } + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then + -- Code slot is occupied + event.value = code_slot .. lock_utils.get_change_type(device, code_slot) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + else + -- Code slot is unoccupied + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + -- Code has been deleted + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + else + -- Code is unset + event.value = code_slot .. " unset" + device:emit_event(event) + end + end + + code_slot = tonumber(code_slot) + if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then + -- the code we're checking has arrived + local last_slot = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - 1 + if (code_slot >= last_slot) then + device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) + device:set_field(lock_utils.CHECKING_CODE, nil) + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = {} + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code changed + event.value = "0 set" + event.data = { codeName = "Master Code" } + device:emit_event(event) + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (zb_mess.body.zcl_body.user_id.value == 0xFF) then + -- All codes deleted + for cs, _ in pairs(lock_utils.get_lock_codes(device)) do + lock_utils.code_deleted(device, cs) + end + lock_utils.lock_codes_event(device, {}) + else + -- One code deleted + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then + -- Code added or changed + local change_type = lock_utils.get_change_type(device, code_slot) + local code_name = lock_utils.get_code_name(device, code_slot) + event.value = code_slot .. change_type + event.data = { codeName = code_name } + device:emit_event(event) + if (change_type == " set") then + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + end + end +end + +local handle_max_codes = function(driver, device, value) + if value.value ~= 0 then + -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, + -- but it gave us a non-zero number of pin users, so we want to switch the profile + if not device:supports_capability_by_id(LockCodes.ID) then + device:try_update_metadata({ profile = "base-lock" }) -- switch to a lock with codes + lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes + if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes + -- so we'll load them manually + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end + end + device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) + end +end + +local handle_max_code_length = function(driver, device, value) + device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) +end + +local handle_min_code_length = function(driver, device, value) + device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) +end + +local lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local lock_codes = device:get_field("lockCodes") + if (lock_codes ~= nil and + lock_codes[code_id] ~= nil) then + code_name = lock_codes[code_id] + end + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + 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( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + +local update_codes = function(driver, device, command) + local delay = 0 + -- args.codes is json + for name, code in pairs(command.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 + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.SetPINCode(device, + code_slot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + code)) + end) + delay = delay + 2 + else + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) + end) + delay = delay + 2 + end + device.thread:call_with_delay(delay, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) + end) + delay = delay + 2 + end + end +end + +local delete_code = function(driver, device, command) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) +end + +local request_code = function(driver, device, command) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) +end + +local set_code = function(driver, device, command) + if (command.args.codePIN == "") then + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = { command.args.codeSlot, command.args.codeName } + }) + else + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.codeSlot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.codePIN) + ) + if (command.args.codeName ~= nil) then + -- wait for confirmation from the lock to commit this to memory + -- Groovy driver has a lot more info passed here as a description string, may need to be investigated + local codeState = device:get_field(lock_utils.CODE_STATE) or {} + codeState["setName" .. command.args.codeSlot] = command.args.codeName + device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) + end + + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) + end +end + +local name_slot = function(driver, device, command) + local code_slot = tostring(command.args.codeSlot) + local lock_codes = lock_utils.get_lock_codes(device) + if (lock_codes[code_slot] ~= nil) then + lock_codes[code_slot] = command.args.codeName + device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) + lock_utils.lock_codes_event(device, lock_codes) + end +end + +local migrate = function(driver, device, command) + local lock_users = {} + local lock_credentials = {} + local lock_codes = lock_utils.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, code_slot in ipairs(ordered_codes) do + table.insert(lock_users, { userIndex = index, userType = "guest", userName = lock_codes[code_slot] }) + 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, 8) + local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME, 0) + if (code_length ~= nil) then + max_code_len = code_length + min_code_len = code_length + end + + device:emit_event(LockCredentials.minPinCodeLen(min_code_len, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.maxPinCodeLen(max_code_len, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + device:emit_event(LockUsers.users(lock_users, { visibility = { displayed = false } })) + device:emit_event(LockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + -- Don't send a reload all codes if this is a part of migration + if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then + device.thread:call_with_delay(2, function(d) + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end) + else + device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) + end +end + +local old_capabilities_driver = { + NAME = "Lock Driver Using Old Capabilities", + supported_capabilities = { + Lock, + LockCodes, + Battery, + }, + zigbee_handlers = { + cluster = { + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + } + }, + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, + [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes, + } + } + }, + capability_handlers = { + [LockCodes.ID] = { + [LockCodes.commands.updateCodes.NAME] = update_codes, + [LockCodes.commands.deleteCode.NAME] = delete_code, + [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [LockCodes.commands.requestCode.NAME] = request_code, + [LockCodes.commands.setCode.NAME] = set_code, + [LockCodes.commands.nameSlot.NAME] = name_slot, + [LockCodes.commands.migrate.NAME] = migrate, + }, + }, + sub_drivers = { + require("using-old-capabilities.sub_drivers") + }, + health_check = false, + lifecycle_handlers = { + doConfigure = do_configure + }, + can_handle = require("using-old-capabilities.can_handle") +} + +return old_capabilities_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/can_handle.lua new file mode 100644 index 0000000000..0eac7666c6 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local fingerprints = require("using-old-capabilities.lock-without-codes.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("using-old-capabilities.lock-without-codes") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/lock-without-codes/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua similarity index 96% rename from drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua index e5c6de3408..743a2cc6f6 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua @@ -8,9 +8,6 @@ local capabilities = require "st.capabilities" local DoorLock = clusters.DoorLock local PowerConfiguration = clusters.PowerConfiguration - - - local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then @@ -73,7 +70,7 @@ local lock_without_codes = { } } }, - can_handle = require("lock-without-codes.can_handle"), + can_handle = require("using-old-capabilities.lock-without-codes.can_handle") } return lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua new file mode 100644 index 0000000000..c2419a7b01 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + if device:get_manufacturer() == "SAMSUNG SDS" then + local subdriver = require("using-old-capabilities.samsungsds") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua similarity index 98% rename from drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua index fff290df5d..51418f6e03 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua @@ -102,7 +102,7 @@ local samsung_sds_driver = { added = device_added, init = device_init }, - can_handle = require("samsungsds.can_handle"), + can_handle = require("using-old-capabilities.samsungsds.can_handle") } return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua new file mode 100644 index 0000000000..1f6149587c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("using-old-capabilities.samsungsds"), + lazy_load_if_possible("using-old-capabilities.yale"), + lazy_load_if_possible("using-old-capabilities.yale-fingerprint-lock"), + lazy_load_if_possible("using-old-capabilities.lock-without-codes") +} +return sub_drivers \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/can_handle.lua new file mode 100644 index 0000000000..a281fc16da --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local fingerprints = require("using-old-capabilities.yale-fingerprint-lock.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("using-old-capabilities.yale-fingerprint-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua similarity index 90% rename from drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua index b78d043784..12619c01d5 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua @@ -8,9 +8,6 @@ local LockCluster = clusters.DoorLock local LockCodes = capabilities.lockCodes local YALE_FINGERPRINT_MAX_CODES = 0x1E - - - local handle_max_codes = function(driver, device, value) device:emit_event(LockCodes.maxCodes(YALE_FINGERPRINT_MAX_CODES), { visibility = { displayed = false } }) end @@ -24,7 +21,7 @@ local yale_fingerprint_lock_driver = { } } }, - can_handle = require("yale-fingerprint-lock.can_handle"), + can_handle = require("using-old-capabilities.yale-fingerprint-lock.can_handle") } return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua new file mode 100644 index 0000000000..25a4a6caee --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then + local subdriver = require("using-old-capabilities.yale") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua similarity index 97% rename from drivers/SmartThings/zigbee-lock/src/yale/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua index c315fbfa06..4d6b8f8431 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua @@ -142,8 +142,8 @@ local yale_door_lock_driver = { [LockCodes.commands.setCode.NAME] = set_code } }, - sub_drivers = require("yale.sub_drivers"), - can_handle = require("yale.can_handle"), + sub_drivers = require("using-old-capabilities.yale.sub_drivers"), + can_handle = require("using-old-capabilities.yale.can_handle") } return yale_door_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/yale/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/sub_drivers.lua similarity index 69% rename from drivers/SmartThings/zigbee-lock/src/yale/sub_drivers.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/sub_drivers.lua index 4b546979d3..d31cfb314a 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/sub_drivers.lua @@ -3,6 +3,6 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { - lazy_load_if_possible("yale.yale-bad-battery-reporter"), + lazy_load_if_possible("using-old-capabilities.yale.yale-bad-battery-reporter"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/can_handle.lua new file mode 100644 index 0000000000..8c3106156a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local fingerprints = require("using-old-capabilities.yale.yale-bad-battery-reporter.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("using-old-capabilities.yale.yale-bad-battery-reporter") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua similarity index 86% rename from drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua index 3b77f32563..358dcbd7b1 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua @@ -4,9 +4,6 @@ local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" - - - local battery_report_handler = function(driver, device, value) device:emit_event(capabilities.battery.battery(value.value)) end @@ -20,7 +17,7 @@ local bad_yale_driver = { } } }, - can_handle = require("yale.yale-bad-battery-reporter.can_handle"), + can_handle = require("using-old-capabilities.yale.yale-bad-battery-reporter.can_handle") } return bad_yale_driver diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua deleted file mode 100644 index a80632bf80..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua +++ /dev/null @@ -1,14 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local yale_fingerprint_lock_models = function(opts, driver, device) - local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("yale-fingerprint-lock") - end - end - return false -end - -return yale_fingerprint_lock_models diff --git a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/can_handle.lua deleted file mode 100644 index 67169e9268..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/can_handle.lua +++ /dev/null @@ -1,14 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local is_bad_yale_lock_models = function(opts, driver, device) - local FINGERPRINTS = require("yale.yale-bad-battery-reporter.fingerprints") - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("yale.yale-bad-battery-reporter") - end - end - return false -end - -return is_bad_yale_lock_models From 2f1d62f12f52dcb2beff5958203217fa62683c35 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 1 May 2026 13:35:20 -0500 Subject: [PATCH 02/33] Restructure zigbee-lock First, I had AI do the following: - Rename subdrivers: using-old-capabilities -> legacy-handlers, promote using-new-capabilities handlers to top-level defaults - Make new capability handling the driver default; legacy-handlers subdriver overrides for pre-migration devices only - Top-level vendor subdrivers (yale, samsungsds, yale-fingerprint-lock) now only activate for migrated devices to avoid double-dispatch - Remove duplicate lock-without-codes top-level subdriver (handled entirely within legacy-handlers since these devices never migrate) - Add init lifecycle handler to legacy-handlers to populate lockCodes state for pre-migration devices on driver restart Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Then, I cleaned up the driver further by fixing the git understanding of what files were made in what year, and by introducing small changes to altogether remove some sub-subdrivers from the legacy subdriver, and vice versa. --- .../can_handle.lua | 6 +- .../fingerprints.lua | 4 +- .../init.lua | 11 +- drivers/SmartThings/zigbee-lock/src/init.lua | 553 +++++++++++++++++- .../can_handle.lua | 6 +- .../init.lua | 60 +- .../legacy_lock_utils.lua} | 51 +- .../lock-without-codes/can_handle.lua | 6 +- .../lock-without-codes/fingerprints.lua | 0 .../lock-without-codes/init.lua | 5 +- .../src/legacy-handlers/sub_drivers.lua | 10 + .../yale-fingerprint-lock/can_handle.lua | 6 +- .../yale-fingerprint-lock/init.lua | 4 +- .../yale/can_handle.lua | 7 +- .../yale/init.lua | 5 +- .../samsungsds/can_handle.lua | 7 +- .../samsungsds/init.lua | 18 +- .../zigbee-lock/src/sub_drivers.lua | 6 +- .../test_zigbee_lock_code_slga_migration.lua | 15 +- .../test_zigbee_lock_new_capabilities.lua | 15 +- .../test_zigbee_yale-new_capabilities.lua | 15 +- .../src/using-new-capabilities/can_handle.lua | 13 - .../lock-without-codes/can_handle.lua | 17 - .../lock-without-codes/init.lua | 87 --- .../samsungsds/init.lua | 118 ---- .../using-new-capabilities/sub_drivers.lua | 11 - .../yale-fingerprint-lock/can_handle.lua | 19 - .../src/using-new-capabilities/yale/init.lua | 171 ------ .../yale/sub_drivers.lua | 8 - .../yale-bad-battery-reporter/can_handle.lua | 21 - .../yale/yale-bad-battery-reporter/init.lua | 34 -- .../samsungsds/can_handle.lua | 10 - .../using-old-capabilities/sub_drivers.lua | 11 - .../yale/can_handle.lua | 10 - .../yale/sub_drivers.lua | 8 - .../src/yale-fingerprint-lock/can_handle.lua | 18 + .../yale-fingerprint-lock/fingerprints.lua | 0 .../yale-fingerprint-lock/init.lua | 20 +- ...w_lock_utils.lua => zigbee_lock_utils.lua} | 18 +- 39 files changed, 665 insertions(+), 739 deletions(-) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities/yale/yale-bad-battery-reporter => bad-battery-reporter}/can_handle.lua (57%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities/yale/yale-bad-battery-reporter => bad-battery-reporter}/fingerprints.lua (79%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities/yale/yale-bad-battery-reporter => bad-battery-reporter}/init.lua (74%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/can_handle.lua (79%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/init.lua (87%) rename drivers/SmartThings/zigbee-lock/src/{lock_utils.lua => legacy-handlers/legacy_lock_utils.lua} (50%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/lock-without-codes/can_handle.lua (60%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/lock-without-codes/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/lock-without-codes/init.lua (96%) create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/yale-fingerprint-lock/can_handle.lua (59%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/yale-fingerprint-lock/init.lua (85%) rename drivers/SmartThings/zigbee-lock/src/{using-new-capabilities => legacy-handlers}/yale/can_handle.lua (61%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => legacy-handlers}/yale/init.lua (97%) rename drivers/SmartThings/zigbee-lock/src/{using-new-capabilities => }/samsungsds/can_handle.lua (55%) rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => }/samsungsds/init.lua (86%) delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua rename drivers/SmartThings/zigbee-lock/src/{using-old-capabilities => }/yale-fingerprint-lock/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-lock/src/{using-new-capabilities => }/yale-fingerprint-lock/init.lua (53%) rename drivers/SmartThings/zigbee-lock/src/{new_lock_utils.lua => zigbee_lock_utils.lua} (94%) diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua similarity index 57% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua index 8c3106156a..7d7d566118 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua @@ -1,11 +1,11 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device) - local fingerprints = require("using-old-capabilities.yale.yale-bad-battery-reporter.fingerprints") + local fingerprints = require("bad-battery-reporter.fingerprints") for _, fingerprint in ipairs(fingerprints) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("using-old-capabilities.yale.yale-bad-battery-reporter") + local subdriver = require("bad-battery-reporter") return true, subdriver end end diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua similarity index 79% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua index cbb7c3404f..3024aeeace 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/fingerprints.lua +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua @@ -1,7 +1,7 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local BAD_YALE_LOCK_FINGERPRINTS = { +local BAD_BATTERY_REPORTING_LOCK_FINGERPRINTS = { { mfr = "Yale", model = "YRD220/240 TSDB" }, { mfr = "Yale", model = "YRL220 TS LL" }, { mfr = "Yale", model = "YRD210 PB DB" }, @@ -10,4 +10,4 @@ local BAD_YALE_LOCK_FINGERPRINTS = { { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } } -return BAD_YALE_LOCK_FINGERPRINTS +return BAD_BATTERY_REPORTING_LOCK_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua similarity index 74% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua rename to drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua index 358dcbd7b1..5fbdabd1b7 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua @@ -4,12 +4,15 @@ local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" + + + local battery_report_handler = function(driver, device, value) device:emit_event(capabilities.battery.battery(value.value)) end -local bad_yale_driver = { - NAME = "YALE BAD Lock Driver", +local bad_battery_reporter_driver = { + NAME = "Bad Battery Reporter Driver", zigbee_handlers = { attr = { [clusters.PowerConfiguration.ID] = { @@ -17,7 +20,7 @@ local bad_yale_driver = { } } }, - can_handle = require("using-old-capabilities.yale.yale-bad-battery-reporter.can_handle") + can_handle = require("bad-battery-reporter.can_handle") } -return bad_yale_driver +return bad_battery_reporter_driver diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 3b72804fac..1c4c74a29e 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -4,26 +4,36 @@ -- Zigbee Driver utilities local defaults = require "st.zigbee.defaults" +local device_management = require "st.zigbee.device_management" local ZigbeeDriver = require "st.zigbee" -- Zigbee Spec Utils local clusters = require "st.zigbee.zcl.clusters" local Alarm = clusters.Alarms local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration -- Capabilities local capabilities = require "st.capabilities" local Battery = capabilities.battery local Lock = capabilities.lock local LockCodes = capabilities.lockCodes +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode local socket = require "cosock.socket" -local lock_utils = require "lock_utils" -local new_lock_utils = require "new_lock_utils" +local legacy_lock_utils = require "legacy-handlers.legacy_lock_utils" +local lock_utils = require "zigbee_lock_utils" local DELAY_LOCK_EVENT = "_delay_lock_event" local MAX_DELAY = 10 + local refresh = function(driver, device, cmd) device:refresh() device:send(LockCluster.attributes.LockState:read(device)) @@ -31,10 +41,10 @@ local refresh = function(driver, device, cmd) -- we can't determine from fingerprints if devices support lock codes, so -- here in the driver we'll do a check once to see if the device responds here -- and if it does, we'll switch it to a profile with lock codes - if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(lock_utils.CHECKED_CODE_SUPPORT) then + if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(legacy_lock_utils.CHECKED_CODE_SUPPORT) then device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) -- we won't make this value persist because it's not that important - device:set_field(lock_utils.CHECKED_CODE_SUPPORT, true) + device:set_field(legacy_lock_utils.CHECKED_CODE_SUPPORT, true) end end @@ -49,20 +59,19 @@ local alarm_handler = function(driver, device, zb_mess) end end --- this command should now trigger setting the migrated field and reinjecting the command. --- this is so we can start using the new capbilities from now on. +-- This triggers setting the migrated field so new devices use new capabilities from the start. local function device_added(driver, device) -- this variable should only be present for test cases trying to test the old capabilities. if device.useOldCapabilityForTesting == nil then if device:supports_capability_by_id(LockCodes.ID) then device:emit_event(LockCodes.migrated(true, { state_change = true, visibility = { displayed = true } })) device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) - new_lock_utils.reload_tables(device) + lock_utils.reload_tables(device) else - lock_utils.populate_state_from_data(device) + legacy_lock_utils.populate_state_from_data(device) end else - lock_utils.populate_state_from_data(device) + legacy_lock_utils.populate_state_from_data(device) end driver:inject_capability_command(device, { @@ -72,12 +81,6 @@ local function device_added(driver, device) }) end -local function init(driver, device) - lock_utils.populate_state_from_data(device) - -- temp fix before this can be changed to non-persistent - device:set_field(lock_utils.CODE_STATE, nil, { persist = true }) -end - -- The following two functions are from the lock defaults. They are in the base driver temporarily -- until the fix is widely released in the lua libs local lock_state_handler = function(driver, device, value, zb_rx) @@ -102,18 +105,501 @@ local lock_state_handler = function(driver, device, value, zb_rx) end end -local function lock(driver, device, command) +local function handle_lock(driver, device, command) device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) end -local function unlock(driver, device, command) +local function handle_unlock(driver, device, command) device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) end +-- Default (post-migration) handlers -- + +local reload_all_codes = function(device) + -- starts at first user code index then iterates through all lock codes as they come in + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + end + if (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfTotalUsersSupported:read(device)) + end + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then + device:set_field(lock_utils.CHECKING_CODE, 1) + end + + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) +end + +local init = function(driver, device) + lock_utils.reload_tables(device) + device.thread:call_with_delay(15, function(d) + reload_all_codes(device) + end) +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME, false) + if lock_codes_migrated then + device.thread:call_with_delay(2, function(d) reload_all_codes(device) end) + else + -- Don't send a reload all codes if this is a part of migration + if device.data.lockCodes == nil or device:get_field(legacy_lock_utils.MIGRATION_RELOAD_SKIPPED) == true then + device.thread:call_with_delay(2, function(d) + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end) + else + device:set_field(legacy_lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) + end + end +end + +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, { persist = true }) + 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 + +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 + 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 + +local add_credential_handler = function(driver, device, command) + 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 + -- set the pin code and then validate it was successful when the GetPINCode response is received. + -- the credential creation and events will also be handled in that response. + device:set_field(lock_utils.ACTIVE_CREDENTIAL, + { userIndex = user_index, userType = user_type, credentialType = credential_type, credentialIndex = credential_index }) + device:send(LockCluster.server.commands.SetPINCode(device, + credential_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + credential_data) + ) + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + else + lock_utils.clear_busy_state(device, status) + end +end + +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(LockCluster.server.commands.SetPINCode(device, + credential_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + credential_data) + ) + device.thread:call_with_delay(4, function() + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + 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(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + 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.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) + end) + device.thread:call_with_delay(delay + 2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + delay = delay + 2 + end + + device.thread:call_with_delay(delay + 4, function() + lock_utils.clear_busy_state(device, status) + end) +end + +local max_code_length_handler = function(driver, device, value) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +local min_code_length_handler = function(driver, device, value) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +local max_codes_handler = function(driver, device, value) + device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) +end + +local get_pin_response_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) + local command = device:get_field(lock_utils.COMMAND_NAME) + local status = lock_utils.STATUS_SUCCESS + local emit_event = false + + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) 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 + -- Called by reloading the codes. 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 zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then + -- tried to add a code that already is in use. + -- remove the created user if one got made. There is no associated credential. + status = lock_utils.STATUS_DUPLICATE + lock_utils.delete_user(device, active_credential.userIndex) + else + if lock_utils.get_credential(device, credential_index) ~= nil then + -- Credential has been deleted. + lock_utils.delete_credential(device, credential_index) + emit_event = true + end + end + + if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then + -- the credential we're checking has arrived + local last_slot = device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME) + if (credential_index >= last_slot) then + device:set_field(lock_utils.CHECKING_CODE, nil) + emit_event = true + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end + + if emit_event then + lock_utils.send_events(device) + end + -- ignore handling the busy state for these commands, they are handled within their own handlers + if command ~= nil and command ~= lock_utils.DELETE_ALL_CREDENTIALS and command ~= lock_utils.DELETE_ALL_USERS then + lock_utils.clear_busy_state(device, status) + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + local command = device:get_field(lock_utils.COMMAND_NAME) + local emit_events = false + + if credential_index >= 256 then -- Index is incorrectly written, attempt to shift it to get an actual value + credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) >> 8 + end + + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code updated + device:emit_event(capabilities.lockCredentials.commandResult( + {commandName = lock_utils.UPDATE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS}, + { state_change = true, visibility = { displayed = true } } + )) + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (credential_index == lock_utils.DELETE_ALL_USERS) then + -- All credentials deleted + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex) + emit_events = true + end + else + -- One credential deleted + if (lock_utils.get_credential(device, credential_index) ~= nil) then + lock_utils.delete_credential(device, credential_index) + emit_events = true + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) 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 + end + end + + if emit_events then + lock_utils.send_events(device) + end +end + +local lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local user = lock_utils.get_user(device, code_id) + if user ~= nil then + code_name = user.userName + end + + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + 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( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + local zigbee_lock_driver = { supported_capabilities = { Lock, - LockCodes, + LockCredentials, + LockUsers, Battery, }, zigbee_handlers = { @@ -121,30 +607,51 @@ local zigbee_lock_driver = { [Alarm.ID] = { [Alarm.client.commands.Alarm.ID] = alarm_handler }, + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + } }, attr = { [LockCluster.ID] = { [LockCluster.attributes.LockState.ID] = lock_state_handler, + [LockCluster.attributes.MaxPINCodeLength.ID] = max_code_length_handler, + [LockCluster.attributes.MinPINCodeLength.ID] = min_code_length_handler, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = max_codes_handler, } } }, capability_handlers = { [Lock.ID] = { - [Lock.commands.lock.NAME] = lock, - [Lock.commands.unlock.NAME] = unlock, + [Lock.commands.lock.NAME] = handle_lock, + [Lock.commands.unlock.NAME] = handle_unlock, }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = 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, + }, }, sub_drivers = require("sub_drivers"), lifecycle_handlers = { added = device_added, init = init, + doConfigure = do_configure, }, health_check = false, } defaults.register_for_default_handlers(zigbee_lock_driver, zigbee_lock_driver.supported_capabilities) -local lock = ZigbeeDriver("zigbee-lock", zigbee_lock_driver) -lock:run() +local driver = ZigbeeDriver("zigbee-lock", zigbee_lock_driver) +driver:run() diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua similarity index 79% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua index 1fe9815bb9..a37b71e079 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua @@ -1,4 +1,4 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device, ...) @@ -6,8 +6,8 @@ return function(opts, driver, device, ...) local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME, false) if not lock_codes_migrated then - local subdriver = require("using-old-capabilities") + local subdriver = require("legacy-handlers") return true, subdriver end return false -end \ No newline at end of file +end diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua similarity index 87% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua index f8fcae368f..b167d7f75f 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -1,25 +1,9 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Zigbee Driver utilities -local device_management = require "st.zigbee.device_management" +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Zigbee Spec Utils local clusters = require "st.zigbee.zcl.clusters" -local Alarm = clusters.Alarms local LockCluster = clusters.DoorLock -local PowerConfiguration = clusters.PowerConfiguration -- Capabilities local capabilities = require "st.capabilities" @@ -35,7 +19,7 @@ local UserTypeEnum = LockCluster.types.DrlkUserType local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode local socket = require "cosock.socket" -local lock_utils = require "lock_utils" +local lock_utils = require "legacy-handlers.legacy_lock_utils" local DELAY_LOCK_EVENT = "_delay_lock_event" local MAX_DELAY = 10 @@ -342,32 +326,8 @@ local migrate = function(driver, device, command) device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) end -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - - device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) - device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - - device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) - device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - -- Don't send a reload all codes if this is a part of migration - if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then - device.thread:call_with_delay(2, function(d) - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end) - else - device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) - end -end - -local old_capabilities_driver = { - NAME = "Lock Driver Using Old Capabilities", +local legacy_capabilities_driver = { + NAME = "Lock Driver Using LockCodes Capability", supported_capabilities = { Lock, LockCodes, @@ -400,14 +360,12 @@ local old_capabilities_driver = { [LockCodes.commands.migrate.NAME] = migrate, }, }, - sub_drivers = { - require("using-old-capabilities.sub_drivers") - }, + sub_drivers = require("legacy-handlers.sub_drivers"), health_check = false, lifecycle_handlers = { - doConfigure = do_configure + init = function(driver, device) lock_utils.populate_state_from_data(device) end, }, - can_handle = require("using-old-capabilities.can_handle") + can_handle = require("legacy-handlers.can_handle") } -return old_capabilities_driver +return legacy_capabilities_driver diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua similarity index 50% rename from drivers/SmartThings/zigbee-lock/src/lock_utils.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua index d9c06f41f6..6afa0aea3a 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua @@ -1,12 +1,13 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 + local utils = require "st.utils" local capabilities = require "st.capabilities" local json = require "st.json" local LockCodes = capabilities.lockCodes -local lock_utils = { +local legacy_lock_utils = { -- Constants LOCK_CODES = "lockCodes", LOCK_USERS = "lockUsers", @@ -17,57 +18,57 @@ local lock_utils = { CHECKED_CODE_SUPPORT = "checkedCodeSupport", } -lock_utils.get_lock_codes = function(device) - local lc = device:get_field(lock_utils.LOCK_CODES) +legacy_lock_utils.get_lock_codes = function(device) + local lc = device:get_field(legacy_lock_utils.LOCK_CODES) return lc ~= nil and lc or {} end -lock_utils.lock_codes_event = function(device, lock_codes) - device:set_field(lock_utils.LOCK_CODES, lock_codes, { persist = true } ) +legacy_lock_utils.lock_codes_event = function(device, lock_codes) + device:set_field(legacy_lock_utils.LOCK_CODES, lock_codes, { persist = true } ) device:emit_event(capabilities.lockCodes.lockCodes(json.encode(utils.deep_copy(lock_codes)), { visibility = { displayed = false } })) end -function lock_utils.get_code_name(device, code_id) - if (device:get_field(lock_utils.CODE_STATE) ~= nil and device:get_field(lock_utils.CODE_STATE)["setName"..code_id] ~= nil) then +function legacy_lock_utils.get_code_name(device, code_id) + if (device:get_field(legacy_lock_utils.CODE_STATE) ~= nil and device:get_field(legacy_lock_utils.CODE_STATE)["setName"..code_id] ~= nil) then -- this means a code set operation succeeded - return device:get_field(lock_utils.CODE_STATE)["setName"..code_id] - elseif (lock_utils.get_lock_codes(device)[code_id] ~= nil) then - return lock_utils.get_lock_codes(device)[code_id] + return device:get_field(legacy_lock_utils.CODE_STATE)["setName"..code_id] + elseif (legacy_lock_utils.get_lock_codes(device)[code_id] ~= nil) then + return legacy_lock_utils.get_lock_codes(device)[code_id] else return "Code " .. code_id end end -function lock_utils.get_change_type(device, code_id) - if (lock_utils.get_lock_codes(device)[code_id] == nil) then +function legacy_lock_utils.get_change_type(device, code_id) + if (legacy_lock_utils.get_lock_codes(device)[code_id] == nil) then return " set" else return " changed" end end -function lock_utils.reset_code_state(device, code_slot) - local codeState = device:get_field(lock_utils.CODE_STATE) +function legacy_lock_utils.reset_code_state(device, code_slot) + local codeState = device:get_field(legacy_lock_utils.CODE_STATE) if (codeState ~= nil) then codeState["setName".. code_slot] = nil codeState["setCode".. code_slot] = nil - device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) + device:set_field(legacy_lock_utils.CODE_STATE, codeState, { persist = true }) end end -function lock_utils.code_deleted(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) +function legacy_lock_utils.code_deleted(device, code_slot) + local lock_codes = legacy_lock_utils.get_lock_codes(device) local event = LockCodes.codeChanged(code_slot.." deleted", { state_change = true }) - event.data = {codeName = lock_utils.get_code_name(device, code_slot)} + event.data = {codeName = legacy_lock_utils.get_code_name(device, code_slot)} lock_codes[code_slot] = nil device:emit_event(event) - lock_utils.reset_code_state(device, code_slot) + legacy_lock_utils.reset_code_state(device, code_slot) return lock_codes end -function lock_utils.populate_state_from_data(device) - if device.data.lockCodes ~= nil and device:get_field(lock_utils.MIGRATION_COMPLETE) ~= true then +function legacy_lock_utils.populate_state_from_data(device) + if device.data.lockCodes ~= nil and device:get_field(legacy_lock_utils.MIGRATION_COMPLETE) ~= true then -- build the lockCodes table local lockCodes = {} local lc_data = json.decode(device.data.lockCodes) @@ -75,14 +76,14 @@ function lock_utils.populate_state_from_data(device) lockCodes[k] = v end -- Populate the devices `lockCodes` field - device:set_field(lock_utils.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) + device:set_field(legacy_lock_utils.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) -- Populate the devices state history cache device.state_cache["main"] = device.state_cache["main"] or {} device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} - device:set_field(lock_utils.MIGRATION_COMPLETE, true, { persist = true }) + device:set_field(legacy_lock_utils.MIGRATION_COMPLETE, true, { persist = true }) end end -return lock_utils +return legacy_lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua similarity index 60% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua index 0eac7666c6..34b3a5a280 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua @@ -1,11 +1,11 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device, cmd) - local fingerprints = require("using-old-capabilities.lock-without-codes.fingerprints") + local fingerprints = require("legacy-handlers.lock-without-codes.fingerprints") for _, fingerprint in ipairs(fingerprints) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("using-old-capabilities.lock-without-codes") + local subdriver = require("legacy-handlers.lock-without-codes") return true, subdriver end end diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua similarity index 96% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua index 743a2cc6f6..4522580297 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua @@ -8,6 +8,9 @@ local capabilities = require "st.capabilities" local DoorLock = clusters.DoorLock local PowerConfiguration = clusters.PowerConfiguration + + + local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then @@ -70,7 +73,7 @@ local lock_without_codes = { } } }, - can_handle = require("using-old-capabilities.lock-without-codes.can_handle") + can_handle = require("legacy-handlers.lock-without-codes.can_handle") } return lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua new file mode 100644 index 0000000000..8cbfd6701f --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers.yale"), + lazy_load_if_possible("legacy-handlers.yale-fingerprint-lock"), + lazy_load_if_possible("legacy-handlers.lock-without-codes") +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua similarity index 59% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua index a281fc16da..3baa8f368c 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua @@ -1,11 +1,11 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device) - local fingerprints = require("using-old-capabilities.yale-fingerprint-lock.fingerprints") + local fingerprints = require("yale-fingerprint-lock.fingerprints") for _, fingerprint in ipairs(fingerprints) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("using-old-capabilities.yale-fingerprint-lock") + local subdriver = require("legacy-handlers.yale-fingerprint-lock") return true, subdriver end end diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua similarity index 85% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua index 12619c01d5..7e982fd943 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 @@ -21,7 +21,7 @@ local yale_fingerprint_lock_driver = { } } }, - can_handle = require("using-old-capabilities.yale-fingerprint-lock.can_handle") + can_handle = require("legacy-handlers.yale-fingerprint-lock.can_handle") } return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua similarity index 61% rename from drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua index ecefc56b4c..832914f6ea 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua @@ -1,10 +1,11 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -return function(opts, driver, device) +local function yale_can_handle(opts, driver, device, ...) if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then - local subdriver = require("using-new-capabilities.yale") - return true, subdriver + return true, require("legacy-handlers.yale") end return false end + +return yale_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua similarity index 97% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua index 4d6b8f8431..a85a9f8798 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua @@ -15,7 +15,7 @@ local LockCodes = capabilities.lockCodes local UserStatusEnum = LockCluster.types.DrlkUserStatus local UserTypeEnum = LockCluster.types.DrlkUserType -local lock_utils = (require "lock_utils") +local lock_utils = (require "legacy-handlers.legacy_lock_utils") local reload_all_codes = function(driver, device, command) -- starts at first user code index then iterates through all lock codes as they come in @@ -142,8 +142,7 @@ local yale_door_lock_driver = { [LockCodes.commands.setCode.NAME] = set_code } }, - sub_drivers = require("using-old-capabilities.yale.sub_drivers"), - can_handle = require("using-old-capabilities.yale.can_handle") + can_handle = require("legacy-handlers.yale.can_handle") } return yale_door_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua similarity index 55% rename from drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua index f9ff67fcf5..c483b2fe27 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua @@ -1,10 +1,11 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -return function(opts, driver, device) +local function samsungsds_can_handle(opts, driver, device, ...) if device:get_manufacturer() == "SAMSUNG SDS" then - local subdriver = require("using-new-capabilities.samsungsds") - return true, subdriver + return true, require("samsungsds") end return false end + +return samsungsds_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua similarity index 86% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua rename to drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index 51418f6e03..060b7d013d 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -10,7 +10,7 @@ local cluster_base = require "st.zigbee.cluster_base" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Lock = capabilities.lock -local lock_utils = require "lock_utils" +local lock_utils = require "zigbee_lock_utils" local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F local SAMSUNG_SDS_MFR_CODE = 0x0003 @@ -53,8 +53,18 @@ local function emit_event_if_latest_state_missing(device, component, capability, end end +local function load_device_state(device) + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME, false) + if lock_codes_migrated then + lock_utils.reload_tables(device) + else + local legacy_lock_utils = require "legacy-handlers.legacy_lock_utils" + legacy_lock_utils.populate_state_from_data(device) + end +end + local device_added = function(self, device) - lock_utils.populate_state_from_data(device) + load_device_state(device) emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) device:emit_event(capabilities.battery.battery(100)) end @@ -71,7 +81,7 @@ local device_init = function(driver, device, event) battery_init(driver, device, event) device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) - lock_utils.populate_state_from_data(device) + load_device_state(device) end local samsung_sds_driver = { @@ -102,7 +112,7 @@ local samsung_sds_driver = { added = device_added, init = device_init }, - can_handle = require("using-old-capabilities.samsungsds.can_handle") + can_handle = require("samsungsds.can_handle") } return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua index e7ed53082b..d3491ed664 100644 --- a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua @@ -3,7 +3,9 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { - lazy_load_if_possible("using-old-capabilities"), - lazy_load_if_possible("using-new-capabilities"), + lazy_load_if_possible("legacy-handlers"), + lazy_load_if_possible("samsungsds"), + lazy_load_if_possible("bad-battery-reporter"), + lazy_load_if_possible("yale-fingerprint-lock"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua index d0de6d6824..0fde1e1cd5 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua @@ -1,16 +1,5 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua index d23423983d..5f6eb6ce5c 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua @@ -1,16 +1,5 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua index 73030a792e..14a183adfe 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua @@ -1,16 +1,5 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua deleted file mode 100644 index 5618b59b30..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/can_handle.lua +++ /dev/null @@ -1,13 +0,0 @@ --- Copyright © 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -return function(opts, driver, device, ...) - local capabilities = require "st.capabilities" - local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, - capabilities.lockCodes.migrated.NAME, false) - if lock_codes_migrated then - local subdriver = require("using-new-capabilities") - return true, subdriver - end - return false -end \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua deleted file mode 100644 index 21984e938b..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/can_handle.lua +++ /dev/null @@ -1,17 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local LOCK_WITHOUT_CODES_FINGERPRINTS = { - { model = "E261-KR0B0Z0-HA" }, - { mfr = "Danalock", model = "V3-BTZB" } -} - -return function(opts, driver, device, cmd) - for _, fingerprint in ipairs(LOCK_WITHOUT_CODES_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("using-new-capabilities.lock-without-codes") - return true, subdriver - end - end - return false -end diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua deleted file mode 100644 index 8363377bf9..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua +++ /dev/null @@ -1,87 +0,0 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local configurationMap = require "configurations" -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" - -local DoorLock = clusters.DoorLock -local PowerConfiguration = clusters.PowerConfiguration - -local function device_init(driver, device) - local configuration = configurationMap.get_device_configuration(device) - if configuration ~= nil then - for _, attribute in ipairs(configuration) do - device:add_configured_attribute(attribute) - end - end -end - -local function handle_lock(driver, device, cmd) - device:send(DoorLock.commands.LockDoor(device)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) -end - -local function handle_unlock(driver, device, cmd) - device:send(DoorLock.commands.UnlockDoor(device)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) -end - -local function do_refresh(driver, device) - device:refresh() -end - -local function do_configure(driver, device) - device:configure() -end - -local function handle_lock_door(driver, device, zb_rx) - local function query_device() - device:send(DoorLock.attributes.LockState:read(device)) - end - device.thread:call_with_delay(5, query_device) -end - -local lock_without_codes = { - NAME = "Zigbee Lock Without Codes", - lifecycle_handlers = { - init = device_init, - doConfigure = do_configure - }, - capability_handlers = { - [capabilities.lock.ID] = { - [capabilities.lock.commands.lock.NAME] = handle_lock, - [capabilities.lock.commands.unlock.NAME] = handle_unlock - }, - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = do_refresh - } - }, - zigbee_handlers = { - cluster = { - [DoorLock.ID] = { - [DoorLock.commands.LockDoorResponse.ID] = handle_lock_door, - [DoorLock.commands.UnlockDoorResponse.ID] = handle_lock_door, - } - }, - attr = { - [DoorLock.ID] = { - [DoorLock.attributes.NumberOfPINUsersSupported.ID] = function() end -- just to make sure we don't switch profiles - } - } - }, - can_handle = require("using-new-capabilities.lock-without-codes.can_handle") -} - -return lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua deleted file mode 100644 index ed61935e4b..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua +++ /dev/null @@ -1,118 +0,0 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local device_management = require "st.zigbee.device_management" -local clusters = require "st.zigbee.zcl.clusters" -local battery_defaults = require "st.zigbee.defaults.battery_defaults" -local capabilities = require "st.capabilities" -local cluster_base = require "st.zigbee.cluster_base" -local PowerConfiguration = clusters.PowerConfiguration -local DoorLock = clusters.DoorLock -local Lock = capabilities.lock -local lock_utils = require "new_lock_utils" - -local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F -local SAMSUNG_SDS_MFR_CODE = 0x0003 - -local function handle_lock_state(driver, device, value, zb_rx) - if value.value == DoorLock.attributes.LockState.LOCKED then - device:emit_event(Lock.lock.locked()) - elseif value.value == DoorLock.attributes.LockState.UNLOCKED then - device:emit_event(Lock.lock.unlocked()) - end -end - -local function mfg_lock_door_handler(driver, device, zb_rx) - local cmd = zb_rx.body.zcl_body.body_bytes:byte(1) - if cmd == 0x00 then - device:emit_event(Lock.lock.unlocked()) - end -end - -local function unlock_cmd_handler(driver, device, command) - device:send(cluster_base.build_manufacturer_specific_command( - device, - DoorLock.ID, - SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND, - SAMSUNG_SDS_MFR_CODE, - "\x10\x04\x31\x32\x33\x35")) -end - -local function lock_cmd_handler(driver, device, command) - -- do nothing in lock command handler -end - -local refresh = function(driver, device, cmd) - -- do nothing in refresh capability handler -end - -local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) - if device:get_latest_state(component, capability.ID, attribute_name) == nil then - device:emit_event(value) - end -end - -local device_added = function(self, device) - lock_utils.reload_tables(device) - emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) - device:emit_event(capabilities.battery.battery(100)) -end - -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, DoorLock.ID, self.environment_info.hub_zigbee_eui)) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0)) -end - -local battery_init = battery_defaults.build_linear_voltage_init(4.0, 6.0) - -local device_init = function(driver, device, event) - battery_init(driver, device, event) - device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) - device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) - lock_utils.reload_tables(device) -end - -local samsung_sds_driver = { - NAME = "SAMSUNG SDS Lock Driver", - zigbee_handlers = { - cluster = { - [DoorLock.ID] = { - [SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND] = mfg_lock_door_handler - } - }, - attr = { - [DoorLock.ID] = { - [DoorLock.attributes.LockState.ID] = handle_lock_state - } - } - }, - capability_handlers = { - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = refresh - }, - [capabilities.lock.ID] = { - [capabilities.lock.commands.unlock.NAME] = unlock_cmd_handler, - [capabilities.lock.commands.lock.NAME] = lock_cmd_handler - } - }, - lifecycle_handlers = { - doConfigure = do_configure, - added = device_added, - init = device_init - }, - can_handle = require("using-new-capabilities.samsungsds.can_handle") -} - -return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua deleted file mode 100644 index 8167f51d98..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/sub_drivers.lua +++ /dev/null @@ -1,11 +0,0 @@ --- 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("using-new-capabilities.samsungsds"), - lazy_load_if_possible("using-new-capabilities.yale-fingerprint-lock"), - lazy_load_if_possible("using-new-capabilities.yale"), - lazy_load_if_possible("using-new-capabilities.lock-without-codes") -} -return sub_drivers \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua deleted file mode 100644 index 6f206ed9b5..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/can_handle.lua +++ /dev/null @@ -1,19 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local YALE_FINGERPRINT_LOCK = { - { mfr = "ASSA ABLOY iRevo", model = "iZBModule01" }, - { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, - { mfr = "ASSA ABLOY iRevo", model = "0700000001" }, - { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } -} - -return function(opts, driver, device) - for _, fingerprint in ipairs(YALE_FINGERPRINT_LOCK) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("using-new-capabilities.yale-fingerprint-lock") - return true, subdriver - end - end - return false -end \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua deleted file mode 100644 index 2e88e950f1..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua +++ /dev/null @@ -1,171 +0,0 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Zigbee Spec Utils -local clusters = require "st.zigbee.zcl.clusters" -local LockCluster = clusters.DoorLock - --- Capabilities -local capabilities = require "st.capabilities" - --- Enums -local UserStatusEnum = LockCluster.types.DrlkUserStatus -local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode - -local SHIFT_INDEX_CHECK = 256 -local YALE_MAX_USERS_OVERRIDE = 10 -- yale supports 250 codes... we're not going to iterate through all that. - -local lock_utils = (require "new_lock_utils") - -local get_pin_response_handler = function(driver, device, zb_mess) - local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) - local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) - local command = device:get_field(lock_utils.COMMAND_NAME) - local status = lock_utils.STATUS_SUCCESS - local emit_event = false - - if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) 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 - -- Called by reloading the codes. 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 zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then - -- tried to add a code that already is in use. - -- remove the created user if one got made. There is no associated credential. - status = lock_utils.STATUS_DUPLICATE - lock_utils.delete_user(device, active_credential.userIndex) - else - if lock_utils.get_credential(device, credential_index) ~= nil then - -- Credential has been deleted. - lock_utils.delete_credential(device, credential_index) - emit_event = true - end - end - - if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then - -- the credential we're checking has arrived - local last_slot = YALE_MAX_USERS_OVERRIDE - if (credential_index >= last_slot) then - device:set_field(lock_utils.CHECKING_CODE, nil) - emit_event = true - else - local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 - device:set_field(lock_utils.CHECKING_CODE, checkingCode) - device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) - end - end - - if emit_event then - lock_utils.send_events(device) - end - -- ignore handling the busy state for these commands, they are handled within their own handlers - if command ~= nil and command ~= lock_utils.DELETE_ALL_CREDENTIALS and command ~= lock_utils.DELETE_ALL_USERS then - lock_utils.clear_busy_state(device, status) - end -end - -local programming_event_handler = function(driver, device, zb_mess) - local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) - local command = device:get_field(lock_utils.COMMAND_NAME) - local emit_events = false - - if credential_index >= SHIFT_INDEX_CHECK then - -- Index is wonky, shift it to get proper value - credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) >> 8 - end - - if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - -- Master code updated - device:emit_event(capabilities.lockCredentials.commandResult( - {commandName = lock_utils.UPDATE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS}, - { state_change = true, visibility = { displayed = true } } - )) - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (zb_mess.body.zcl_body.user_id.value == 0xFFFF) then - -- All credentials deleted - for _, credential in pairs(lock_utils.get_credentials(device)) do - lock_utils.delete_credential(device, credential.credentialIndex) - emit_events = true - end - else - -- One credential deleted - if (lock_utils.get_credential(device, credential_index) ~= nil) then - lock_utils.delete_credential(device, credential_index) - emit_events = true - end - end - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) 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 - end - end - - if emit_events then - lock_utils.send_events(device) - end -end - -local yale_door_lock_driver = { - NAME = "Yale Door Lock", - zigbee_handlers = { - cluster = { - [LockCluster.ID] = { - [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, - [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, - } - } - }, - - sub_drivers = { require("using-new-capabilities.yale.sub_drivers") }, - can_handle = require("using-new-capabilities.yale.can_handle") -} - -return yale_door_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua deleted file mode 100644 index 19d92e6f3e..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/sub_drivers.lua +++ /dev/null @@ -1,8 +0,0 @@ --- 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("using-new-capabilities.yale.yale-bad-battery-reporter"), -} -return sub_drivers \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua deleted file mode 100644 index 39fffc666c..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/can_handle.lua +++ /dev/null @@ -1,21 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local BAD_YALE_LOCK_FINGERPRINTS = { - { mfr = "Yale", model = "YRD220/240 TSDB" }, - { mfr = "Yale", model = "YRL220 TS LL" }, - { mfr = "Yale", model = "YRD210 PB DB" }, - { mfr = "Yale", model = "YRL210 PB LL" }, - { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, - { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } -} - -return function(opts, driver, device) - for _, fingerprint in ipairs(BAD_YALE_LOCK_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("using-new-capabilities.yale.yale-bad-battery-reporter") - return true, subdriver - end - end - return false -end \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua deleted file mode 100644 index 0a1bcaceb3..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua +++ /dev/null @@ -1,34 +0,0 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" - -local battery_report_handler = function(driver, device, value) - device:emit_event(capabilities.battery.battery(value.value)) -end - -local bad_yale_driver = { - NAME = "YALE BAD Lock Driver", - zigbee_handlers = { - attr = { - [clusters.PowerConfiguration.ID] = { - [clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_report_handler - } - } - }, - can_handle = require("using-new-capabilities.yale.yale-bad-battery-reporter.can_handle") -} - -return bad_yale_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua deleted file mode 100644 index c2419a7b01..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/can_handle.lua +++ /dev/null @@ -1,10 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -return function(opts, driver, device) - if device:get_manufacturer() == "SAMSUNG SDS" then - local subdriver = require("using-old-capabilities.samsungsds") - return true, subdriver - end - return false -end diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua deleted file mode 100644 index 1f6149587c..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/sub_drivers.lua +++ /dev/null @@ -1,11 +0,0 @@ --- 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("using-old-capabilities.samsungsds"), - lazy_load_if_possible("using-old-capabilities.yale"), - lazy_load_if_possible("using-old-capabilities.yale-fingerprint-lock"), - lazy_load_if_possible("using-old-capabilities.lock-without-codes") -} -return sub_drivers \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua deleted file mode 100644 index 25a4a6caee..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/can_handle.lua +++ /dev/null @@ -1,10 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -return function(opts, driver, device) - if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then - local subdriver = require("using-old-capabilities.yale") - return true, subdriver - end - return false -end diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/sub_drivers.lua deleted file mode 100644 index d31cfb314a..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/sub_drivers.lua +++ /dev/null @@ -1,8 +0,0 @@ --- 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("using-old-capabilities.yale.yale-bad-battery-reporter"), -} -return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua new file mode 100644 index 0000000000..84a9918622 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local yale_fingerprint_lock_models = function(opts, driver, device) + local capabilities = require "st.capabilities" + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if not lock_codes_migrated then return false end + local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("yale-fingerprint-lock") + end + end + return false +end + +return yale_fingerprint_lock_models \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua similarity index 53% rename from drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua rename to drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua index d0ab156b65..9965ef181e 100644 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -20,6 +10,8 @@ local LockUsers = capabilities.lockUsers local YALE_FINGERPRINT_MAX_CODES = 0x1E + + local handle_max_codes = function(driver, device, value) device:emit_event(LockCredentials.pinUsersSupported(YALE_FINGERPRINT_MAX_CODES)) device:emit_event(LockUsers.totalUsersSupported(YALE_FINGERPRINT_MAX_CODES)) @@ -34,7 +26,7 @@ local yale_fingerprint_lock_driver = { } } }, - can_handle = require("using-new-capabilities.yale-fingerprint-lock.can_handle") + can_handle = require("yale-fingerprint-lock.can_handle") } return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua similarity index 94% rename from drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua rename to drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua index 9d5848b21b..6000f6a804 100644 --- a/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua @@ -1,16 +1,6 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- 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 @@ -45,6 +35,8 @@ local new_lock_utils = { USER_TYPE = "userType" } +new_lock_utils.DELETE_ALL_USERS = 0xFF + -- check if we are currently busy performing a task. -- if we aren't then set as busy. new_lock_utils.busy_check_and_set = function (device, command, override_busy_check) From c02dbd6122294d99ec6191165d7ddd5ee89a5b62 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sun, 3 May 2026 16:36:20 -0500 Subject: [PATCH 03/33] fixup! remove unused file --- .../src/using-new-capabilities/init.lua | 570 ------------------ 1 file changed, 570 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua deleted file mode 100644 index 4b3c6a4096..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua +++ /dev/null @@ -1,570 +0,0 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Zigbee Driver utilities -local device_management = require "st.zigbee.device_management" -local log = require "log" -local utils = require "st.utils" - - --- Zigbee Spec Utils -local clusters = require "st.zigbee.zcl.clusters" -local Alarm = clusters.Alarms -local LockCluster = clusters.DoorLock -local PowerConfiguration = clusters.PowerConfiguration - --- Capabilities -local capabilities = require "st.capabilities" -local Battery = capabilities.battery -local Lock = capabilities.lock -local LockCredentials = capabilities.lockCredentials -local LockUsers = capabilities.lockUsers - --- Enums -local UserStatusEnum = LockCluster.types.DrlkUserStatus -local UserTypeEnum = LockCluster.types.DrlkUserType -local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode - -local socket = require "cosock.socket" -local lock_utils = require "new_lock_utils" - -local DELAY_LOCK_EVENT = "_delay_lock_event" -local MAX_DELAY = 10 - -local reload_all_codes = function(device) - -- starts at first user code index then iterates through all lock codes as they come in - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then - device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then - device:send(LockCluster.attributes.MinPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - end - if (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfTotalUsersSupported:read(device)) - end - if (device:get_field(lock_utils.CHECKING_CODE) == nil) then - device:set_field(lock_utils.CHECKING_CODE, 1) - end - - device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) -end - -local init = function(driver, device) - lock_utils.reload_tables(device) - device.thread:call_with_delay(15, function(d) - reload_all_codes(device) - end) -end - -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - - device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) - device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - - device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) - device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - device.thread:call_with_delay(2, function(d) - reload_all_codes(device) - end) -end - -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, { persist = true }) - 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 - -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 - 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 - -local add_credential_handler = function(driver, device, command) - 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 - -- set the pin code and then validate it was successful when the GetPINCode response is received. - -- the credential creation and events will also be handled in that response. - device:set_field(lock_utils.ACTIVE_CREDENTIAL, - { userIndex = user_index, userType = user_type, credentialType = credential_type, credentialIndex = credential_index }) - device:send(LockCluster.server.commands.SetPINCode(device, - credential_index, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - credential_data) - ) - device.thread:call_with_delay(4, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - else - lock_utils.clear_busy_state(device, status) - end -end - -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(LockCluster.server.commands.SetPINCode(device, - credential_index, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - credential_data) - ) - device.thread:call_with_delay(4, function() - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - 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(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) - device.thread:call_with_delay(2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - 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.thread:call_with_delay(delay, function() - device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) - end) - device.thread:call_with_delay(delay + 2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - delay = delay + 2 - end - - device.thread:call_with_delay(delay + 4, function() - lock_utils.clear_busy_state(device, status) - end) -end - -local max_code_length_handler = function(driver, device, value) - device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) -end - -local min_code_length_handler = function(driver, device, value) - device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) -end - -local max_codes_handler = function(driver, device, value) - device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) - device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) -end - -local get_pin_response_handler = function(driver, device, zb_mess) - local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) - local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) - local command = device:get_field(lock_utils.COMMAND_NAME) - local status = lock_utils.STATUS_SUCCESS - local emit_event = false - - if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) 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 - -- Called by reloading the codes. 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 zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then - -- tried to add a code that already is in use. - -- remove the created user if one got made. There is no associated credential. - status = lock_utils.STATUS_DUPLICATE - lock_utils.delete_user(device, active_credential.userIndex) - else - if lock_utils.get_credential(device, credential_index) ~= nil then - -- Credential has been deleted. - lock_utils.delete_credential(device, credential_index) - emit_event = true - end - end - - if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then - -- the credential we're checking has arrived - local last_slot = device:get_latest_state("main", capabilities.lockCredentials.ID, - capabilities.lockCredentials.pinUsersSupported.NAME) - if (credential_index >= last_slot) then - device:set_field(lock_utils.CHECKING_CODE, nil) - emit_event = true - else - local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 - device:set_field(lock_utils.CHECKING_CODE, checkingCode) - device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) - end - end - - if emit_event then - lock_utils.send_events(device) - end - -- ignore handling the busy state for these commands, they are handled within their own handlers - if command ~= nil and command ~= lock_utils.DELETE_ALL_CREDENTIALS and command ~= lock_utils.DELETE_ALL_USERS then - lock_utils.clear_busy_state(device, status) - end -end - -local programming_event_handler = function(driver, device, zb_mess) - local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) - local command = device:get_field(lock_utils.COMMAND_NAME) - local emit_events = false - - if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - -- Master code updated - device:emit_event(capabilities.lockCredentials.commandResult( - {commandName = lock_utils.UPDATE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS}, - { state_change = true, visibility = { displayed = true } } - )) - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (zb_mess.body.zcl_body.user_id.value == 0xFF) then - -- All credentials deleted - for _, credential in pairs(lock_utils.get_credentials(device)) do - lock_utils.delete_credential(device, credential.credentialIndex) - emit_events = true - end - else - -- One credential deleted - if (lock_utils.get_credential(device, credential_index) ~= nil) then - lock_utils.delete_credential(device, credential_index) - emit_events = true - end - end - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) 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 - end - end - - if emit_events then - lock_utils.send_events(device) - end -end - --- REMOVE THIS AFTER DONE WITH TESTING -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 lock_operation_event_handler = function(driver, device, zb_rx) - local event_code = zb_rx.body.zcl_body.operation_event_code.value - local source = zb_rx.body.zcl_body.operation_event_source.value - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" - } - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() - } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" - else - event.data.method = METHOD[source] - end - if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad - local code_id = zb_rx.body.zcl_body.user_id.value - local code_name = "Code " .. code_id - local user = lock_utils.get_user(device, code_id) - if user ~= nil then - code_name = user.userName - end - - event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } - 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( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) - end - end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) - end -end - - -local new_capabilities_driver = { - NAME = "Lock Driver Using New Capabilities", - supported_capabilities = { - Lock, - LockCredentials, - LockUsers, - Battery, - }, - zigbee_handlers = { - cluster = { - [LockCluster.ID] = { - [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, - [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, - [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler, - } - }, - attr = { - [LockCluster.ID] = { - [LockCluster.attributes.MaxPINCodeLength.ID] = max_code_length_handler, - [LockCluster.attributes.MinPINCodeLength.ID] = min_code_length_handler, - [LockCluster.attributes.NumberOfPINUsersSupported.ID] = max_codes_handler, - } - } - }, - capability_handlers = { - [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] = { -- REMOVE THIS WHEN DONE WITH TESTING - [capabilities.lockCodes.commands.migrate.NAME] = migrate, - }, - }, - sub_drivers = { - require("using-new-capabilities.sub_drivers") - }, - health_check = false, - lifecycle_handlers = { - init = init, - doConfigure = do_configure - }, - can_handle = require("using-new-capabilities.can_handle") -} - -return new_capabilities_driver From 2cfd70682716cad38e2b1fb39f67173fbb465d78 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 4 May 2026 14:31:48 -0500 Subject: [PATCH 04/33] only update on added if state is typed --- drivers/SmartThings/zigbee-lock/src/init.lua | 12 +- .../zigbee-lock/src/samsungsds/init.lua | 8 +- .../test/test_zigbee_lock_code_migration.lua | 250 ------------------ .../test_zigbee_lock_code_slga_migration.lua | 17 +- .../src/test/test_zigbee_samsungsds.lua | 1 + .../test_zigbee_yale-new_capabilities.lua | 245 +++++++---------- 6 files changed, 117 insertions(+), 416 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 1c4c74a29e..0e02b8ec88 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -59,19 +59,13 @@ local alarm_handler = function(driver, device, zb_mess) end end --- This triggers setting the migrated field so new devices use new capabilities from the start. local function device_added(driver, device) - -- this variable should only be present for test cases trying to test the old capabilities. - if device.useOldCapabilityForTesting == nil then - if device:supports_capability_by_id(LockCodes.ID) then + if device:supports_capability_by_id(LockCodes.ID) then + if device._provisioning_state == "TYPED" then -- only run migration for typed devices, as provisioned devices may be in the process of migrating and we don't want to interfere with that. + -- If a device is newly onboarded (typed), we set the migrated field to true so devices use lockCredentials/lockUsers from the start. device:emit_event(LockCodes.migrated(true, { state_change = true, visibility = { displayed = true } })) device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) - lock_utils.reload_tables(device) - else - legacy_lock_utils.populate_state_from_data(device) end - else - legacy_lock_utils.populate_state_from_data(device) end driver:inject_capability_command(device, { diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index 060b7d013d..ca9986ad16 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -64,7 +64,13 @@ local function load_device_state(device) end local device_added = function(self, device) - load_device_state(device) + if device:supports_capability_by_id(capabilities.lockCodes.ID) then + if device._provisioning_state == "TYPED" then -- only run migration for typed devices, as provisioned devices may be in the process of migrating and we don't want to interfere with that. + -- If a device is newly onboarded (typed), we set the migrated field to true so devices use lockCredentials/lockUsers from the start. + device:emit_event(capabilities.lockCodes.migrated(true, { state_change = true, visibility = { displayed = true } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + end + end emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) device:emit_event(capabilities.battery.battery(100)) end diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua deleted file mode 100644 index bc1c2b54ef..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua +++ /dev/null @@ -1,250 +0,0 @@ --- Copyright 2022 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Mock out globals -local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local t_utils = require "integration_test.utils" - -local clusters = require "st.zigbee.zcl.clusters" -local PowerConfiguration = clusters.PowerConfiguration -local DoorLock = clusters.DoorLock -local Alarm = clusters.Alarms - -local json = require "st.json" - -local mock_datastore = require "integration_test.mock_env_datastore" - -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - data = { - lockCodes = json.encode({ - ["1"] = "Zach", - ["2"] = "Steven" - }) - }, - useOldCapabilityForTesting = true - } -) - -local mock_device_no_data = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - data = {}, - useOldCapabilityForTesting = true - } -) -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init()end - -test.set_test_init_function(test_init) - -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.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- 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" }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, DoorLock.attributes.LockState:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, Alarm.attributes.AlarmCount:read(mock_device_no_data) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", nil) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", nil) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.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.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- 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, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- 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 with new data should populate fields", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, DoorLock.attributes.LockState:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, Alarm.attributes.AlarmCount:read(mock_device_no_data) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "lockCodes", nil) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "__state_cache", {}) - -- 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({ ["1"] = "Zach", ["2"] = "Steven" }) - } - } - )) - test.wait_for_events() - 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, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- 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, device response produces no events", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.wait_for_events() - - -- run do_configure step after added and verify no refresh all codes - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, - 600, - 21600, - 1) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - DoorLock.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, - 0, - 3600, - 0) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - Alarm.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, - 0, - 21600, - 0) }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - -- Validate migration reload skipped datastore - test.wait_for_events() - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationReloadSkipped", true) - -- Verify the timer doesn't fire as it wasn't created - test.mock_time.advance_time(4) - test.wait_for_events() - end, - { - min_api_version = 17 - } -) - - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua index 0fde1e1cd5..2bfb0032ff 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua @@ -7,9 +7,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" -local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock -local Alarm = clusters.Alarms local capabilities = require "st.capabilities" local json = require "st.json" @@ -24,30 +22,27 @@ local mock_device = test.mock_device.build_test_zigbee_device( ["1"] = "Zach", ["5"] = "Steven" }), - }, - useOldCapabilityForTesting = true + } } ) zigbee_test_utils.prepare_zigbee_env_info() -local function test_init()end +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end test.set_test_init_function(test_init) test.register_coroutine_test( "Device called 'migrate' command", function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.wait_for_events() -- Validate lockCodes field mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["5"] = "Steven" }) -- Validate migration complete flag mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - -- Set min/max code length attributes test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 5) }) test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 10) }) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua index 4b5b18a70e..fe76380bba 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua @@ -30,6 +30,7 @@ local SAMSUNG_SDS_MFR_CODE = 0x0003 local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("lock-without-codes.yml"), + provisioning_state = "TYPED", zigbee_endpoints = { [1] = { id = 1, diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua index 14a183adfe..0f0f03d455 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua @@ -17,9 +17,12 @@ local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType local ProgrammingEventCode = DoorLock.types.ProgramEventCode +test.disable_startup_messages() + local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), + _provisioning_state = "TYPED", zigbee_endpoints = { [1] = { id = 1, manufacturer = "Yale", server_clusters = { 0x0001 } } } @@ -28,6 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device({ zigbee_test_utils.prepare_zigbee_env_info() local function test_init_default() + test.disable_startup_messages() test.mock_device.add_test_device(mock_device) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", @@ -40,18 +44,8 @@ local function test_init_default() test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) end -local function test_init_add_device() - test.mock_device.add_test_device(mock_device) - 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, { state_change = true, visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read( - mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - +local function test_init_lifecycle_event(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init"}) test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( mock_device, 4) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", @@ -62,6 +56,7 @@ local function test_init_add_device() capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.wait_for_events() end test.set_test_init_function(test_init_default) @@ -118,6 +113,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Adding a credential should succeed and report users, credentials, and command result.", function() + test_init_lifecycle_event(mock_device) test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) test.socket.zigbee:__expect_send( @@ -177,17 +173,13 @@ test.register_coroutine_test( ) ) ) - end, - { - test_init = function() - test_init_add_device() - end - } + end ) test.register_coroutine_test( "Updating a credential should succeed and report users, credentials, and command result.", function() + test_init_lifecycle_event(mock_device) -- add credential first test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) @@ -326,86 +318,67 @@ test.register_coroutine_test( ) ) test.wait_for_events() - end, - { - test_init = function() - test_init_add_device() - end - } + end ) -test.register_message_test( +test.register_coroutine_test( "The lock reporting a single code has been set and then deleted should be handled", - { + function() + test_init_lifecycle_event(mock_device) -- add credential - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_ADDED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + }) + 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 } })) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, { state_change = true, visibility = { displayed = true } })) - }, + ) + test.wait_for_events() -- delete the credential - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - 0x0000, - "data" - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } })) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } })) - } - }, - { test_init = test_init_add_device } + ) + test.wait_for_events() + end ) test.register_message_test( @@ -433,78 +406,65 @@ test.register_message_test( } ) -test.register_message_test( +test.register_coroutine_test( "The lock reporting all codes have been deleted should be handled", - { + function() + test_init_lifecycle_event(mock_device) -- add a credential - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_ADDED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + }) + 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 } })) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, { state_change = true, visibility = { displayed = true } })) - }, + ) + test.wait_for_events() -- delete all credentials - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 0xFFFF - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 0xFFFF + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { state_change = true, visibility = { displayed = true } })) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { state_change = true, visibility = { displayed = true } })) - } - }, - { test_init = test_init_add_device } + ) + test.wait_for_events() + end ) test.register_coroutine_test( "Out of band get pin call should add credential if it doesn't exist (happens during reload all codes).", function() + test_init_lifecycle_event(mock_device) test.socket.zigbee:__queue_receive( { mock_device.id, @@ -531,12 +491,7 @@ test.register_coroutine_test( { state_change = true, visibility = { displayed = true } }) ) ) - end, - { - test_init = function() - test_init_add_device() - end - } + end ) test.run_registered_tests() From b36d8a8b49dcd4fbbee0c8bbc100a3e5e6f65647 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 4 May 2026 15:15:53 -0500 Subject: [PATCH 05/33] remove explicit state_change value from capability event --- drivers/SmartThings/zigbee-lock/src/init.lua | 12 ++++++------ .../SmartThings/zigbee-lock/src/samsungsds/init.lua | 12 ++++++------ .../src/test/test_zigbee_yale-new_capabilities.lua | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 0e02b8ec88..6388db425a 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -60,12 +60,12 @@ local alarm_handler = function(driver, device, zb_mess) end local function device_added(driver, device) - if device:supports_capability_by_id(LockCodes.ID) then - if device._provisioning_state == "TYPED" then -- only run migration for typed devices, as provisioned devices may be in the process of migrating and we don't want to interfere with that. - -- If a device is newly onboarded (typed), we set the migrated field to true so devices use lockCredentials/lockUsers from the start. - device:emit_event(LockCodes.migrated(true, { state_change = true, visibility = { displayed = true } })) - device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) - end + 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:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) end driver:inject_capability_command(device, { diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index ca9986ad16..48926ff1ef 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -64,12 +64,12 @@ local function load_device_state(device) end local device_added = function(self, device) - if device:supports_capability_by_id(capabilities.lockCodes.ID) then - if device._provisioning_state == "TYPED" then -- only run migration for typed devices, as provisioned devices may be in the process of migrating and we don't want to interfere with that. - -- If a device is newly onboarded (typed), we set the migrated field to true so devices use lockCredentials/lockUsers from the start. - device:emit_event(capabilities.lockCodes.migrated(true, { state_change = true, visibility = { displayed = true } })) - device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) - end + 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:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) end emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) device:emit_event(capabilities.battery.battery(100)) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua index 0f0f03d455..3ac6d09e86 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua @@ -35,7 +35,7 @@ local function test_init_default() test.mock_device.add_test_device(mock_device) 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, { state_change = true, visibility = { displayed = true } }))) + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read( From ee7c5fcfc5c062420a47ce30386bcdea8d5c5dc5 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Wed, 6 May 2026 17:23:20 -0500 Subject: [PATCH 06/33] update test names, add persisted datastore value for slga migration --- drivers/SmartThings/zigbee-lock/src/init.lua | 5 +- .../src/legacy-handlers/can_handle.lua | 7 +- .../zigbee-lock/src/legacy-handlers/init.lua | 3 + .../zigbee-lock/src/samsungsds/init.lua | 5 +- .../zigbee-lock/src/test/test_zigbee_lock.lua | 1386 +++++++---------- .../test_zigbee_lock_code_slga_migration.lua | 2 + .../src/test/test_zigbee_lock_legacy.lua | 942 +++++++++++ .../test_zigbee_lock_new_capabilities.lua | 614 -------- .../test_zigbee_yale-new_capabilities.lua | 497 ------ .../zigbee-lock/src/test/test_zigbee_yale.lua | 736 +++++---- .../src/test/test_zigbee_yale_legacy.lua | 449 ++++++ .../src/yale-fingerprint-lock/can_handle.lua | 9 +- .../zigbee-lock/src/zigbee_lock_utils.lua | 141 +- 13 files changed, 2402 insertions(+), 2394 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 6388db425a..689aa63d56 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -65,6 +65,7 @@ local function device_added(driver, device) -- 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 migration event in the datastore device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) end @@ -148,8 +149,8 @@ local do_configure = function(self, device) device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME, false) - if lock_codes_migrated then + local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + if slga_migrated then device.thread:call_with_delay(2, function(d) reload_all_codes(device) end) else -- Don't send a reload all codes if this is a part of migration diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua index a37b71e079..fb869ffe0d 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua @@ -2,10 +2,9 @@ -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device, ...) - local capabilities = require "st.capabilities" - local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, - capabilities.lockCodes.migrated.NAME, false) - if not lock_codes_migrated then + local lock_utils = require "zigbee_lock_utils" + local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + if not slga_migrated then local subdriver = require("legacy-handlers") return true, subdriver end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua index b167d7f75f..d66f5e583f 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -290,6 +290,8 @@ local name_slot = function(driver, device, command) end local migrate = function(driver, device, command) + local post_migration_lock_utils = require "zigbee_lock_utils" + local lock_users = {} local lock_credentials = {} local lock_codes = lock_utils.get_lock_codes(device) @@ -324,6 +326,7 @@ local migrate = function(driver, device, command) device:emit_event(LockUsers.users(lock_users, { visibility = { displayed = false } })) device:emit_event(LockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(post_migration_lock_utils.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore end local legacy_capabilities_driver = { diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index 48926ff1ef..79d4bfa5fa 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -54,8 +54,8 @@ local function emit_event_if_latest_state_missing(device, component, capability, end local function load_device_state(device) - local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME, false) - if lock_codes_migrated then + local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + if slga_migrated then lock_utils.reload_tables(device) else local legacy_lock_utils = require "legacy-handlers.legacy_lock_utils" @@ -69,6 +69,7 @@ local device_added = function(self, device) -- 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 migration event in the datastore device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) end emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua index 12f8331253..db6e13e8c0 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -- Mock out globals @@ -7,936 +7,610 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" -local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock -local Alarm = clusters.Alarms local capabilities = require "st.capabilities" -local DoorLockState = DoorLock.attributes.LockState -local OperationEventCode = DoorLock.types.OperationEventCode local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType -local ProgrammingEventCode = DoorLock.types.ProgramEventCode - -local json = require "dkjson" +local lock_utils = require "zigbee_lock_utils" +local test_credential_index = 1 +local test_credentials = {} +local test_users = {} local mock_device = test.mock_device.build_test_zigbee_device( - { profile = t_utils.get_profile_definition("base-lock.yml") } -) -zigbee_test_utils.prepare_zigbee_env_info() -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.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, - true) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) }) -end - -test.register_coroutine_test( - "Configure should configure all necessary attributes and begin reading codes", - function() - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, - 600, - 21600, - 1) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - DoorLock.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, - 0, - 3600, - 0) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - Alarm.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, - 0, - 21600, - 0) }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.wait_for_events() - - test.mock_time.advance_time(2) - expect_reload_all_codes_messages() - - end, { - min_api_version = 17 + profile = t_utils.get_profile_definition("base-lock.yml"), } ) -test.register_coroutine_test( - "Refresh should read expected attributes", - function() - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.capability:__queue_receive({mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }}) - - test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) - test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) - test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) - end, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Lock status reporting should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, - DoorLockState.LOCKED) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Battery percentage report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, - 55) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Lock operation event reporting should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, - DoorLock.client.commands.OperatingEventNotification.build_test_rx( - mock_device, - 0x02, - OperationEventCode.LOCK, - 0x0000, - "", - 0x0000, - "") } - }, - { - 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( - "Pin response reporting should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x02, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 set", - { data = { codeName = "Code 2" }, state_change = true })) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"} ), { visibility = { displayed = false } })) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Sending the lock command should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "lock", component = "main", command = "lock", args = {} } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, DoorLock.server.commands.LockDoor(mock_device) } - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Min lock code length report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 4) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false }})) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Max lock code length report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 4) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(4, { visibility = { displayed = false }})) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Max user code number report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, - 16) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false }})) - } - }, - { - min_api_version = 17 - } -) +zigbee_test_utils.prepare_zigbee_env_info() -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 - } -) +local function test_init_new_capabilities() + test_credential_index = 1 + test_credentials = {} + test_users = {} + test.mock_device.add_test_device(mock_device) +end -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 = "zigbee", - direction = "send", - message = { mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) } - } - }, - { - min_api_version = 17 - } -) +local function init_migration() + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( + mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report( + mock_device, 8) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.maxCodeLength(8, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported + :build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + 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(8, { 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({}, { 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.users({}, { 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.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") +end -test.register_coroutine_test( - "Deleting a user code should be handled", - function() - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", - { data = { codeName = "Code 1" }, state_change = true }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode( {["1"] = "Code 1"} ), { visibility = { displayed = false }}) - )) - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "deleteCode", args = { 1 } } }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, - true) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) - test.wait_for_events() - - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) - test.socket.zigbee:__queue_receive({ mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.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 - } -) +local function add_default_users() + local user_list = {} + for i = 1, 4 do + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "Guest" .. i, "guest" } + }, + }) + -- add to the user list that is now expected + table.insert(user_list, { userIndex = i, userType = "guest", userName = "Guest" .. i }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + user_list, + { 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 = i }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end +end -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, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) - test.socket.zigbee:__expect_send( - { +local function add_credential(user_index, credential_data) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { user_index, "guest", "pin", credential_data } + }, + }) + test.socket.zigbee:__expect_send( + { mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" + test_credential_index, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + credential_data ) - } - ) - test.wait_for_events() - - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { + } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - - test.wait_for_events() - - test.socket.zigbee:__queue_receive( - { + DoorLock.server.commands.GetPINCode(mock_device, test_credential_index) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { mock_device.id, DoorLock.client.commands.GetPINCodeResponse.build_test_rx( mock_device, - 0x01, + test_credential_index, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, - "1234" + credential_data ) - } - ) - 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.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }))) - end, - { - min_api_version = 17 - } -) - -local function init_code_slot(slot_number, name, device) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { slot_number, "1234", name } } }) - test.socket.zigbee:__expect_send( - { - device.id, - DoorLock.server.commands.SetPINCode(device, - slot_number, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" + } + ) + table.insert(test_credentials, + { userIndex = test_credential_index, credentialIndex = test_credential_index, credentialType = "pin" }) + 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 } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials(test_credentials, + { state_change = true, visibility = { displayed = true } }) ) - } - ) - test.wait_for_events() - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - device.id, - DoorLock.server.commands.GetPINCode(device, slot_number) - } - ) - test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - device, - slot_number, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" + ) + 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.socket.capability:__expect_send(device:generate_test_message("main", - capabilities.lockCodes.codeChanged(slot_number .. " set", { data = { codeName = name }, state_change = true })) - ) + ) + test.wait_for_events() + test_credential_index = test_credential_index + 1 end +test.set_test_init_function(test_init_new_capabilities) + test.register_coroutine_test( - "Setting a user code name should be handled", + "Add User command received and commandResult is success until totalUsersSupported reached", function() - init_code_slot(1, "initialName", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) - test.wait_for_events() - - 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.codeChanged("1 renamed", {state_change = true}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) - end, - { - min_api_version = 17 - } + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial max users + add_default_users() + + -- 5th addUser call - totalUsersSupported is passsed and now commandResult should be resourceExhausted + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end ) test.register_coroutine_test( - "Setting a user code name via setCode should be handled", - function() - init_code_slot(1, "initialName", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) - test.wait_for_events() + "Update User command reports a commandResult of success unless user index doesn't exist", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial users + add_default_users() - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "", "foo"} } }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) - end, - { - min_api_version = 17 - } -) + -- success + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = { "2", "ChangeUserName", "guest" } + }, + }) + + local users = { + { userIndex = 1, userName = "Guest1", userType = "guest" }, + { userIndex = 2, userName = "ChangeUserName", userType = "guest" }, + { userIndex = 3, userName = "Guest3", userType = "guest" }, + { userIndex = 4, userName = "Guest4", userType = "guest" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(users, { 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 = 2 }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) -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.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.zigbee:__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.zigbee:__expect_send({ - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 2, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "2345" - ) - }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 3, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "3456" - ) - }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) - }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 2) - }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 3) - }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 4) - }) - test.wait_for_events() - end, - { - min_api_version = 17 - } + -- failure - try updating non existent userIndex + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = { "6", "ChangeUserName", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end ) test.register_coroutine_test( - "Setting all user codes should result in a code set event for each", - 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.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.zigbee:__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.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234")}) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "2345")}) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 2) }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "3456")}) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 3) }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) }) - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) - test.wait_for_events() - end, - { - min_api_version = 17 - } -) + "Delete User command reports a commandResult of success unless user index doesn't exist", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial users + add_default_users() -test.register_message_test( - "Master code programming event should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x00, - ProgrammingEventCode.MASTER_CODE_CHANGED, - 0, - "1234", - DoorLockUserType.MASTER_USER, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) + -- success + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { "3" } + }, + }) + + local users = { + { userIndex = 1, userName = "Guest1", userType = "guest" }, + { userIndex = 2, userName = "Guest2", userType = "guest" }, + { userIndex = 4, userName = "Guest4", userType = "guest" }, } - }, - { - channel = "capability", - direction = "send", - message = 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.lockUsers.users(users, { 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 = 3 }, + { 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 = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_ADDED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "Code 1"}, state_change = true })) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } })) - } - }, - { - min_api_version = 17 - } + -- failure - try updating non existent userIndex + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { "3" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end ) + test.register_coroutine_test( - "The lock reporting a code has been deleted should be handled", + "addCredential command received and commandResult is success", function() - init_code_slot(1, "Code 1", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - 0x0000, - "data" - ) - } - ) - 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 - } + init_migration() + add_credential(0, "abc123") + end ) test.register_coroutine_test( - "The lock reporting that all codes have been deleted should be handled", + "updateCredential command received and commandResult is success", function() - init_code_slot(1, "Code 1", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) - init_code_slot(2, "Code 2", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2"}), { visibility = { displayed = false } }))) - init_code_slot(3, "Code 3", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2", ["3"] = "Code 3"}), { visibility = { displayed = false } }))) - - test.socket.zigbee:__queue_receive( - { + init_migration() + add_credential(0, "abc123") + + -- try to update the wrong credentialIndex (4) first and expect a failure + test.socket.capability:__queue_receive({ mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 0xFF, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - 0x0000, - "data" + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "4", "4", "pin", "abc123" } + }, + }) + 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.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 } }))) - test.wait_for_events() - end, - { - min_api_version = 17 - } + ) + test.wait_for_events() + + -- try to update the right credential + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "1", "1", "pin", "changedPin123" } + }, + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "changedPin123" + ) + } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "abc123" + ) + } + ) + 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.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() + end ) test.register_coroutine_test( - "The lock reporting unlock via code should include the code info in the report", + "deleteCredential command received and commandResult is success", function() - init_code_slot(1, "Code 1", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive( - { + init_migration() + add_credential(0, "abc123") + add_credential(0, "test123") + add_credential(0, "321test") + + -- try to delete credential with wrong index and expect a failure + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { "4", "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() + + -- try to delete credential with correct index + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { "1", "pin" } + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ mock_device.id, - DoorLock.client.commands.OperatingEventNotification.build_test_rx( + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( mock_device, - 0x00, -- 0 = keypad - OperationEventCode.UNLOCK, - 0x0001, - "1234", - 0x0000, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, "" ) - } - ) - 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( - "Lock state attribute reports (after the first) should be delayed if they come before event notifications ", - function() - init_code_slot(1, "Code 1", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.UNLOCKED)}) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lock.lock.unlocked() - ) - ) - test.mock_time.advance_time(2) - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.OperatingEventNotification.build_test_rx( - mock_device, - 0x00, -- 0 = keypad - OperationEventCode.UNLOCK, - 0x0001, - "1234", - 0x0000, - "" + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + { userIndex = 2, userType = "guest", userName = "Guest2" }, + { userIndex = 3, userType = "guest", userName = "Guest3" } + }, + { state_change = true, visibility = { displayed = true } } + ) + ) ) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) - ) - ) - test.mock_time.advance_time(2) - test.timer.__create_and_queue_test_time_advance_timer(2.5, "oneshot") - test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.LOCKED)}) - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.OperatingEventNotification.build_test_rx( - mock_device, - 0x00, -- 0 = keypad - OperationEventCode.LOCK, - 0x0001, - "1234", - 0x0000, - "" + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" } + }, + { state_change = true, visibility = { displayed = true } } + ) + ) ) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lock.lock.locked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) - ) - ) - test.mock_time.advance_time(2.5) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lock.lock.locked() - ) - ) - end, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Alarm code 0 should generate lock unknown event", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x00, DoorLock.ID) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Alarm code 1 should generate lock unknown event", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x01, DoorLock.ID) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) - } - }, - { - min_api_version = 17 - } -) - -test.register_message_test( - "Pin response for unoccupied slot with no existing code should generate unset event", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x05, - DoorLockUserStatus.OCCUPIED_DISABLED, - DoorLockUserType.UNRESTRICTED, - "" - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("5 unset", - { data = { codeName = "Code 5" }, state_change = true })) - } - }, - { - min_api_version = 17 - } + 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() + end ) test.register_coroutine_test( - "Pin response for already-set slot should use changed change type", + "deleteAllCredentials command received and commandResult is success", function() - init_code_slot(1, "Code 1", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) - test.wait_for_events() + init_migration() + add_credential(0, "abc123") + add_credential(0, "test123") + add_credential(0, "321test") - test.socket.zigbee:__queue_receive( - { + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {} + }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) + }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.client.commands.GetPINCodeResponse.build_test_rx( mock_device, 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, - "1234" + DoorLockUserStatus.AVAILABLE, + "" ) - } - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 changed", { data = { codeName = "Code 1" }, state_change = true }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) - end, - { - min_api_version = 17 - } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + { userIndex = 2, userType = "guest", userName = "Guest2" }, + { userIndex = 3, userType = "guest", userName = "Guest3" } + }, + { 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" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" } + }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + 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/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua index 2bfb0032ff..af8c8f20ce 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua @@ -9,6 +9,7 @@ local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" local DoorLock = clusters.DoorLock local capabilities = require "st.capabilities" +local lock_utils = require "zigbee_lock_utils" local json = require "st.json" @@ -62,6 +63,7 @@ test.register_coroutine_test( 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.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") end ) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua new file mode 100644 index 0000000000..12f8331253 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua @@ -0,0 +1,942 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local PowerConfiguration = clusters.PowerConfiguration +local DoorLock = clusters.DoorLock +local Alarm = clusters.Alarms +local capabilities = require "st.capabilities" + +local DoorLockState = DoorLock.attributes.LockState +local OperationEventCode = DoorLock.types.OperationEventCode +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode + +local json = require "dkjson" + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("base-lock.yml") } +) +zigbee_test_utils.prepare_zigbee_env_info() +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.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) }) +end + +test.register_coroutine_test( + "Configure should configure all necessary attributes and begin reading codes", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, + 600, + 21600, + 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + DoorLock.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, + 0, + 3600, + 0) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + Alarm.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, + 0, + 21600, + 0) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + test.mock_time.advance_time(2) + expect_reload_all_codes_messages() + + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should read expected attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }}) + + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Lock status reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, + DoorLockState.LOCKED) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, + 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Lock operation event reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x02, + OperationEventCode.LOCK, + 0x0000, + "", + 0x0000, + "") } + }, + { + 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( + "Pin response reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x02, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 set", + { data = { codeName = "Code 2" }, state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"} ), { visibility = { displayed = false } })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Sending the lock command should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "lock", component = "main", command = "lock", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, DoorLock.server.commands.LockDoor(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min lock code length report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 4) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max lock code length report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 4) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(4, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max user code number report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 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 = "zigbee", + direction = "send", + message = { mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) } + } + }, + { + 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(2, "oneshot") + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", + { data = { codeName = "Code 1" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode( {["1"] = "Code 1"} ), { visibility = { displayed = false }}) + )) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "deleteCode", args = { 1 } } }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.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( + "Setting a user code should result in the named code changed event firing", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +local function init_code_slot(slot_number, name, device) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { slot_number, "1234", name } } }) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.SetPINCode(device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.GetPINCode(device, slot_number) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCodes.codeChanged(slot_number .. " set", { data = { codeName = name }, state_change = true })) + ) +end + +test.register_coroutine_test( + "Setting a user code name should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + 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.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code name via setCode should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "", "foo"} } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) + 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.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.zigbee:__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.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "2345" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "3456" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 2) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 3) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 4) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting all user codes should result in a code set event for each", + 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.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.zigbee:__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.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "2345")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 2) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "3456")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 3) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Master code programming event should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x00, + ProgrammingEventCode.MASTER_CODE_CHANGED, + 0, + "1234", + DoorLockUserType.MASTER_USER, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + + { + 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 = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "Code 1"}, state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } })) + } + }, + { + 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.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + } + ) + 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) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + init_code_slot(2, "Code 2", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2"}), { visibility = { displayed = false } }))) + init_code_slot(3, "Code 3", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2", ["3"] = "Code 3"}), { visibility = { displayed = false } }))) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 0xFF, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + } + ) + + 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 } }))) + test.wait_for_events() + 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, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.UNLOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + 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( + "Lock state attribute reports (after the first) should be delayed if they come before event notifications ", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.UNLOCKED)}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked() + ) + ) + test.mock_time.advance_time(2) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.UNLOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + test.mock_time.advance_time(2) + test.timer.__create_and_queue_test_time_advance_timer(2.5, "oneshot") + test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.LOCKED)}) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.LOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + test.mock_time.advance_time(2.5) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked() + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Alarm code 0 should generate lock unknown event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x00, DoorLock.ID) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Alarm code 1 should generate lock unknown event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x01, DoorLock.ID) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Pin response for unoccupied slot with no existing code should generate unset event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x05, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("5 unset", + { data = { codeName = "Code 5" }, state_change = true })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot should use changed change type", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 changed", { data = { codeName = "Code 1" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua deleted file mode 100644 index 5f6eb6ce5c..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua +++ /dev/null @@ -1,614 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Mock out globals -local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local t_utils = require "integration_test.utils" - -local clusters = require "st.zigbee.zcl.clusters" -local DoorLock = clusters.DoorLock -local capabilities = require "st.capabilities" - -local DoorLockUserStatus = DoorLock.types.DrlkUserStatus -local DoorLockUserType = DoorLock.types.DrlkUserType - -local test_credential_index = 1 -local test_credentials = {} -local test_users = {} -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - } -) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init_new_capabilities() - test_credential_index = 1 - test_credentials = {} - test_users = {} - test.mock_device.add_test_device(mock_device) -end - -local function init_migration() - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( - mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report( - mock_device, 8) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.maxCodeLength(8, { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported - :build_test_attr_report(mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) - test.wait_for_events() - 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(8, { 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({}, { 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.users({}, { 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.lockCodes.migrated(true, { visibility = { displayed = false } }))) - test.wait_for_events() -end - -local function add_default_users() - local user_list = {} - for i = 1, 4 do - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "addUser", - args = { "Guest" .. i, "guest" } - }, - }) - -- add to the user list that is now expected - table.insert(user_list, { userIndex = i, userType = "guest", userName = "Guest" .. i }) - - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users( - user_list, - { 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 = i }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) - end -end - -local function add_credential(user_index, credential_data) - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "addCredential", - args = { user_index, "guest", "pin", credential_data } - }, - }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - test_credential_index, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - credential_data - ) - } - ) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, test_credential_index) - } - ) - test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - test_credential_index, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - credential_data - ) - } - ) - table.insert(test_credentials, - { userIndex = test_credential_index, credentialIndex = test_credential_index, credentialType = "pin" }) - 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 } }) - ) - ) - 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 - -test.set_test_init_function(test_init_new_capabilities) - -test.register_coroutine_test( - "Add User command received and commandResult is success until totalUsersSupported reached", - function() - -- make sure we have migrated and are using the new capabilities - init_migration() - -- create initial max users - add_default_users() - - -- 5th addUser call - totalUsersSupported is passsed and now commandResult should be resourceExhausted - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "addUser", - args = { "TestUser", "guest" } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "addUser", statusCode = "resourceExhausted" }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) - end -) - -test.register_coroutine_test( - "Update User command reports a commandResult of success unless user index doesn't exist", - function() - -- make sure we have migrated and are using the new capabilities - init_migration() - -- create initial users - add_default_users() - - -- success - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "updateUser", - args = { "2", "ChangeUserName", "guest" } - }, - }) - - local users = { - { userIndex = 1, userName = "Guest1", userType = "guest" }, - { userIndex = 2, userName = "ChangeUserName", userType = "guest" }, - { userIndex = 3, userName = "Guest3", userType = "guest" }, - { userIndex = 4, userName = "Guest4", userType = "guest" }, - } - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users(users, { 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 = 2 }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) - - -- failure - try updating non existent userIndex - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "updateUser", - args = { "6", "ChangeUserName", "guest" } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "failure" }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) - end -) - -test.register_coroutine_test( - "Delete User command reports a commandResult of success unless user index doesn't exist", - function() - -- make sure we have migrated and are using the new capabilities - init_migration() - -- create initial users - add_default_users() - - -- success - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "deleteUser", - args = { "3" } - }, - }) - - local users = { - { userIndex = 1, userName = "Guest1", userType = "guest" }, - { userIndex = 2, userName = "Guest2", userType = "guest" }, - { userIndex = 4, userName = "Guest4", userType = "guest" }, - } - - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users(users, { 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 = 3 }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) - - -- failure - try updating non existent userIndex - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "deleteUser", - args = { "3" } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "deleteUser", statusCode = "failure" }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) - end -) - - -test.register_coroutine_test( - "addCredential command received and commandResult is success", - function() - init_migration() - add_credential(0, "abc123") - end -) - -test.register_coroutine_test( - "updateCredential command received and commandResult is success", - function() - init_migration() - add_credential(0, "abc123") - - -- try to update the wrong credentialIndex (4) first and expect a failure - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "updateCredential", - args = { "4", "4", "pin", "abc123" } - }, - }) - 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() - - -- try to update the right credential - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "updateCredential", - args = { "1", "1", "pin", "changedPin123" } - }, - }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "changedPin123" - ) - } - ) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "abc123" - ) - } - ) - 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.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() - end -) - -test.register_coroutine_test( - "deleteCredential command received and commandResult is success", - function() - init_migration() - add_credential(0, "abc123") - add_credential(0, "test123") - add_credential(0, "321test") - - -- try to delete credential with wrong index and expect a failure - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "deleteCredential", - args = { "4", "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() - - -- try to delete credential with correct index - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "deleteCredential", - args = { "1", "pin" } - }, - }) - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) - }) - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - "" - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users( - { - { userIndex = 2, userType = "guest", userName = "Guest2" }, - { userIndex = 3, userType = "guest", userName = "Guest3" } - }, - { 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" }, - { userIndex = 3, credentialIndex = 3, 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() - end -) - -test.register_coroutine_test( - "deleteAllCredentials command received and commandResult is success", - function() - init_migration() - add_credential(0, "abc123") - add_credential(0, "test123") - add_credential(0, "321test") - - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "deleteAllCredentials", - args = {} - }, - }) - - test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) - }) - - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) - }) - - test.wait_for_events() - test.mock_time.advance_time(2) - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - "" - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users( - { - { userIndex = 2, userType = "guest", userName = "Guest2" }, - { userIndex = 3, userType = "guest", userName = "Guest3" } - }, - { 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" }, - { userIndex = 3, credentialIndex = 3, credentialType = "pin" } - }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) - 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/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua deleted file mode 100644 index 3ac6d09e86..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-new_capabilities.lua +++ /dev/null @@ -1,497 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Mock out globals -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" - -local PowerConfiguration = clusters.PowerConfiguration -local Alarm = clusters.Alarms - -local DoorLock = clusters.DoorLock -local DoorLockUserStatus = DoorLock.types.DrlkUserStatus -local DoorLockUserType = DoorLock.types.DrlkUserType -local ProgrammingEventCode = DoorLock.types.ProgramEventCode - -test.disable_startup_messages() - - -local mock_device = test.mock_device.build_test_zigbee_device({ - profile = t_utils.get_profile_definition("base-lock.yml"), - _provisioning_state = "TYPED", - zigbee_endpoints = { - [1] = { id = 1, manufacturer = "Yale", server_clusters = { 0x0001 } } - } -}) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init_default() - test.disable_startup_messages() - test.mock_device.add_test_device(mock_device) - 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.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read( - mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) -end - -local function test_init_lifecycle_event(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init"}) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( - mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported - :build_test_attr_report(mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) - test.wait_for_events() -end - -test.set_test_init_function(test_init_default) - -local expect_reload_all_codes_messages = function() - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, - true) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfTotalUsersSupported:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) -end - -test.register_coroutine_test( - "Configure should configure all necessary attributes and begin reading codes", - function() - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining - :configure_reporting(mock_device, - 600, - 21600, - 1) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - DoorLock.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, - 0, - 3600, - 0) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - Alarm.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, - 0, - 21600, - 0) }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.wait_for_events() - - test.mock_time.advance_time(2) - expect_reload_all_codes_messages() - end -) - -test.register_coroutine_test( - "Adding a credential should succeed and report users, credentials, and command result.", - function() - test_init_lifecycle_event(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - test.wait_for_events() - - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { 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( - "Updating a credential should succeed and report users, credentials, and command result.", - function() - test_init_lifecycle_event(mock_device) - -- add credential first - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - test.wait_for_events() - - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { 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 } } - ) - ) - ) - - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.wait_for_events() - - -- update the credential - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "updateCredential", - args = { "1", "1", "pin", "changedPin123" } - }, - }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "changedPin123" - ) - } - ) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "abc123" - ) - } - ) - 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.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() - end -) - -test.register_coroutine_test( - "The lock reporting a single code has been set and then deleted should be handled", - function() - test_init_lifecycle_event(mock_device) - -- add credential - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_ADDED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) - }) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { state_change = true, visibility = { displayed = true } })) - ) - test.wait_for_events() - - -- delete the credential - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - 0x0000, - "data" - ) - }) - 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_message_test( - "The lock reporting master code changed", - { - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.MASTER_CODE_CHANGED - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCredentials.commandResult({ commandName = "updateCredential", statusCode = "success" }, - { state_change = true, visibility = { displayed = true } })) - } - } -) - -test.register_coroutine_test( - "The lock reporting all codes have been deleted should be handled", - function() - test_init_lifecycle_event(mock_device) - -- add a credential - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_ADDED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) - }) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { state_change = true, visibility = { displayed = true } })) - ) - test.wait_for_events() - - -- delete all credentials - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 0xFFFF - ) - }) - 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( - "Out of band get pin call should add credential if it doesn't exist (happens during reload all codes).", - function() - test_init_lifecycle_event(mock_device) - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { state_change = true, visibility = { displayed = true } }) - ) - ) - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua index 95d496e786..3ac6d09e86 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -- Mock out globals @@ -8,7 +8,6 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" -local json = require "dkjson" local PowerConfiguration = clusters.PowerConfiguration local Alarm = clusters.Alarms @@ -16,41 +15,62 @@ local Alarm = clusters.Alarms local DoorLock = clusters.DoorLock local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode + +test.disable_startup_messages() + local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), - zigbee_endpoints ={ - [1] = {id = 1, manufacturer ="Yale", server_clusters = {0x0001}} + _provisioning_state = "TYPED", + zigbee_endpoints = { + [1] = { id = 1, manufacturer = "Yale", server_clusters = { 0x0001 } } } }) zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device)end -test.set_test_init_function(test_init) +local function test_init_default() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + 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.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read( + mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) +end + +local function test_init_lifecycle_event(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init"}) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( + mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported + :build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.wait_for_events() +end + +test.set_test_init_function(test_init_default) local expect_reload_all_codes_messages = function() test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, - true) }) + true) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false }}))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfTotalUsersSupported:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) end -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_coroutine_test( "Configure should configure all necessary attributes and begin reading codes", function() @@ -60,390 +80,418 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, - 600, - 21600, - 1) }) + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining + :configure_reporting(mock_device, + 600, + 21600, + 1) }) test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - DoorLock.ID) }) + zigbee_test_utils.mock_hub_eui, + DoorLock.ID) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, - 0, - 3600, - 0) }) + 0, + 3600, + 0) }) test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - Alarm.ID) }) + zigbee_test_utils.mock_hub_eui, + Alarm.ID) }) test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, - 0, - 21600, - 0) }) + 0, + 21600, + 0) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) test.wait_for_events() test.mock_time.advance_time(2) expect_reload_all_codes_messages() - - end, - { - min_api_version = 17 - } + end ) 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, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - test.wait_for_events() + "Adding a credential should succeed and report users, credentials, and command result.", + function() + test_init_lifecycle_event(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { state_change = true, visibility = { displayed = true } }) ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false }}))) - end, - { - min_api_version = 17 - } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + end ) test.register_coroutine_test( - "Setting the master code should result in the correct user type being used", - function() - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 0, "1234", "test" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 0, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.MASTER_USER, - "1234" - ) - } - ) - test.wait_for_events() + "Updating a credential should succeed and report users, credentials, and command result.", + function() + test_init_lifecycle_event(mock_device) + -- add credential first + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 0) - } - ) - test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x00, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.MASTER_USER, - "1234" - ) - } + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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.lockCodes.codeChanged("0 set", { data = { codeName = "test"}, state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { state_change = true, visibility = { displayed = true } }) ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["0"] = "test"}), { visibility = { displayed = false }}))) - end, - { - min_api_version = 17 - } -) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = true } } + ) + ) + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.wait_for_events() -test.register_message_test( - "Pin response reporting should be handled when the Lock User status is disabled", - { + -- update the credential + test.socket.capability:__queue_receive({ + mock_device.id, { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - nil, - DoorLockUserStatus.OCCUPIED_DISABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "1", "1", "pin", "changedPin123" } }, + }) + test.socket.zigbee:__expect_send( { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("0 unset", - { data = { codeName = "Code 0" }, state_change = true })) - } - }, - { - min_api_version = 17 - } -) - -local function init_code_slot(slot_number, name, device) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { slot_number, "1234", name } } }) - test.socket.zigbee:__expect_send( - { - device.id, - DoorLock.server.commands.SetPINCode(device, - slot_number, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "changedPin123" ) } - ) - test.wait_for_events() - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( { - device.id, - DoorLock.server.commands.GetPINCode(device, slot_number) + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) } - ) - test.wait_for_events() - test.socket.zigbee:__queue_receive( + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( { - device.id, + mock_device.id, DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - device, - slot_number, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "abc123" ) } - ) - test.socket.capability:__expect_send(device:generate_test_message("main", - capabilities.lockCodes.codeChanged(slot_number .. " set", { data = { codeName = name }, state_change = true })) - ) -end - -test.register_coroutine_test( - "Setting a user code name should be handled", - function() - init_code_slot(1, "initialName", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) - test.wait_for_events() - - 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.codeChanged("1 renamed", {state_change = true}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) - end, - { - min_api_version = 17 - } + ) + 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.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() + end ) test.register_coroutine_test( - "Setting a user code and getting an incorrect code in response should indicate failure", + "The lock reporting a single code has been set and then deleted should be handled", function() - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } + test_init_lifecycle_event(mock_device) + -- add credential + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + }) + 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.wait_for_events() - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { state_change = true, visibility = { displayed = true } })) ) test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "5678" - ) - } - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) - end, - { - min_api_version = 17 - } -) -test.register_coroutine_test( - "Setting a user code name via setCode should be handled", - function() - init_code_slot(1, "initialName", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + -- delete the credential + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + }) + 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.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "", "foo"} } }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) - end, +test.register_message_test( + "The lock reporting master code changed", { - min_api_version = 17 + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.MASTER_CODE_CHANGED + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult({ commandName = "updateCredential", statusCode = "success" }, + { state_change = true, visibility = { displayed = true } })) + } } ) test.register_coroutine_test( - "Setting a user code for a slot that is empty should indicate failure and unset", - function() - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } + "The lock reporting all codes have been deleted should be handled", + function() + test_init_lifecycle_event(mock_device) + -- add a credential + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" ) - test.wait_for_events() - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) - test.wait_for_events() + }) + 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, + { state_change = true, visibility = { displayed = true } })) + ) + test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 1, - DoorLockUserStatus.OCCUPIED_DISABLED, - DoorLockUserType.UNRESTRICTED, - "" - ) - } + -- delete all credentials + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 0xFFFF ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.codeChanged("1 is not set", { state_change = true }))) - end, - { - min_api_version = 17 - } + }) + 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( - "Pin response for already-set slot without pending operation should use changed change type", - function() - init_code_slot(1, "initialName", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) - test.wait_for_events() - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } + "Out of band get pin call should add credential if it doesn't exist (happens during reload all codes).", + function() + test_init_lifecycle_event(mock_device) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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.lockCodes.codeChanged("1 changed", { data = { codeName = "initialName" }, state_change = true }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Pin response for already-set slot that is now empty should delete the code", - function() - init_code_slot(1, "initialName", mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) - test.wait_for_events() - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 1, - DoorLockUserStatus.OCCUPIED_DISABLED, - DoorLockUserType.UNRESTRICTED, - "" - ) - } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({ { credentialIndex = 1, credentialType = "pin", 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 = "initialName" }, 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 - } + ) + end ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua new file mode 100644 index 0000000000..95d496e786 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua @@ -0,0 +1,449 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local json = require "dkjson" + +local PowerConfiguration = clusters.PowerConfiguration +local Alarm = clusters.Alarms + +local DoorLock = clusters.DoorLock +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints ={ + [1] = {id = 1, manufacturer ="Yale", server_clusters = {0x0001}} + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +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.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false }}))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) +end + +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_coroutine_test( + "Configure should configure all necessary attributes and begin reading codes", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, + 600, + 21600, + 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + DoorLock.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, + 0, + 3600, + 0) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + Alarm.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, + 0, + 21600, + 0) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + test.mock_time.advance_time(2) + expect_reload_all_codes_messages() + + end, + { + min_api_version = 17 + } +) + +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, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + 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.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting the master code should result in the correct user type being used", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 0, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 0, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.MASTER_USER, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 0) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x00, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.MASTER_USER, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "test"}, state_change = true })) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["0"] = "test"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Pin response reporting should be handled when the Lock User status is disabled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + nil, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("0 unset", + { data = { codeName = "Code 0" }, state_change = true })) + } + }, + { + min_api_version = 17 + } +) + +local function init_code_slot(slot_number, name, device) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { slot_number, "1234", name } } }) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.SetPINCode(device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.GetPINCode(device, slot_number) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCodes.codeChanged(slot_number .. " set", { data = { codeName = name }, state_change = true })) + ) +end + +test.register_coroutine_test( + "Setting a user code name should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + 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.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code and getting an incorrect code in response should indicate failure", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "5678" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code name via setCode should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "", "foo"} } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code for a slot that is empty should indicate failure and unset", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 is not set", { state_change = true }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot without pending operation should use changed change type", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 changed", { data = { codeName = "initialName" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot that is now empty should delete the code", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "initialName" }, 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.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua index 84a9918622..be2197030c 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua @@ -2,10 +2,9 @@ -- Licensed under the Apache License, Version 2.0 local yale_fingerprint_lock_models = function(opts, driver, device) - local capabilities = require "st.capabilities" - local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, - capabilities.lockCodes.migrated.NAME, false) - if not lock_codes_migrated then return false end + local lock_utils = require "zigbee_lock_utils" + local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + if not slga_migrated then return false end local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then @@ -15,4 +14,4 @@ local yale_fingerprint_lock_models = function(opts, driver, device) return false end -return yale_fingerprint_lock_models \ No newline at end of file +return yale_fingerprint_lock_models diff --git a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua index 6000f6a804..7d96477574 100644 --- a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua @@ -5,7 +5,7 @@ local capabilities = require "st.capabilities" local utils = require "st.utils" local INITIAL_INDEX = 1 -local new_lock_utils = { +local lock_utils = { -- Constants ADD_CREDENTIAL = "addCredential", ADD_USER = "addUser", @@ -32,31 +32,32 @@ local new_lock_utils = { UPDATE_USER = "updateUser", USER_INDEX = "userIndex", USER_NAME = "userName", - USER_TYPE = "userType" + USER_TYPE = "userType", + SLGA_MIGRATED = "slgaMigrated" } -new_lock_utils.DELETE_ALL_USERS = 0xFF +lock_utils.DELETE_ALL_USERS = 0xFF -- check if we are currently busy performing a task. -- if we aren't then set as busy. -new_lock_utils.busy_check_and_set = function (device, command, override_busy_check) +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(new_lock_utils.BUSY) or false + 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(new_lock_utils.COMMAND_NAME, command) - device:set_field(new_lock_utils.BUSY, c_time) + device:set_field(lock_utils.COMMAND_NAME, command) + device:set_field(lock_utils.BUSY, c_time) return false else local command_result_info = { commandName = command.name, - statusCode = new_lock_utils.STATUS_BUSY + statusCode = lock_utils.STATUS_BUSY } - if command.type == new_lock_utils.LOCK_USERS then + if command.type == lock_utils.LOCK_USERS then device:emit_event(capabilities.lockUsers.commandResult( command_result_info, { state_change = true, visibility = { displayed = true } } )) @@ -69,18 +70,18 @@ new_lock_utils.busy_check_and_set = function (device, command, override_busy_che end end -new_lock_utils.clear_busy_state = function(device, status, override_busy_check) +lock_utils.clear_busy_state = function(device, status, override_busy_check) if override_busy_check then return end - local command = device:get_field(new_lock_utils.COMMAND_NAME) - local active_credential = device:get_field(new_lock_utils.ACTIVE_CREDENTIAL) + local command = device:get_field(lock_utils.COMMAND_NAME) + 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 == new_lock_utils.LOCK_USERS then + 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 @@ -100,35 +101,35 @@ new_lock_utils.clear_busy_state = function(device, status, override_busy_check) end end - device:set_field(new_lock_utils.ACTIVE_CREDENTIAL, nil) - device:set_field(new_lock_utils.COMMAND_NAME, nil) - device:set_field(new_lock_utils.BUSY, false) + device:set_field(lock_utils.ACTIVE_CREDENTIAL, nil) + device:set_field(lock_utils.COMMAND_NAME, nil) + device:set_field(lock_utils.BUSY, false) end -new_lock_utils.reload_tables = function(device) +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(new_lock_utils.LOCK_USERS, users) + device:set_field(lock_utils.LOCK_USERS, users) end if next(credentials) ~= nil then - device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) end - device:set_field(new_lock_utils.TABLES_LOADED, true) + device:set_field(lock_utils.TABLES_LOADED, true) end -new_lock_utils.get_users = function(device) - if not device:get_field(new_lock_utils.TABLES_LOADED) then - new_lock_utils.reload_tables(device) +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(new_lock_utils.LOCK_USERS)) + local users = utils.deep_copy(device:get_field(lock_utils.LOCK_USERS)) return users ~= nil and users or {} end -new_lock_utils.get_user = function(device, user_index) - for _, user in pairs(new_lock_utils.get_users(device)) do +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 @@ -137,10 +138,10 @@ new_lock_utils.get_user = function(device, user_index) return nil end -new_lock_utils.get_available_user_index = function(device) +lock_utils.get_available_user_index = function(device) local max = device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME, 0) - local current_users = new_lock_utils.get_users(device) + local current_users = lock_utils.get_users(device) local available_index = nil local used_index = {} for _, user in pairs(current_users) do @@ -159,17 +160,17 @@ new_lock_utils.get_available_user_index = function(device) return available_index end -new_lock_utils.get_credentials = function(device) - if not device:get_field(new_lock_utils.TABLES_LOADED) then - new_lock_utils.reload_tables(device) +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(new_lock_utils.LOCK_CREDENTIALS)) + local credentials = utils.deep_copy(device:get_field(lock_utils.LOCK_CREDENTIALS)) return credentials ~= nil and credentials or {} end -new_lock_utils.get_credential = function(device, credential_index) - for _, credential in pairs(new_lock_utils.get_credentials(device)) do +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 @@ -177,8 +178,8 @@ new_lock_utils.get_credential = function(device, credential_index) return nil end -new_lock_utils.get_credential_by_user_index = function(device, user_index) - for _, credential in pairs(new_lock_utils.get_credentials(device)) do +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 @@ -187,10 +188,10 @@ new_lock_utils.get_credential_by_user_index = function(device, user_index) return nil end -new_lock_utils.get_available_credential_index = function(device) +lock_utils.get_available_credential_index = function(device) local max = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME, 0) - local current_credentials = new_lock_utils.get_credentials(device) + local current_credentials = lock_utils.get_credentials(device) local available_index = nil local used_index = {} for _, credential in pairs(current_credentials) do @@ -209,53 +210,53 @@ new_lock_utils.get_available_credential_index = function(device) return available_index end -new_lock_utils.create_user = function(device, user_name, user_type, user_index) +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 = new_lock_utils.get_users(device) + local current_users = lock_utils.get_users(device) table.insert(current_users, { userIndex = user_index, userType = user_type, userName = user_name }) - device:set_field(new_lock_utils.LOCK_USERS, current_users, { persist = true }) + device:set_field(lock_utils.LOCK_USERS, current_users, { persist = true }) end -new_lock_utils.delete_user = function(device, user_index) - local current_users = new_lock_utils.get_users(device) - local status_code = new_lock_utils.STATUS_FAILURE +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(new_lock_utils.LOCK_USERS, current_users) - status_code = new_lock_utils.STATUS_SUCCESS + device:set_field(lock_utils.LOCK_USERS, current_users) + status_code = lock_utils.STATUS_SUCCESS break end end return status_code end -new_lock_utils.add_credential = function(device, user_index, credential_type, credential_index) - local credentials = new_lock_utils.get_credentials(device) +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(new_lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) - return new_lock_utils.STATUS_SUCCESS + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) + return lock_utils.STATUS_SUCCESS end -new_lock_utils.delete_credential = function(device, credential_index) - local credentials = new_lock_utils.get_credentials(device) - local status_code = new_lock_utils.STATUS_FAILURE +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 - new_lock_utils.delete_user(device, credential.userIndex) + 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(new_lock_utils.LOCK_CREDENTIALS, credentials) - status_code = new_lock_utils.STATUS_SUCCESS + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) + status_code = lock_utils.STATUS_SUCCESS break end end @@ -263,16 +264,16 @@ new_lock_utils.delete_credential = function(device, credential_index) return status_code end -new_lock_utils.update_credential = function(device, credential_index, user_index, credential_type) - local credentials = new_lock_utils.get_credentials(device) - local status_code = new_lock_utils.STATUS_FAILURE +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(new_lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) - status_code = new_lock_utils.STATUS_SUCCESS + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) + status_code = lock_utils.STATUS_SUCCESS break end end @@ -280,7 +281,7 @@ new_lock_utils.update_credential = function(device, credential_index, user_index end -- emit_event doesn't like having `nil` values in the table. Remove any if they are present. -new_lock_utils.prep_table = function(data) +lock_utils.prep_table = function(data) local clean_table = {} for _, value in pairs(data) do if value ~= nil then @@ -290,19 +291,19 @@ new_lock_utils.prep_table = function(data) return clean_table end -new_lock_utils.send_events = function(device, type) - if type == nil or type == new_lock_utils.LOCK_USERS then - local current_users = new_lock_utils.prep_table(new_lock_utils.get_users(device)) - device:set_field(new_lock_utils.LOCK_USERS, current_users, { persist = true }) +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:set_field(lock_utils.LOCK_USERS, current_users, { persist = true }) device:emit_event(capabilities.lockUsers.users(current_users, {state_change = true, visibility = { displayed = true } })) end - if type == nil or type == new_lock_utils.LOCK_CREDENTIALS then - local credentials = new_lock_utils.prep_table(new_lock_utils.get_credentials(device)) - device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) + if type == nil or type == lock_utils.LOCK_CREDENTIALS then + local credentials = lock_utils.prep_table(lock_utils.get_credentials(device)) + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) device:emit_event(capabilities.lockCredentials.credentials(credentials, { state_change = true, visibility = { displayed = true } })) end end -return new_lock_utils +return lock_utils From d74f6a534292f0c0502d9a8fc739c3665575cff7 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 8 May 2026 10:54:41 -0500 Subject: [PATCH 07/33] rename COMMAND field since it's not just the name --- drivers/SmartThings/zigbee-lock/src/init.lua | 4 ++-- drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 689aa63d56..41a336a73c 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -405,7 +405,7 @@ end local get_pin_response_handler = function(driver, device, zb_mess) local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) - local command = device:get_field(lock_utils.COMMAND_NAME) + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) local status = lock_utils.STATUS_SUCCESS local emit_event = false @@ -480,7 +480,7 @@ end local programming_event_handler = function(driver, device, zb_mess) local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) - local command = device:get_field(lock_utils.COMMAND_NAME) + local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) local emit_events = false if credential_index >= 256 then -- Index is incorrectly written, attempt to shift it to get an actual value diff --git a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua index 7d96477574..8658711a5d 100644 --- a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua @@ -10,7 +10,7 @@ local lock_utils = { ADD_CREDENTIAL = "addCredential", ADD_USER = "addUser", BUSY = "busy", - COMMAND_NAME = "commandName", + COMMAND_IN_PROGRESS = "commandInProgress", CREDENTIAL_TYPE = "pin", CHECKING_CODE = "checkingCode", DELETE_ALL_CREDENTIALS = "deleteAllCredentials", @@ -49,7 +49,7 @@ lock_utils.busy_check_and_set = function (device, command, override_busy_check) 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_NAME, command) + device:set_field(lock_utils.COMMAND_IN_PROGRESS, command) device:set_field(lock_utils.BUSY, c_time) return false else @@ -74,7 +74,7 @@ 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_NAME) + 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 = { @@ -102,7 +102,7 @@ lock_utils.clear_busy_state = function(device, status, override_busy_check) end device:set_field(lock_utils.ACTIVE_CREDENTIAL, nil) - device:set_field(lock_utils.COMMAND_NAME, nil) + device:set_field(lock_utils.COMMAND_IN_PROGRESS, nil) device:set_field(lock_utils.BUSY, false) end From 49d5ce18eefc67685087d822a54a14c868dde823 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 8 May 2026 13:47:47 -0500 Subject: [PATCH 08/33] start to update field names --- .../zigbee-lock/src/zigbee_lock_utils.lua | 85 +++++++++++-------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua index 8658711a5d..e19a0ae6c7 100644 --- a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua @@ -5,38 +5,51 @@ local capabilities = require "st.capabilities" local utils = require "st.utils" local INITIAL_INDEX = 1 -local lock_utils = { - -- Constants - ADD_CREDENTIAL = "addCredential", - ADD_USER = "addUser", +local lock_utils = {} + +lock_utils.DRIVER_STATE = { 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 = "activeCredential", - 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" + LOCK_USERS = "lockUsers", + LOCK_CREDENTIALS = "lockCredentials", + SLGA_MIGRATED = "slgaMigrated", + CURRENT_CREDENTIAL = "currentCredential", + CURRENT_CREDENTIAL_INDEX = "currentCredentialIndex", +} + +lock_utils.USER_DATA = { + INDEX = "userIndex", + NAME = "userName", + TYPE = "userType" +} + +lock_utils.PIN_STATUS = { + SUCCESS = "success", + FAILURE = "failure", + DUPLICATE = "duplicate", + OCCUPIED = "occupied", + INVALID_COMMAND = "invalidCommand", + RESOURCE_EXHAUSTED = "resourceExhausted", + BUSY = "busy" +} + +lock_utils.LOCK_CREDENTIALS_CMD = { + ADD = "addCredential", + UPDATE = "updateCredential", + DELETE = "deleteCredential", + DELETE_ALL = "deleteAllCredentials" +} + +lock_utils.LOCK_USERS_CMD = { + ADD = "addUser", + UPDATE = "updateUser", + DELETE = "deleteUser", + DELETE_ALL = "deleteAllUsers" } lock_utils.DELETE_ALL_USERS = 0xFF +lock_utils.CRED_TYPE_PIN = "pin" -- check if we are currently busy performing a task. -- if we aren't then set as busy. @@ -46,22 +59,22 @@ lock_utils.busy_check_and_set = function (device, command, override_busy_check) return false end local c_time = os.time() - local busy_state = device:get_field(lock_utils.BUSY) or false + local busy_state = device:get_field(lock_utils.DRIVER_STATE.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) + device:set_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS, command) + device:set_field(lock_utils.DRIVER_STATE.BUSY, c_time) return false else local command_result_info = { commandName = command.name, - statusCode = lock_utils.STATUS_BUSY + statusCode = lock_utils.DRIVER_STATE.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 + elseif command.type == lock_utils.LOCK_CREDENTIALS then device:emit_event(capabilities.lockCredentials.commandResult( command_result_info, { state_change = true, visibility = { displayed = true } } )) @@ -74,8 +87,8 @@ 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) + local command = device:get_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS) + local active_credential = device:get_field(lock_utils.DRIVER_STATE.MOUNTED_CREDENTIAL) if command ~= nil then local command_result_info = { commandName = command.name, @@ -101,9 +114,9 @@ lock_utils.clear_busy_state = function(device, status, override_busy_check) 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) + device:set_field(lock_utils.DRIVER_STATE.MOUNTED_CREDENTIAL, nil) + device:set_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(lock_utils.DRIVER_STATE.BUSY, false) end lock_utils.reload_tables = function(device) From b549cb84ce242544c2adf350ff291c17db7cc37e Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 8 May 2026 17:09:43 -0500 Subject: [PATCH 09/33] a little firther, will likely have to revert --- drivers/SmartThings/zigbee-lock/src/init.lua | 157 +++++----- .../zigbee-lock/src/lock_tables.lua | 183 +++++++++++ .../zigbee-lock/src/zigbee_lock_utils.lua | 296 ++++++------------ 3 files changed, 358 insertions(+), 278 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_tables.lua diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 41a336a73c..c95b9186b6 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -41,7 +41,7 @@ local refresh = function(driver, device, cmd) -- we can't determine from fingerprints if devices support lock codes, so -- here in the driver we'll do a check once to see if the device responds here -- and if it does, we'll switch it to a profile with lock codes - if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(legacy_lock_utils.CHECKED_CODE_SUPPORT) then + if not device:supports_capability(LockCodes) and not device:get_field(legacy_lock_utils.CHECKED_CODE_SUPPORT) then device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) -- we won't make this value persist because it's not that important device:set_field(legacy_lock_utils.CHECKED_CODE_SUPPORT, true) @@ -60,7 +60,7 @@ local alarm_handler = function(driver, device, zb_mess) end local function device_added(driver, device) - if device:supports_capability_by_id(capabilities.lockCodes.ID) and device._provisioning_state == "TYPED" then + if device:supports_capability(LockCodes) and device._provisioning_state == "TYPED" then -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, -- and should be migrated manually by the user. @@ -110,9 +110,11 @@ end -- Default (post-migration) handlers -- -local reload_all_codes = function(device) - -- starts at first user code index then iterates through all lock codes as they come in +local function reload_all_codes(device) + -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) end @@ -122,13 +124,13 @@ local reload_all_codes = function(device) if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) end - if (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfTotalUsersSupported:read(device)) - end + + -- TODO: check what this is doing if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 1) end + -- TODO: check what this is doing device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) end @@ -169,44 +171,46 @@ local do_configure = function(self, device) end 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 + if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.ADD) 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 + local status + + local next_available_index = lock_utils.get_next_available_user_index(device) + if next_available_index == nil then + status = lock_utils.PIN_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) + device:set_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL, { userIndex = next_available_index}) + lock_utils.create_user(device, command.args.userName, command.args.userType, next_available_index) + status = lock_utils.PIN_STATUS.SUCCESS end - if status == lock_utils.STATUS_SUCCESS then + if status == lock_utils.PIN_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 +local function update_user_handler(driver, device, command) + if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.UPDATE) then return end local user_name = command.args.userName local user_type = command.args.userType - local user_index = tonumber(command.args.userIndex) + local user_index = command.args.userIndex local current_users = lock_utils.get_users(device) - local status = lock_utils.STATUS_FAILURE + local status = lock_utils.PIN_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}) + device:set_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL, { userIndex = user_index}) user.userName = user_name user.userType = user_type - device:set_field(lock_utils.LOCK_USERS, current_users, { persist = true }) + device:set_field(lock_utils.LOCK_USERS, current_users) lock_utils.send_events(device, lock_utils.LOCK_USERS) - status = lock_utils.STATUS_SUCCESS + status = lock_utils.PIN_STATUS.SUCCESS break end end @@ -214,16 +218,15 @@ local update_user_handler = function(driver, device, command) 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 +local function delete_user_handler(driver, device, command) + if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.DELETE) then return end - local status = lock_utils.STATUS_SUCCESS - local user_index = tonumber(command.args.userIndex) + local status = lock_utils.PIN_STATUS.SUCCESS + local user_index = 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 + device:set_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL, { userIndex = user_index }) + local associated_credential = lock_utils.get_credential_by_user_index(device, user_index) if associated_credential ~= nil then @@ -238,19 +241,19 @@ local delete_user_handler = function(driver, device, command) 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) + lock_utils.clear_busy_state(device, status) end else - status = lock_utils.STATUS_FAILURE - lock_utils.clear_busy_state(device, status, command.override_busy_check) + status = lock_utils.PIN_STATUS.FAILURE + lock_utils.clear_busy_state(device, status) end end -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 +local function delete_all_users_handler(driver, device, command) + if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.DELETE_ALL) then return end - local status = lock_utils.STATUS_SUCCESS + local status = lock_utils.PIN_STATUS.SUCCESS local current_users = lock_utils.get_users(device) local delay = 0 @@ -271,35 +274,35 @@ local delete_all_users_handler = function(driver, device, command) end) end -local add_credential_handler = function(driver, device, command) - if lock_utils.busy_check_and_set(device, {name = lock_utils.ADD_CREDENTIAL, type = lock_utils.LOCK_CREDENTIALS}) then +local function add_credential_handler(driver, device, command) + if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.ADD) then return end - local user_index = tonumber(command.args.userIndex) + local user_index = 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 status = lock_utils.PIN_STATUS.SUCCESS local credential_index = lock_utils.get_available_credential_index(device) if credential_index == nil then - status = lock_utils.STATUS_RESOURCE_EXHAUSTED + status = lock_utils.PIN_STATUS.RESOURCE_EXHAUSTED elseif user_index ~= 0 and lock_utils.get_credential_by_user_index(device, user_index) then - status = lock_utils.STATUS_OCCUPIED + status = lock_utils.PIN_STATUS.OCCUPIED elseif user_index ~= 0 and lock_utils.get_user(device, user_index) == nil then - status = lock_utils.STATUS_FAILURE + status = lock_utils.PIN_STATUS.FAILURE end if user_index == 0 then - user_index = lock_utils.get_available_user_index(device) + user_index = lock_utils.get_next_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 + status = lock_utils.PIN_STATUS.RESOURCE_EXHAUSTED end end - if status == lock_utils.STATUS_SUCCESS then + if status == lock_utils.PIN_STATUS.SUCCESS then -- set the pin code and then validate it was successful when the GetPINCode response is received. -- the credential creation and events will also be handled in that response. device:set_field(lock_utils.ACTIVE_CREDENTIAL, @@ -318,11 +321,11 @@ local add_credential_handler = function(driver, device, command) end end -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 +local function update_credential_handler(driver, device, command) + if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.UPDATE) then return end - local credential_index = tonumber(command.args.credentialIndex) + local credential_index = command.args.credentialIndex local credential_data = command.args.credentialData local credential = lock_utils.get_credential(device, credential_index) @@ -339,16 +342,16 @@ local update_credential_handler = function(driver, device, command) device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) end) else - lock_utils.clear_busy_state(device, lock_utils.STATUS_FAILURE) + lock_utils.clear_busy_state(device, lock_utils.PIN_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 +local function delete_credential_handler(driver, device, command) + if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.DELETE) then return end - local credential_index = tonumber(command.args.credentialIndex) + local credential_index = command.args.credentialIndex local credential = lock_utils.get_credential(device, credential_index) if credential ~= nil then if command.override_busy_check == nil then @@ -362,19 +365,19 @@ local delete_credential_handler = function(driver, device, command) device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) end) else - lock_utils.clear_busy_state(device, lock_utils.STATUS_FAILURE, command.override_busy_check) + lock_utils.clear_busy_state(device, lock_utils.PIN_STATUS.FAILURE) 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 +local function delete_all_credentials_handler(driver, device, command) + if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.DELETE_ALL) then return end local credentials = lock_utils.get_credentials(device) - local status = lock_utils.STATUS_SUCCESS + local status = lock_utils.PIN_STATUS.SUCCESS local delay = 0 for _, credential in pairs(credentials) do - local credential_index = tonumber(credential.credentialIndex) + local credential_index = credential.credentialIndex device.thread:call_with_delay(delay, function() device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) end) @@ -389,28 +392,28 @@ local delete_all_credentials_handler = function(driver, device, command) end) end -local max_code_length_handler = function(driver, device, value) +local function max_pin_code_length_handler(driver, device, value) device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) end -local min_code_length_handler = function(driver, device, value) +local function min_pin_code_length_handler(driver, device, value) device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) end -local max_codes_handler = function(driver, device, value) - device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) +local function number_of_pin_users_supported(driver, device, value) device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) end -local get_pin_response_handler = function(driver, device, zb_mess) +local function get_pin_response_handler(driver, device, zb_mess) local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) local status = lock_utils.STATUS_SUCCESS local emit_event = false + local user_status = zb_mess.body.zcl_body.user_status.value if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then - if command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then + if command == lock_utils.LOCK_CREDENTIALS_CMD.ADD then -- create credential if not already present. if lock_utils.get_credential(device, credential_index) == nil then lock_utils.add_credential(device, @@ -419,7 +422,7 @@ local get_pin_response_handler = function(driver, device, zb_mess) credential_index) emit_event = true end - elseif command ~= nil and command.name == lock_utils.UPDATE_CREDENTIAL then + elseif command == lock_utils.LOCK_CREDENTIALS_CMD.UPDATE then -- update credential local credential = lock_utils.get_credential(device, credential_index) if credential ~= nil then @@ -429,7 +432,7 @@ local get_pin_response_handler = function(driver, device, zb_mess) else -- Called by reloading the codes. 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) + local new_user_index = lock_utils.get_next_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, @@ -438,14 +441,14 @@ local get_pin_response_handler = function(driver, device, zb_mess) credential_index) emit_event = true else - status = lock_utils.STATUS_RESOURCE_EXHAUSTED + status = lock_utils.PIN_STATUS.RESOURCE_EXHAUSTED end end end - elseif zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command ~= nil and command.name == lock_utils.ADD_CREDENTIAL then + elseif zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command == lock_utils.LOCK_CREDENTIALS_CMD.ADD then -- tried to add a code that already is in use. -- remove the created user if one got made. There is no associated credential. - status = lock_utils.STATUS_DUPLICATE + status = lock_utils.PIN_STATUS.DUPLICATE lock_utils.delete_user(device, active_credential.userIndex) else if lock_utils.get_credential(device, credential_index) ~= nil then @@ -473,7 +476,7 @@ local get_pin_response_handler = function(driver, device, zb_mess) lock_utils.send_events(device) end -- ignore handling the busy state for these commands, they are handled within their own handlers - if command ~= nil and command ~= lock_utils.DELETE_ALL_CREDENTIALS and command ~= lock_utils.DELETE_ALL_USERS then + if command ~= lock_utils.LOCK_CREDENTIALS_CMD.DELETE_ALL and command ~= lock_utils.LOCK_USERS_CMD.DELETE_ALL then lock_utils.clear_busy_state(device, status) end end @@ -488,13 +491,9 @@ local programming_event_handler = function(driver, device, zb_mess) end if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - -- Master code updated - device:emit_event(capabilities.lockCredentials.commandResult( - {commandName = lock_utils.UPDATE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS}, - { state_change = true, visibility = { displayed = true } } - )) + lock_utils.emit_command_result(device, lock_utils.LOCK_USERS_CMD.UPDATE, lock_utils.STATUS_SUCCESS) -- Master code updated elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (credential_index == lock_utils.DELETE_ALL_USERS) then + if (credential_index == lock_utils.ZIGBEE_DELETE_ALL_USERS) then -- All credentials deleted for _, credential in pairs(lock_utils.get_credentials(device)) do lock_utils.delete_credential(device, credential.credentialIndex) @@ -510,7 +509,7 @@ local programming_event_handler = function(driver, device, zb_mess) elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then if lock_utils.get_credential(device, credential_index) == nil and command == nil then - local user_index = lock_utils.get_available_user_index(device) + local user_index = lock_utils.get_next_available_user_index(device) if user_index ~= nil then lock_utils.create_user(device, nil, "guest", user_index) lock_utils.add_credential(device, @@ -611,9 +610,9 @@ local zigbee_lock_driver = { attr = { [LockCluster.ID] = { [LockCluster.attributes.LockState.ID] = lock_state_handler, - [LockCluster.attributes.MaxPINCodeLength.ID] = max_code_length_handler, - [LockCluster.attributes.MinPINCodeLength.ID] = min_code_length_handler, - [LockCluster.attributes.NumberOfPINUsersSupported.ID] = max_codes_handler, + [LockCluster.attributes.MaxPINCodeLength.ID] = max_pin_code_length_handler, + [LockCluster.attributes.MinPINCodeLength.ID] = min_pin_code_length_handler, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = number_of_pin_users_supported, } } }, diff --git a/drivers/SmartThings/zigbee-lock/src/lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_tables.lua new file mode 100644 index 0000000000..ee43d60f1b --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_tables.lua @@ -0,0 +1,183 @@ + +local capabilities = require "st.capabilities" +local utils = require "st.utils" + +local lock_table_utils = { + TABLES_LOADED = "tablesLoaded", + LOCK_USERS = "lockUsers", + LOCK_CREDENTIALS = "lockCredentials" +} + +function lock_table_utils.reload_tables(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_table_utils.LOCK_USERS, users) + end + if next(credentials) ~= nil then + device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) + end + device:set_field(lock_table_utils.TABLES_LOADED, true) +end + +function lock_table_utils.get_cached_table(device, table) + if not device:get_field(lock_table_utils.TABLES_LOADED) then + lock_table_utils.reload_tables(device) + end + + return utils.deep_copy(device:get_field(table)) or {} +end + +function lock_table_utils.get_cached_users(device) + return lock_table_utils.get_cached_table(device, lock_table_utils.LOCK_USERS) +end + +function lock_table_utils.get_cached_credentials(device) + return lock_table_utils.get_cached_table(device, lock_table_utils.LOCK_CREDENTIALS) +end + +function lock_table_utils.get_user_from_user_index(device, user_index) + for _, user in pairs(lock_table_utils.get_cached_users(device)) do + if user.userIndex == user_index then return user end + end +end + +function lock_table_utils.get_credential_from_credential_index(device, credential_index) + for _, credential in pairs(lock_table_utils.get_cached_credentials(device)) do + if credential.credentialIndex == credential_index then return credential end + end +end + +function lock_table_utils.get_credential_from_user_index(device, user_index) + for _, credential in pairs(lock_table_utils.get_cached_credentials(device)) do + if credential.userIndex == user_index then return credential end + end +end + +local function add_credential_to_table(device, user_index, credential_index, credential_type) + -- Get latest credential table + local latest_credential_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Add new entry to table + table.insert(latest_credential_table, {userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type}) + device:emit_event(capabilities.lockCredentials.credentials(latest_credential_table, {visibility = {displayed = false}})) +end + +local function delete_credential_from_table(device, credIdx) + -- If Credential Index is ALL_INDEX, remove all entries from the table + if credIdx == ALL_INDEX then + device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) + return ALL_INDEX + end + + -- Get latest credential table + local cred_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Delete an entry from credential table + local userIdx = nil + for index, entry in pairs(cred_table) do + if entry.credentialIndex == credIdx then + table.remove(cred_table, index) + userIdx = entry.userIndex + break + end + end + + device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) + return userIdx +end + +local function delete_credential_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) + return + end + + -- Get latest credential table + local cred_table = device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME + ) or {} + local new_cred_table = {} + + -- Re-create credential table + for index, entry in pairs(cred_table) do + if entry.userIndex ~= userIdx then + table.insert(new_cred_table, entry) + end + end + + device:emit_event(capabilities.lockCredentials.credentials(new_cred_table, {visibility = {displayed = false}})) +end + +function lock_table_utils.add_credential(device, user_index, credential_type, credential_index) + local credentials = lock_table_utils.get_cached_credentials(device) + + table.insert(credentials, { userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type }) + device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) + + local credentials = lock_table_utils.prep_table(lock_table_utils.get_cached_credentials(device)) + device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) + device:emit_event(capabilities.lockCredentials.credentials(credentials, + { state_change = true, visibility = { displayed = true } })) +end + +function lock_table_utils.delete_credential(device, credential_index) + local credentials = lock_table_utils.get_cached_credentials(device) + local status_code = lock_table_utils.STATUS_FAILURE + + for index, credential in pairs(credentials) do + if credential.credentialIndex == credential_index then + lock_table_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_table_utils.LOCK_CREDENTIALS, credentials) + status_code = lock_table_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. +function lock_table_utils.prep_table(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 + +function lock_table_utils.send_events(device, capability) + if capability == capabilities.lockUsers then + local current_users = lock_table_utils.prep_table(lock_table_utils.get_cached_users(device)) + device:set_field(lock_table_utils.LOCK_USERS, current_users) + device:emit_event(capabilities.lockUsers.users(current_users, + {state_change = true, visibility = { displayed = true } })) + elseif capability == capabilities.lockCredentials then + local credentials = lock_table_utils.prep_table(lock_table_utils.get_cached_credentials(device)) + device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) + device:emit_event(capabilities.lockCredentials.credentials(credentials, + { state_change = true, visibility = { displayed = true } })) + end +end + + +return lock_table_utils diff --git a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua index e19a0ae6c7..1ef3f7e4c9 100644 --- a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua @@ -1,8 +1,10 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" -local utils = require "st.utils" +local lock_table_utils = require "lock_tables" +local LockCluster = clusters.DoorLock local INITIAL_INDEX = 1 local lock_utils = {} @@ -10,7 +12,6 @@ local lock_utils = {} lock_utils.DRIVER_STATE = { BUSY = "busy", COMMAND_IN_PROGRESS = "commandInProgress", - TABLES_LOADED = "tablesLoaded", LOCK_USERS = "lockUsers", LOCK_CREDENTIALS = "lockCredentials", SLGA_MIGRATED = "slgaMigrated", @@ -48,193 +49,142 @@ lock_utils.LOCK_USERS_CMD = { DELETE_ALL = "deleteAllUsers" } -lock_utils.DELETE_ALL_USERS = 0xFF +lock_utils.ZIGBEE_DELETE_ALL_USERS = 0xFF lock_utils.CRED_TYPE_PIN = "pin" --- 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 +local function tbl_contains(array, value) + if value == nil then return false end + for _, element in pairs(array or {}) do + if element == value then + return true + end end + return false +end + +function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) + local info = additional_info or {} + info.commandName = command_name + info.statusCode = status_code + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) +end + +-- Check if we are currently busy performing a task (or have timed out). If we are busy, +-- emit the appropriate events to notify the user and return true. If not, set the current command +-- as in progress and set busy to the current time. +function lock_utils.is_device_busy(device, command) local c_time = os.time() - local busy_state = device:get_field(lock_utils.DRIVER_STATE.BUSY) or false + local busy_since = device:get_field(lock_utils.DRIVER_STATE.BUSY) or false - if busy_state == false or c_time - busy_state > 10 then + if (busy_since == false) or (c_time - busy_since > 10) then device:set_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS, command) device:set_field(lock_utils.DRIVER_STATE.BUSY, c_time) return false - else - local command_result_info = { - commandName = command.name, - statusCode = lock_utils.DRIVER_STATE.BUSY - } - if command.type == lock_utils.LOCK_USERS then - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, { state_change = true, visibility = { displayed = true } } - )) - elseif command.type == lock_utils.LOCK_CREDENTIALS then - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, { state_change = true, visibility = { displayed = true } } - )) - end - return true + elseif tbl_contains(lock_utils.LOCK_USERS_CMD, command) then + lock_utils.emit_command_result(device, capabilities.lockUsers, command, lock_utils.PIN_STATUS.BUSY) + elseif tbl_contains(lock_utils.LOCK_CREDENTIALS_CMD, command) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, command, lock_utils.PIN_STATUS.BUSY) end + return true 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.DRIVER_STATE.COMMAND_IN_PROGRESS) - local active_credential = device:get_field(lock_utils.DRIVER_STATE.MOUNTED_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 +function lock_utils.clear_busy_state(device, status) + local command = device:get_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS) or {} + local current_credential = device:get_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL) or {} + + if tbl_contains(lock_utils.LOCK_USERS_CMD, command) then + lock_utils.emit_command_result(device, capabilities.lockUsers, command, status, { userIndex = current_credential.userIndex }) + elseif tbl_contains(lock_utils.LOCK_CREDENTIALS_CMD, command) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, command, status, { userIndex = current_credential.userIndex }) end - device:set_field(lock_utils.DRIVER_STATE.MOUNTED_CREDENTIAL, nil) + device:set_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL, nil) device:set_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) device:set_field(lock_utils.DRIVER_STATE.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) +function lock_utils.reload_all_codes(device) + + -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) end - if next(credentials) ~= nil then - device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) 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) + -- TODO: check what this is doing + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then + device:set_field(lock_utils.CHECKING_CODE, 1) end - local users = utils.deep_copy(device:get_field(lock_utils.LOCK_USERS)) - return users ~= nil and users or {} + -- TODO: check what this is doing + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) 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 +function lock_utils.get_next_available_user_index(device) -lock_utils.get_available_user_index = function(device) - local max = device:get_latest_state("main", capabilities.lockUsers.ID, - capabilities.lockUsers.totalUsersSupported.NAME, 0) - local current_users = lock_utils.get_users(device) - local available_index = nil - local used_index = {} + local used_indices = {} + local current_users = lock_table_utils.get_cached_users(device) for _, user in pairs(current_users) do - used_index[user.userIndex] = true + used_indices[user.userIndex] = true end - if current_users ~= {} then - for index = 1, max do - if used_index[index] == nil then - available_index = index + + local next_available_user_index + if current_users == {} then + next_available_user_index = INITIAL_INDEX + else + local total_supported_indices = device:get_latest_state("main", + capabilities.lockUsers.ID, + capabilities.lockUsers.totalUsersSupported.NAME + ) or 0 + for checked_index = INITIAL_INDEX, total_supported_indices do + if used_indices[checked_index] == nil then + next_available_user_index = checked_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 + return next_available_user_index 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 +function lock_utils.get_next_available_credential_index(device) - 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, 0) - local current_credentials = lock_utils.get_credentials(device) - local available_index = nil - local used_index = {} + local used_indices = {} + local current_credentials = lock_table_utils.get_cached_credentials(device) for _, credential in pairs(current_credentials) do - used_index[credential.credentialIndex] = true + used_indices[credential.credentialIndex] = true end - if current_credentials ~= {} then - for index = 1, max do - if used_index[index] == nil then - available_index = index + + local next_available_credential_index + if current_credentials == {} then + next_available_credential_index = INITIAL_INDEX + else + local total_supported_indices = device:get_latest_state("main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME + ) or 0 + for index = INITIAL_INDEX, total_supported_indices do + if used_indices[index] == nil then + next_available_credential_index = index break end end - else - available_index = INITIAL_INDEX end - return available_index + return next_available_credential_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, { persist = true }) -end - -lock_utils.delete_user = function(device, user_index) - local current_users = lock_utils.get_users(device) +function lock_utils.delete_user(device, user_index) + local current_users = lock_table_utils.get_cached_users(device) local status_code = lock_utils.STATUS_FAILURE for index, user in pairs(current_users) do @@ -250,42 +200,15 @@ lock_utils.delete_user = function(device, user_index) 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, { persist = true }) - 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) +function lock_utils.update_credential(device, credential_index, user_index, credential_type) + local credentials = lock_table_utils.get_cached_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, { persist = true }) + device:set_field(lock_utils.LOCK_CREDENTIALS, credentials) status_code = lock_utils.STATUS_SUCCESS break end @@ -293,30 +216,5 @@ lock_utils.update_credential = function(device, credential_index, user_index, cr 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:set_field(lock_utils.LOCK_USERS, current_users, { persist = true }) - 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:set_field(lock_utils.LOCK_CREDENTIALS, credentials, { persist = true }) - device:emit_event(capabilities.lockCredentials.credentials(credentials, - { state_change = true, visibility = { displayed = true } })) - end -end return lock_utils From 5f5efc6ef15e9b0f3ef906e427d6fa33925b7c16 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sat, 9 May 2026 21:10:51 -0500 Subject: [PATCH 10/33] try an entirely new system --- drivers/SmartThings/zigbee-lock/src/init.lua | 661 ++---------------- .../src/legacy-handlers/can_handle.lua | 4 +- .../src/lock_handlers/attributes.lua | 47 ++ .../src/lock_handlers/capabilities.lua | 165 +++++ .../src/lock_handlers/commands.lua | 280 ++++++++ .../src/lock_handlers/constants.lua | 44 ++ .../zigbee-lock/src/lock_handlers/tables.lua | 169 +++++ .../zigbee-lock/src/lock_handlers/utils.lua | 74 ++ .../zigbee-lock/src/lock_tables.lua | 183 ----- .../zigbee-lock/src/samsungsds/init.lua | 5 +- .../zigbee-lock/src/test/test_zigbee_lock.lua | 2 +- .../src/yale-fingerprint-lock/can_handle.lua | 4 +- .../zigbee-lock/src/zigbee_lock_utils.lua | 220 ------ 13 files changed, 852 insertions(+), 1006 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/lock_tables.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index c95b9186b6..f97640eebd 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -1,74 +1,28 @@ -- Copyright 2022 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - --- Zigbee Driver utilities +local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" local device_management = require "st.zigbee.device_management" -local ZigbeeDriver = require "st.zigbee" - --- Zigbee Spec Utils -local clusters = require "st.zigbee.zcl.clusters" -local Alarm = clusters.Alarms -local LockCluster = clusters.DoorLock -local PowerConfiguration = clusters.PowerConfiguration - --- Capabilities -local capabilities = require "st.capabilities" -local Battery = capabilities.battery -local Lock = capabilities.lock -local LockCodes = capabilities.lockCodes -local LockCredentials = capabilities.lockCredentials -local LockUsers = capabilities.lockUsers - --- Enums -local UserStatusEnum = LockCluster.types.DrlkUserStatus -local UserTypeEnum = LockCluster.types.DrlkUserType -local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode - -local socket = require "cosock.socket" -local legacy_lock_utils = require "legacy-handlers.legacy_lock_utils" -local lock_utils = require "zigbee_lock_utils" - -local DELAY_LOCK_EVENT = "_delay_lock_event" -local MAX_DELAY = 10 +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local constants = require "lock_handlers.constants" +local lock_utils = require "lock_handlers.utils" +local command_handlers = require "lock_handlers.commands" +local attribute_handlers = require "lock_handlers.attributes" +local capability_handlers = require "lock_handlers.capabilities" -local refresh = function(driver, device, cmd) - device:refresh() - device:send(LockCluster.attributes.LockState:read(device)) - device:send(Alarm.attributes.AlarmCount:read(device)) - -- we can't determine from fingerprints if devices support lock codes, so - -- here in the driver we'll do a check once to see if the device responds here - -- and if it does, we'll switch it to a profile with lock codes - if not device:supports_capability(LockCodes) and not device:get_field(legacy_lock_utils.CHECKED_CODE_SUPPORT) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - -- we won't make this value persist because it's not that important - device:set_field(legacy_lock_utils.CHECKED_CODE_SUPPORT, true) - end -end - -local alarm_handler = function(driver, device, zb_mess) - local ALARM_REPORT = { - [0] = Lock.lock.unknown(), - [1] = Lock.lock.unknown(), - -- Events 16-19 are low battery events, but are presented as descriptionText only - } - if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then - device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) - end -end +local LockLifecycle = {} -local function device_added(driver, device) - if device:supports_capability(LockCodes) and device._provisioning_state == "TYPED" then +function LockLifecycle.device_added(driver, device) + if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, -- and should be migrated manually by the user. device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - device:set_field(lock_utils.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore - device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore end - driver:inject_capability_command(device, { capability = capabilities.refresh.ID, command = capabilities.refresh.commands.refresh.NAME, @@ -76,573 +30,88 @@ local function device_added(driver, device) }) end --- The following two functions are from the lock defaults. They are in the base driver temporarily --- until the fix is widely released in the lua libs -local lock_state_handler = function(driver, device, value, zb_rx) - local attr = capabilities.lock.lock - local LOCK_STATE = { - [value.NOT_FULLY_LOCKED] = attr.unknown(), - [value.LOCKED] = attr.locked(), - [value.UNLOCKED] = attr.unlocked(), - [value.UNDEFINED] = attr.unknown(), - } - - -- this is where we decide whether or not we need to delay our lock event because we've - -- observed it coming before the event (or we're starting to compute the timer) - local delay = device:get_field(DELAY_LOCK_EVENT) or 100 - if (delay < MAX_DELAY) then - device.thread:call_with_delay(delay+.5, function () - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) - end) - else - device:set_field(DELAY_LOCK_EVENT, socket.gettime()) - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) - end -end - -local function handle_lock(driver, device, command) - device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) -end - -local function handle_unlock(driver, device, command) - device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) -end - --- Default (post-migration) handlers -- - -local function reload_all_codes(device) - -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - - -- If we are missing the cached values for these attributes, read them so we can properly manage them locally - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then - device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then - device:send(LockCluster.attributes.MinPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) +function LockLifecycle.init(driver, device) + if device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true and device:supports_capability(capabilities.lockCodes) then + -- ensure lockCodes capability state is reflected correctly for migrated devices + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) end - - -- TODO: check what this is doing - if (device:get_field(lock_utils.CHECKING_CODE) == nil) then - device:set_field(lock_utils.CHECKING_CODE, 1) + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) end - - -- TODO: check what this is doing - device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) -end - -local init = function(driver, device) - lock_utils.reload_tables(device) device.thread:call_with_delay(15, function(d) - reload_all_codes(device) - end) -end - -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - - device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) - device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - - device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) - device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false - if slga_migrated then - device.thread:call_with_delay(2, function(d) reload_all_codes(device) end) - else - -- Don't send a reload all codes if this is a part of migration - if device.data.lockCodes == nil or device:get_field(legacy_lock_utils.MIGRATION_RELOAD_SKIPPED) == true then - device.thread:call_with_delay(2, function(d) - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end) - else - device:set_field(legacy_lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) - end - end -end - -local add_user_handler = function(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.ADD) then - return - end - local status - - local next_available_index = lock_utils.get_next_available_user_index(device) - if next_available_index == nil then - status = lock_utils.PIN_STATUS.RESOURCE_EXHAUSTED - else - device:set_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL, { userIndex = next_available_index}) - lock_utils.create_user(device, command.args.userName, command.args.userType, next_available_index) - status = lock_utils.PIN_STATUS.SUCCESS - end - - if status == lock_utils.PIN_STATUS.SUCCESS then - lock_utils.send_events(device, lock_utils.LOCK_USERS) - end - - lock_utils.clear_busy_state(device, status) -end - -local function update_user_handler(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.UPDATE) then - return - end - - local user_name = command.args.userName - local user_type = command.args.userType - local user_index = command.args.userIndex - local current_users = lock_utils.get_users(device) - local status = lock_utils.PIN_STATUS.FAILURE - - for _, user in pairs(current_users) do - if user.userIndex == user_index then - device:set_field(lock_utils.DRIVER_STATE.CURRENT_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.PIN_STATUS.SUCCESS - break - end - end - - lock_utils.clear_busy_state(device, status) -end - -local function delete_user_handler(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.DELETE) then - return - end - local status = lock_utils.PIN_STATUS.SUCCESS - local user_index = command.args.userIndex - if lock_utils.get_user(device, user_index) ~= nil then - device:set_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL, { userIndex = user_index }) - - - 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) - end - else - status = lock_utils.PIN_STATUS.FAILURE - lock_utils.clear_busy_state(device, status) - end -end - -local function delete_all_users_handler(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_USERS_CMD.DELETE_ALL) then - return - end - local status = lock_utils.PIN_STATUS.SUCCESS - local current_users = lock_utils.get_users(device) - - local delay = 0 - 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) + lock_utils.reload_all_codes(device) end) end -local function add_credential_handler(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.ADD) then - return - end - local user_index = 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.PIN_STATUS.SUCCESS - - local credential_index = lock_utils.get_available_credential_index(device) - if credential_index == nil then - status = lock_utils.PIN_STATUS.RESOURCE_EXHAUSTED - elseif user_index ~= 0 and lock_utils.get_credential_by_user_index(device, user_index) then - status = lock_utils.PIN_STATUS.OCCUPIED - elseif user_index ~= 0 and lock_utils.get_user(device, user_index) == nil then - status = lock_utils.PIN_STATUS.FAILURE - end - - if user_index == 0 then - user_index = lock_utils.get_next_available_user_index(device) - if user_index ~= nil then - lock_utils.create_user(device, nil, user_type, user_index) - else - status = lock_utils.PIN_STATUS.RESOURCE_EXHAUSTED - end - end - - if status == lock_utils.PIN_STATUS.SUCCESS then - -- set the pin code and then validate it was successful when the GetPINCode response is received. - -- the credential creation and events will also be handled in that response. - device:set_field(lock_utils.ACTIVE_CREDENTIAL, - { userIndex = user_index, userType = user_type, credentialType = credential_type, credentialIndex = credential_index }) - device:send(LockCluster.server.commands.SetPINCode(device, - credential_index, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - credential_data) - ) - device.thread:call_with_delay(4, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - else - lock_utils.clear_busy_state(device, status) - end -end - -local function update_credential_handler(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.UPDATE) then - return - end - local credential_index = 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(LockCluster.server.commands.SetPINCode(device, - credential_index, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - credential_data) - ) - device.thread:call_with_delay(4, function() - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - else - lock_utils.clear_busy_state(device, lock_utils.PIN_STATUS.FAILURE) - end -end - -local function delete_credential_handler(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.DELETE) then - return - end - - local credential_index = 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(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) - device.thread:call_with_delay(2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - else - lock_utils.clear_busy_state(device, lock_utils.PIN_STATUS.FAILURE) - end -end - -local function delete_all_credentials_handler(driver, device, command) - if lock_utils.is_device_busy(device, lock_utils.LOCK_CREDENTIALS_CMD.DELETE_ALL) then - return - end - local credentials = lock_utils.get_credentials(device) - local status = lock_utils.PIN_STATUS.SUCCESS - local delay = 0 - for _, credential in pairs(credentials) do - local credential_index = credential.credentialIndex - device.thread:call_with_delay(delay, function() - device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) - end) - device.thread:call_with_delay(delay + 2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) - end) - delay = delay + 2 - end - - device.thread:call_with_delay(delay + 4, function() - lock_utils.clear_busy_state(device, status) - end) -end - -local function max_pin_code_length_handler(driver, device, value) - device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) -end - -local function min_pin_code_length_handler(driver, device, value) - device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) -end - -local function number_of_pin_users_supported(driver, device, value) - device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) -end - -local function get_pin_response_handler(driver, device, zb_mess) - local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) - local active_credential = device:get_field(lock_utils.ACTIVE_CREDENTIAL) - local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) - local status = lock_utils.STATUS_SUCCESS - local emit_event = false +function LockLifecycle.do_configure(self, device) + device:send(device_management.build_bind_request(device, clusters.PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - local user_status = zb_mess.body.zcl_body.user_status.value - if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then - if command == lock_utils.LOCK_CREDENTIALS_CMD.ADD 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 == lock_utils.LOCK_CREDENTIALS_CMD.UPDATE 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 - -- Called by reloading the codes. Don't add if already in table. - if lock_utils.get_credential(device, credential_index) == nil then - local new_user_index = lock_utils.get_next_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.PIN_STATUS.RESOURCE_EXHAUSTED - end - end - end - elseif zb_mess.body.zcl_body.user_status.value == UserStatusEnum.AVAILABLE and command == lock_utils.LOCK_CREDENTIALS_CMD.ADD then - -- tried to add a code that already is in use. - -- remove the created user if one got made. There is no associated credential. - status = lock_utils.PIN_STATUS.DUPLICATE - lock_utils.delete_user(device, active_credential.userIndex) - else - if lock_utils.get_credential(device, credential_index) ~= nil then - -- Credential has been deleted. - lock_utils.delete_credential(device, credential_index) - emit_event = true - end - end + device:send(device_management.build_bind_request(device, clusters.DoorLock.ID, self.environment_info.hub_zigbee_eui)) + device:send(clusters.DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then - -- the credential we're checking has arrived - local last_slot = device:get_latest_state("main", capabilities.lockCredentials.ID, - capabilities.lockCredentials.pinUsersSupported.NAME) - if (credential_index >= last_slot) then - device:set_field(lock_utils.CHECKING_CODE, nil) - emit_event = true - else - local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 - device:set_field(lock_utils.CHECKING_CODE, checkingCode) - device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) - end - end + device:send(device_management.build_bind_request(device, clusters.Alarms.ID, self.environment_info.hub_zigbee_eui)) + device:send(clusters.Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - if emit_event then - lock_utils.send_events(device) - end - -- ignore handling the busy state for these commands, they are handled within their own handlers - if command ~= lock_utils.LOCK_CREDENTIALS_CMD.DELETE_ALL and command ~= lock_utils.LOCK_USERS_CMD.DELETE_ALL then - lock_utils.clear_busy_state(device, status) - end -end - -local programming_event_handler = function(driver, device, zb_mess) - local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) - local command = device:get_field(lock_utils.COMMAND_IN_PROGRESS) - local emit_events = false - - if credential_index >= 256 then -- Index is incorrectly written, attempt to shift it to get an actual value - credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) >> 8 - end - - if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - lock_utils.emit_command_result(device, lock_utils.LOCK_USERS_CMD.UPDATE, lock_utils.STATUS_SUCCESS) -- Master code updated - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (credential_index == lock_utils.ZIGBEE_DELETE_ALL_USERS) then - -- All credentials deleted - for _, credential in pairs(lock_utils.get_credentials(device)) do - lock_utils.delete_credential(device, credential.credentialIndex) - emit_events = true - end - else - -- One credential deleted - if (lock_utils.get_credential(device, credential_index) ~= nil) then - lock_utils.delete_credential(device, credential_index) - emit_events = true - end - end - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then - if lock_utils.get_credential(device, credential_index) == nil and command == nil then - local user_index = lock_utils.get_next_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 - end - end - - if emit_events then - lock_utils.send_events(device) - end -end - -local lock_operation_event_handler = function(driver, device, zb_rx) - local event_code = zb_rx.body.zcl_body.operation_event_code.value - local source = zb_rx.body.zcl_body.operation_event_source.value - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" - } - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() - } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" - else - event.data.method = METHOD[source] - end - if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad - local code_id = zb_rx.body.zcl_body.user_id.value - local code_name = "Code " .. code_id - local user = lock_utils.get_user(device, code_id) - if user ~= nil then - code_name = user.userName - end - - event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } - 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( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) - end - end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) - end + device.thread:call_with_delay(2, function(d) lock_utils.reload_all_codes(device) end) end local zigbee_lock_driver = { - supported_capabilities = { - Lock, - LockCredentials, - LockUsers, - Battery, + lifecycle_handlers = { + added = LockLifecycle.device_added, + init = LockLifecycle.init, + doConfigure = LockLifecycle.do_configure, }, zigbee_handlers = { cluster = { - [Alarm.ID] = { - [Alarm.client.commands.Alarm.ID] = alarm_handler + [clusters.Alarms.ID] = { + [clusters.Alarms.client.commands.Alarm.ID] = command_handlers.alarm }, - [LockCluster.ID] = { - [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, - [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, - [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + [clusters.DoorLock.ID] = { + [clusters.DoorLock.client.commands.ClearAllPINCodesResponse.ID] = command_handlers.clear_all_pin_codes_response, + [clusters.DoorLock.client.commands.ClearPINCodeResponse.ID] = command_handlers.clear_pin_code_response, + [clusters.DoorLock.client.commands.GetPINCodeResponse.ID] = command_handlers.get_pin_code_response, + [clusters.DoorLock.client.commands.ProgrammingEventNotification.ID] = command_handlers.programming_event_notification, + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = command_handlers.operating_event_notification } }, attr = { - [LockCluster.ID] = { - [LockCluster.attributes.LockState.ID] = lock_state_handler, - [LockCluster.attributes.MaxPINCodeLength.ID] = max_pin_code_length_handler, - [LockCluster.attributes.MinPINCodeLength.ID] = min_pin_code_length_handler, - [LockCluster.attributes.NumberOfPINUsersSupported.ID] = number_of_pin_users_supported, + [clusters.DoorLock.ID] = { + [clusters.DoorLock.attributes.LockState.ID] = attribute_handlers.lock_state, + [clusters.DoorLock.attributes.MaxPINCodeLength.ID] = attribute_handlers.max_pin_code_length, + [clusters.DoorLock.attributes.MinPINCodeLength.ID] = attribute_handlers.min_pin_code_length, + [clusters.DoorLock.attributes.NumberOfPINUsersSupported.ID] = attribute_handlers.number_of_pin_users_supported, } } }, capability_handlers = { - [Lock.ID] = { - [Lock.commands.lock.NAME] = handle_lock, - [Lock.commands.unlock.NAME] = handle_unlock, + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = capability_handlers.lock, + [capabilities.lock.commands.unlock.NAME] = capability_handlers.unlock, }, - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = refresh + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = capability_handlers.add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = capability_handlers.update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = capability_handlers.delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = capability_handlers.delete_all_users, }, - [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, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = capability_handlers.add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = capability_handlers.update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = capability_handlers.delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = capability_handlers.delete_all_credentials, }, - [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.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh, }, }, - sub_drivers = require("sub_drivers"), - lifecycle_handlers = { - added = device_added, - init = init, - doConfigure = do_configure, + supported_capabilities = { + capabilities.lock, + capabilities.lockCredentials, + capabilities.lockUsers, + capabilities.battery, }, + sub_drivers = require("sub_drivers"), health_check = false, } diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua index fb869ffe0d..e81d05a92e 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua @@ -2,8 +2,8 @@ -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device, ...) - local lock_utils = require "zigbee_lock_utils" - local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + local constants = require "lock_handlers.constants" + local slga_migrated = device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) or false if not slga_migrated then local subdriver = require("legacy-handlers") return true, subdriver diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua new file mode 100644 index 0000000000..da5bc9e069 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua @@ -0,0 +1,47 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local socket = require "cosock.socket" + +local AttributeHandlers = {} + +-- [[ DOOR LOCK CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.lock_state(driver, device, value, zb_rx) + local attr = capabilities.lock.lock + local LOCK_STATE = { + [value.NOT_FULLY_LOCKED] = attr.unknown(), + [value.LOCKED] = attr.locked(), + [value.UNLOCKED] = attr.unlocked(), + [value.UNDEFINED] = attr.unknown(), + } + + -- this is where we decide whether or not we need to delay our lock event because we've + -- observed it coming before the event (or we're starting to compute the timer) + local DELAY_LOCK_EVENT, MAX_DELAY = "_delay_lock_event", 10 + local delay = device:get_field(DELAY_LOCK_EVENT) or 100 + if (delay < MAX_DELAY) then + device.thread:call_with_delay(delay+.5, function () + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end) + else + device:set_field(DELAY_LOCK_EVENT, socket.gettime()) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end +end + +function AttributeHandlers.max_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function AttributeHandlers.min_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function AttributeHandlers.number_of_pin_users_supported(driver, device, value) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) + device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) +end + +return AttributeHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua new file mode 100644 index 0000000000..8a8dc005dc --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -0,0 +1,165 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local lock_utils = require "lock_handlers.utils" +local tables = require "lock_handlers.tables" +local constants = require "lock_handlers.constants" + +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType + + +local CapabilityHandlers = {} + + +-- [[ LOCK CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.lock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) +end + +function CapabilityHandlers.unlock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) +end + + +-- [[ LOCK USERS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_user(driver, device, command) + local status = lock_utils.is_device_busy(device) and constants.COMMAND_RESULT.BUSY or + tables.add_entry(device, tables.DEFS.users, + { userName = command.args.userName, userType = command.args.userType } + ) + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.ADD, status) +end + +function CapabilityHandlers.update_user(driver, device, command) + local status = lock_utils.is_device_busy(device) and constants.COMMAND_RESULT.BUSY or + tables.update_entry(device, tables.DEFS.users, + command.args.userIndex, + { userName = command.args.userName, userType = command.args.userType } + ) + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.UPDATE, status) +end + +function CapabilityHandlers.delete_user(driver, device, command) + -- Note: We are going to hold off on clearing the user until we get + -- the response from the lock confirming that the user has been deleted + + local get_credential_index_from_associated_user_index = function(device, user_index) + local credentials = tables.get_state(device, tables.DEFS.credentials) + if not credentials then return end + for _, credential in pairs(credentials) do + if credential.userIndex == user_index then + return credential.credentialIndex + end + end + end + + local associated_credential_index = get_credential_index_from_associated_user_index(device, command.args.userIndex) + if associated_credential_index then + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { + credentialIndex = associated_credential_index, + credentialType = constants.CRED_TYPE_PIN, + deleteUser = constants.LOCK_USERS.DELETE -- This is injected for logging purposes in the command result + } + }) + end +end + +function CapabilityHandlers.delete_all_users(driver, device, command) + -- Note: We are going to hold off on clearing the users table until we get the response from the lock confirming that all users have been deleted + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { + credentialType = constants.CRED_TYPE_PIN, + deleteAllUsers = constants.LOCK_USERS.DELETE_ALL -- This is injected for logging purposes in the command result + } + }) +end + + +-- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.ADD, constants.COMMAND_RESULT.BUSY) + else + lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.ADD, command.args) + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.userIndex, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.userIndex)) + end) + end +end + +function CapabilityHandlers.update_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, constants.COMMAND_RESULT.BUSY) + else + lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.UPDATE, command.args) + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.userIndex, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) + device.thread:call_with_delay(4, function() + device:send(LockCluster.server.commands.GetPINCode(device, command.args.userIndex)) + end) + end +end + +function CapabilityHandlers.delete_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + if command.args.deleteUser then + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, constants.COMMAND_RESULT.BUSY) + else + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.BUSY) + end + else + lock_utils.set_busy_state(device, command.args.deleteUser or constants.LOCK_CREDENTIALS.DELETE, command.args) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, command.args.credentialIndex)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.credentialIndex)) + end) + end +end + +function CapabilityHandlers.delete_all_credentials(driver, device, command) + if lock_utils.is_device_busy(device) then + if command.args.deleteAllUsers then + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) + else + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) + end + else + lock_utils.set_busy_state(device, command.args.deleteAllUsers or constants.LOCK_CREDENTIALS.DELETE_ALL, command.args) + device:send(LockCluster.server.commands.ClearAllPINCodes(device)) + end +end + + +-- [[ REFRESH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.refresh(driver, device, cmd) + device:refresh() + device:send(LockCluster.attributes.LockState:read(device)) + device:send(Alarm.attributes.AlarmCount:read(device)) +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua new file mode 100644 index 0000000000..76bfaa9282 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -0,0 +1,280 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local socket = require "cosock.socket" +local lock_utils = require "lock_handlers.utils" +local tables = require "lock_handlers.tables" +local constants = require "lock_handlers.constants" + +local DELAY_LOCK_EVENT = "_delay_lock_event" +local MAX_DELAY = 10 + + +local ResponseHandlers = {} + + +local UserStatusEnum = clusters.DoorLock.types.DrlkUserStatus +-- AVAILABLE = 0, This user slot is empty / unused +-- OCCUPIED_ENABLED = 1, A user exists in that slot and is allowed access +-- OCCUPIED_DISABLED = 3, A user exists, but access is disabled +-- NOT_SUPPORTED = 0xFF, Lock doesn't support reporting/setting this field + +local function pin_response_after_add_credential(device, user_id, user_status, credential_args_in_use) + local status + if user_status == UserStatusEnum.OCCUPIED_ENABLED then + -- The code was successfully added. + status = tables.add_entry(device, tables.DEFS.credentials, { + userIndex = credential_args_in_use.userIndex, + credentialIndex = user_id, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + elseif user_status == UserStatusEnum.AVAILABLE then + -- We tried to add a code for some user id, but the user status for that id indicates that the location is still available. + -- Therefore, the code was not added. Besides an internal error, this might happen if the credential was a duplicate, so we should remove the user we created for this code since there is no credential after all. + status = constants.COMMAND_RESULT.DUPLICATE + tables.delete_entry(device, tables.DEFS.users, user_id) -- TODO: should we add a command result for lockUsers as well here? + elseif user_status == UserStatusEnum.OCCUPIED_DISABLED then + status = constants.COMMAND_RESULT.RESOURCE_EXHAUSTED -- We can't add the code because the slot is disabled. + elseif user_status == UserStatusEnum.NOT_SUPPORTED then + status = constants.COMMAND_RESULT.INVALID_COMMAND -- The lock doesn't support adding codes at this user index. + else + status = constants.COMMAND_RESULT.FAILURE -- catch-all for any unexpected user status value + end + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.ADD, status) + lock_utils.clear_busy_state(device) +end + +local function pin_response_after_update_credential(device, user_id, user_status, credential_args_in_use) + local status + if user_status == UserStatusEnum.OCCUPIED_ENABLED then + status = tables.update_entry(device, tables.DEFS.credentials, { + userIndex = credential_args_in_use.userIndex, + credentialIndex = user_id, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + else + -- if no values were returned for any reason, we should remove the credential and associated user + -- from our table since they are not actually present on the lock. + status = constants.COMMAND_RESULT.INVALID_COMMAND + tables.delete_entry(device, tables.DEFS.credentials, user_id) + tables.delete_entry(device, tables.DEFS.users, credential_args_in_use.userIndex) + end + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, status) + lock_utils.clear_busy_state(device) +end + +local function pin_response_after_syncing_code_from_lock(device, user_id) + -- if an entry already exists at this user index, this will be a no-op. This is just meant to populate our tables with the existing codes on the lock, so we don't need to worry about handling updates vs adds here + local status = tables.add_entry(device, tables.DEFS.users, { + userIndex = user_id, + userName = "User " .. user_id, -- generic default, since we didn't explicitly set a name for this code. + userType = "guest", -- also a generic default. + }) + if status == constants.COMMAND_RESULT.SUCCESS then + -- if the entry was successfully added to the user table, we should also add an entry to the credential table for this code. + tables.add_entry(device, tables.DEFS.credentials, { + userIndex = user_id, + credentialIndex = user_id, + credentialType = lock_utils.CREDENTIAL_TYPE, + credentialName = "User " .. user_id, -- also a generic default. + }) + end + if user_id >= tables.get_max_entries(device, tables.DEFS.credentials) then + device:set_field(constants.SYNC.CODE_INDEX, nil) + lock_utils.clear_busy_state(device) + else + local synced_code_index = device:get_field(constants.SYNC.CODE_INDEX) + 1 + device:set_field(constants.SYNC.CODE_INDEX, synced_code_index) + lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK_CMD, { checkingCode = synced_code_index }) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) + end +end + +function ResponseHandlers.get_pin_code_response(driver, device, zb_mess) + -- cached values from capability command + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + -- response values + local user_id = tonumber(zb_mess.body.zcl_body.user_id.value) + local user_status = zb_mess.body.zcl_body.user_status.value + + if command_in_progress == constants.LOCK_CREDENTIALS.ADD then + pin_response_after_add_credential(device, user_id, user_status, credential_args_in_use) + elseif command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then + pin_response_after_update_credential(device, user_id, user_status, credential_args_in_use) + elseif command_in_progress == constants.SYNC.CODES_FROM_LOCK_CMD then + pin_response_after_syncing_code_from_lock(device, user_id) + end +end + + +local ProgrammingEventCodeEnum = clusters.DoorLock.types.ProgramEventCode +-- MASTER_CODE_CHANGED = 1 +-- PIN_CODE_ADDED = 2 +-- PIN_CODE_DELETED = 3 +-- PIN_CODE_CHANGED = 4 +-- RFID_CODE_ADDED = 5 +-- RFID_CODE_DELETED = 6 + +function ResponseHandlers.programming_event_notification(driver, device, zb_mess) + -- cached values from capability command + local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(lock_utils.CREDENTIAL_ARGS_IN_USE) + -- response values + local user_id = tonumber(zb_mess.body.zcl_body.user_id.value) + local event_code = tonumber(zb_mess.body.zcl_body.program_event_code.value) + + -- TODO + if user_id >= 256 then -- Index is incorrectly written, attempt to shift it to get an actual value + user_id = user_id >> 8 + end + + if event_code == ProgrammingEventCodeEnum.PIN_CODE_ADDED then + if command_in_progress == constants.LOCK_CREDENTIALS.ADD then + -- if we just added/updated a code and got this event, we know that the get_pin_code_response handler will handle + -- updating our tables and emitting command results, so we should return here and avoid duplicating any events. + return + end + tables.add_entry(device, tables.DEFS.users, { + userIndex = credential_args_in_use.userIndex, + userName = credential_args_in_use.credentialName or ("User " .. user_id), -- if we have a name for this code, use it, otherwise default to a generic name + userType = "guest", -- we don't have a way to get the user type from the lock, so just default to guest + }) + tables.add_entry(device, tables.DEFS.credentials, { + userIndex = credential_args_in_use.userIndex, + credentialIndex = user_id, + credentialType = lock_utils.CREDENTIAL_TYPE, + credentialName = credential_args_in_use.credentialName or ("User " .. user_id), -- if we have a name for this code, use it, otherwise default to a generic name + }) + elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_DELETED then + if command_in_progress == constants.LOCK_USERS.DELETE or command_in_progress == constants.LOCK_CREDENTIALS.DELETE then + -- if we just deleted a code and got this event, we know that the clear_pin_code_response handler will handle + -- updating our tables and emitting command results, so we should return here and avoid duplicating any events. + return + end + -- if this did not get triggered by one of our delete commands, then we should remove the code from our tables since it has been deleted from the lock. + tables.delete_entry(device, tables.DEFS.users, user_id) + tables.delete_entry(device, tables.DEFS.credentials, user_id) + elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_CHANGED then + if command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then + -- if we just updated a code and got this event, we know that the get_pin_code_response handler will handle + -- updating our tables and emitting command results, so we should return here and avoid duplicating any events. + return + end + -- if this did not get triggered by one of our update commands, then we should update the code in our tables since it has been updated on the lock. + tables.update_entry(device, tables.DEFS.credentials, { + userIndex = credential_args_in_use.userIndex, + credentialIndex = user_id, + credentialType = lock_utils.CREDENTIAL_TYPE, + credentialName = credential_args_in_use.credentialName or ("User " .. user_id), -- if we have a name for this code, use it, otherwise default to a generic name + }) + end +end + +function ResponseHandlers.operating_event_notification(driver, device, zb_rx) + local event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) + local source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad + local code_id = tonumber(zb_rx.body.zcl_body.user_id.value) + local code_name = "Code " .. code_id + local user = lock_utils.get_user(device, code_id) + if user ~= nil then + code_name = user.userName + end + + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + 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( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + +function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_mess) + -- We can now safely clear all credentials, as well as users, from our tables, since the lock has confirmed that all codes have been deleted. + local status + status = tables.delete_all_entries(device, tables.DEFS.users) + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, status) + status = tables.delete_all_entries(device, tables.DEFS.credentials) + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, status) + lock_utils.clear_busy_state(device) +end + +function ResponseHandlers.clear_pin_code_response(driver, device, zb_mess) + -- cached values from capability command + local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(lock_utils.CREDENTIAL_ARGS_IN_USE) + + local user_status = tables.delete_entry(device, tables.DEFS.users, credential_args_in_use.userIndex) + local credential_status = tables.delete_entry(device, tables.DEFS.credentials, credential_args_in_use.credentialIndex) + if command_in_progress == constants.LOCK_USERS.DELETE then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, user_status) + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status) + elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status) + end +end + +-- [[ ALARMS CLUSTER COMMANDS ]] -- + +function ResponseHandlers.alarm(driver, device, zb_mess) + local ALARM_REPORT = { + [0] = capabilities.lock.lock.unknown(), + [1] = capabilities.lock.lock.unknown(), + -- Events 16-19 are low battery events, but are presented as descriptionText only + } + if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) + end +end + +return ResponseHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua new file mode 100644 index 0000000000..b7d1d9bf80 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua @@ -0,0 +1,44 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lock_constants = {} + +lock_constants.DRIVER_STATE = { + BUSY = "busy", + COMMAND_IN_PROGRESS = "commandInProgress", + CREDENTIAL_ARGS_IN_USE = "currentCredential", + SLGA_MIGRATED = "slgaMigrated", +} + +lock_constants.SYNC = { + CODES_FROM_LOCK = "syncCodesFromLock", + CODE_INDEX = "syncCodeIndex", +} + +lock_constants.COMMAND_RESULT = { + SUCCESS = "success", + FAILURE = "failure", + DUPLICATE = "duplicate", + OCCUPIED = "occupied", + INVALID_COMMAND = "invalidCommand", + RESOURCE_EXHAUSTED = "resourceExhausted", + BUSY = "busy" +} + +lock_constants.LOCK_CREDENTIALS = { + ADD = "addCredential", + UPDATE = "updateCredential", + DELETE = "deleteCredential", + DELETE_ALL = "deleteAllCredentials" +} + +lock_constants.LOCK_USERS = { + ADD = "addUser", + UPDATE = "updateUser", + DELETE = "deleteUser", + DELETE_ALL = "deleteAllUsers" +} + +lock_constants.CRED_TYPE_PIN = "pin" + +return lock_constants diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua new file mode 100644 index 0000000000..360a644312 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua @@ -0,0 +1,169 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local constants = require "lock_handlers.constants" + +local table_utils = {} + +-- DEFS describes how each capability-backed state table is structured: +-- +-- capability SmartThings capability (used for device support checks) +-- attribute Capability attribute function (used to emit state) +-- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table +-- match_key Key used to identify entries in flat tables +-- required_keys Keys that must be non-nil when adding an entry +-- + +table_utils.DEFS = { + users = { + capability = capabilities.lockUsers, + attribute = capabilities.lockUsers.users, + max_entries = capabilities.lockUsers.totalUsersSupported, + match_key = "userIndex", + required_keys = {"userIndex", "userType"}, + }, + credentials = { + capability = capabilities.lockCredentials, + attribute = capabilities.lockCredentials.credentials, + max_entries = capabilities.lockCredentials.pinUsersSupported, + match_key = "credentialIndex", + required_keys = {"userIndex", "credentialIndex", "credentialType"}, + } +} + +-- Resolve a table name to its definition. Logs an error and returns nil if unknown. +local function resolve_table_def(device, table_name) + local def = table_utils.DEFS[table_name] + if not def then + device.log.error(string.format("table_helpers: unknown table %q", table_name)) + end + return def +end + +-- Validate that an entry table contains all required keys. +local function validate_entry(device, entry, required_keys) + for _, key in ipairs(required_keys or {}) do + if entry[key] == nil then + device.log.error(string.format("table_helpers: entry missing required key %q", key)) + return false + end + end + return true +end + +-- Read the current state for a table definition and return a deep-copied array. +-- Returns nil (with a warning) if the capability is unsupported by the device. +--- @return table[] | nil +function table_utils.get_state(device, def) + if not device:supports_capability(def.capability, "main") then + device.log.warn(string.format( + "table_helpers: device does not support capability %q", def.capability.ID + )) + return + end + return st_utils.deep_copy(device:get_latest_state("main", def.capability.ID, def.attribute.NAME, {})) +end + +function table_utils.get_max_entries(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return end + return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing +end + +-- Add an entry to a named table. The entry must satisfy all required_keys for +-- that table. An entry whose match_key value already exists in the +-- table is skipped to prevent duplicates. If the table has a max_entries limit, +-- entries that exceed the limit are not added. +function table_utils.add_entry(device, table_name, entry) + local def = resolve_table_def(device, table_name) + if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not validate_entry(device, entry, def.required_keys) then return constants.COMMAND_RESULT_STATUS.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return constants.COMMAND_RESULT_STATUS.FAILURE end + + if #t >= table_utils.get_max_entries(device, table_name) then + device.log.warn(string.format( + "table_helpers: cannot add entry to %q, max entries reached", table_name + )) + return constants.COMMAND_RESULT_STATUS.RESOURCE_EXHAUSTED + end + + -- Object entry: skip if an entry with the same match_key value already exists + if def.match_key then + for _, existing in ipairs(t) do + if existing[def.match_key] == entry[def.match_key] then + device.log.warn(string.format( + "table_helpers: entry with %s == %s already exists in %q, skipping", + def.match_key, tostring(entry[def.match_key]), table_name + )) + return constants.COMMAND_RESULT_STATUS.OCCUPIED + end + end + end + + table.insert(t, st_utils.deep_copy(entry)) + + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + return constants.COMMAND_RESULT_STATUS.SUCCESS +end + + +--- Update fields of an existing entry in a table. +--- The entry to update is identified by the match_key parameter in DEFS. +function table_utils.update_entry(device, table_name, match_value, updates) + local def = resolve_table_def(device, table_name) + if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return constants.COMMAND_RESULT_STATUS.FAILURE end + + for _, entry in ipairs(t) do + if entry[def.match_key] == match_value then + for k, v in pairs(updates) do + entry[k] = v + end + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + return constants.COMMAND_RESULT_STATUS.SUCCESS + end + end + + device.log.warn(string.format( + "table_helpers: no entry found in %q with %s == %s", + table_name, def.match_key, tostring(match_value) + )) + return constants.COMMAND_RESULT_STATUS.FAILURE +end + + +-- Delete an entry from a table. +-- +-- Returns the deleted entry, or FAILURE if nothing matched. +function table_utils.delete_entry(device, table_name, matcher) + local def = resolve_table_def(device, table_name) + if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return constants.COMMAND_RESULT_STATUS.FAILURE end + + local predicate = function(entry) return entry[def.match_key] == matcher end + + local removed = nil + for i, entry in ipairs(t) do + if predicate(entry) then + removed = table.remove(t, i) + break + end + end + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + return removed or constants.COMMAND_RESULT_STATUS.FAILURE +end + +-- Delete all entries from a table. +function table_utils.delete_all_entries(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end + device:emit_event(def.attribute({}, {visibility = {displayed = false}})) + return constants.COMMAND_RESULT_STATUS.SUCCESS +end + +return table_utils diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua new file mode 100644 index 0000000000..f4cae92023 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua @@ -0,0 +1,74 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local constants = require "lock_handlers.constants" +local tables = require "lock_handlers.tables" + +local lock_utils = {} + + +-- [[ BUSY STATE MANAGEMENT ]] -- + +-- Check if we are currently busy performing a task, or at least 10 seconds have passed since the busy state was last set. +-- If busy, return true. If not busy, clear any stale state and return false. +function lock_utils.is_device_busy(device) + local c_time = os.time() + local busy_since = device:get_field(constants.DRIVER_STATE.BUSY) or false + + if (busy_since == false) or (c_time - busy_since > 10) then + lock_utils.clear_busy_state(device) + return false + end + return true +end + +-- Set states that may be required when in busy state +function lock_utils.set_busy_state(device, command_name, command_args) + device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) + device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) + device:set_field(constants.DRIVER_STATE.BUSY, os.time()) +end + +-- Clear states that were set when in busy state +function lock_utils.clear_busy_state(device) + device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) + device:set_field(constants.DRIVER_STATE.BUSY, false) +end + + +-- [[ CAPABILITY STATE MANAGEMENT ]] -- + +function lock_utils.reload_all_codes(device) + -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. + device:send(clusters.DoorLock.attributes.SendPINOverTheAir:write(device, true)) + + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then + device:send(clusters.DoorLock.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then + device:send(clusters.DoorLock.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) or + (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == 0) then + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) + end + + if (device:get_field(constants.SYNC.CODE_INDEX) == nil) then -- if this value is nil, we haven't started syncing codes from the lock yet, so start the process + device:set_field(constants.SYNC.CODE_INDEX, 1) + end + lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, device:get_field(constants.SYNC.CODE_INDEX))) +end + +function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) + local info = additional_info or {} + info.commandName = command_name + info.statusCode = status_code + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) +end + +return lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_tables.lua deleted file mode 100644 index ee43d60f1b..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/lock_tables.lua +++ /dev/null @@ -1,183 +0,0 @@ - -local capabilities = require "st.capabilities" -local utils = require "st.utils" - -local lock_table_utils = { - TABLES_LOADED = "tablesLoaded", - LOCK_USERS = "lockUsers", - LOCK_CREDENTIALS = "lockCredentials" -} - -function lock_table_utils.reload_tables(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_table_utils.LOCK_USERS, users) - end - if next(credentials) ~= nil then - device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) - end - device:set_field(lock_table_utils.TABLES_LOADED, true) -end - -function lock_table_utils.get_cached_table(device, table) - if not device:get_field(lock_table_utils.TABLES_LOADED) then - lock_table_utils.reload_tables(device) - end - - return utils.deep_copy(device:get_field(table)) or {} -end - -function lock_table_utils.get_cached_users(device) - return lock_table_utils.get_cached_table(device, lock_table_utils.LOCK_USERS) -end - -function lock_table_utils.get_cached_credentials(device) - return lock_table_utils.get_cached_table(device, lock_table_utils.LOCK_CREDENTIALS) -end - -function lock_table_utils.get_user_from_user_index(device, user_index) - for _, user in pairs(lock_table_utils.get_cached_users(device)) do - if user.userIndex == user_index then return user end - end -end - -function lock_table_utils.get_credential_from_credential_index(device, credential_index) - for _, credential in pairs(lock_table_utils.get_cached_credentials(device)) do - if credential.credentialIndex == credential_index then return credential end - end -end - -function lock_table_utils.get_credential_from_user_index(device, user_index) - for _, credential in pairs(lock_table_utils.get_cached_credentials(device)) do - if credential.userIndex == user_index then return credential end - end -end - -local function add_credential_to_table(device, user_index, credential_index, credential_type) - -- Get latest credential table - local latest_credential_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.credentials.NAME, - {} - )) - - -- Add new entry to table - table.insert(latest_credential_table, {userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type}) - device:emit_event(capabilities.lockCredentials.credentials(latest_credential_table, {visibility = {displayed = false}})) -end - -local function delete_credential_from_table(device, credIdx) - -- If Credential Index is ALL_INDEX, remove all entries from the table - if credIdx == ALL_INDEX then - device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) - return ALL_INDEX - end - - -- Get latest credential table - local cred_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.credentials.NAME, - {} - )) - - -- Delete an entry from credential table - local userIdx = nil - for index, entry in pairs(cred_table) do - if entry.credentialIndex == credIdx then - table.remove(cred_table, index) - userIdx = entry.userIndex - break - end - end - - device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) - return userIdx -end - -local function delete_credential_from_table_as_user(device, userIdx) - -- If User Index is ALL_INDEX, remove all entry from the table - if userIdx == ALL_INDEX then - device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) - return - end - - -- Get latest credential table - local cred_table = device:get_latest_state( - "main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.credentials.NAME - ) or {} - local new_cred_table = {} - - -- Re-create credential table - for index, entry in pairs(cred_table) do - if entry.userIndex ~= userIdx then - table.insert(new_cred_table, entry) - end - end - - device:emit_event(capabilities.lockCredentials.credentials(new_cred_table, {visibility = {displayed = false}})) -end - -function lock_table_utils.add_credential(device, user_index, credential_type, credential_index) - local credentials = lock_table_utils.get_cached_credentials(device) - - table.insert(credentials, { userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type }) - device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) - - local credentials = lock_table_utils.prep_table(lock_table_utils.get_cached_credentials(device)) - device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) - device:emit_event(capabilities.lockCredentials.credentials(credentials, - { state_change = true, visibility = { displayed = true } })) -end - -function lock_table_utils.delete_credential(device, credential_index) - local credentials = lock_table_utils.get_cached_credentials(device) - local status_code = lock_table_utils.STATUS_FAILURE - - for index, credential in pairs(credentials) do - if credential.credentialIndex == credential_index then - lock_table_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_table_utils.LOCK_CREDENTIALS, credentials) - status_code = lock_table_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. -function lock_table_utils.prep_table(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 - -function lock_table_utils.send_events(device, capability) - if capability == capabilities.lockUsers then - local current_users = lock_table_utils.prep_table(lock_table_utils.get_cached_users(device)) - device:set_field(lock_table_utils.LOCK_USERS, current_users) - device:emit_event(capabilities.lockUsers.users(current_users, - {state_change = true, visibility = { displayed = true } })) - elseif capability == capabilities.lockCredentials then - local credentials = lock_table_utils.prep_table(lock_table_utils.get_cached_credentials(device)) - device:set_field(lock_table_utils.LOCK_CREDENTIALS, credentials) - device:emit_event(capabilities.lockCredentials.credentials(credentials, - { state_change = true, visibility = { displayed = true } })) - end -end - - -return lock_table_utils diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index 79d4bfa5fa..fdb7c514aa 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -10,7 +10,8 @@ local cluster_base = require "st.zigbee.cluster_base" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Lock = capabilities.lock -local lock_utils = require "zigbee_lock_utils" +local constants = require "lock_handlers.constants" +local lock_utils = require "lock_handlers.utils" local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F local SAMSUNG_SDS_MFR_CODE = 0x0003 @@ -54,7 +55,7 @@ local function emit_event_if_latest_state_missing(device, component, capability, end local function load_device_state(device) - local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + local slga_migrated = device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) or false if slga_migrated then lock_utils.reload_tables(device) else diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua index db6e13e8c0..1279d71083 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua @@ -12,7 +12,7 @@ local capabilities = require "st.capabilities" local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType -local lock_utils = require "zigbee_lock_utils" +local lock_utils = require "lock_handlers.utils" local test_credential_index = 1 local test_credentials = {} diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua index be2197030c..4b04b2aaf6 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua @@ -2,8 +2,8 @@ -- Licensed under the Apache License, Version 2.0 local yale_fingerprint_lock_models = function(opts, driver, device) - local lock_utils = require "zigbee_lock_utils" - local slga_migrated = device:get_field(lock_utils.SLGA_MIGRATED) or false + local constants = require "lock_handlers.constants" + local slga_migrated = device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) or false if not slga_migrated then return false end local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do diff --git a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua deleted file mode 100644 index 1ef3f7e4c9..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/zigbee_lock_utils.lua +++ /dev/null @@ -1,220 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local lock_table_utils = require "lock_tables" -local LockCluster = clusters.DoorLock -local INITIAL_INDEX = 1 - -local lock_utils = {} - -lock_utils.DRIVER_STATE = { - BUSY = "busy", - COMMAND_IN_PROGRESS = "commandInProgress", - LOCK_USERS = "lockUsers", - LOCK_CREDENTIALS = "lockCredentials", - SLGA_MIGRATED = "slgaMigrated", - CURRENT_CREDENTIAL = "currentCredential", - CURRENT_CREDENTIAL_INDEX = "currentCredentialIndex", -} - -lock_utils.USER_DATA = { - INDEX = "userIndex", - NAME = "userName", - TYPE = "userType" -} - -lock_utils.PIN_STATUS = { - SUCCESS = "success", - FAILURE = "failure", - DUPLICATE = "duplicate", - OCCUPIED = "occupied", - INVALID_COMMAND = "invalidCommand", - RESOURCE_EXHAUSTED = "resourceExhausted", - BUSY = "busy" -} - -lock_utils.LOCK_CREDENTIALS_CMD = { - ADD = "addCredential", - UPDATE = "updateCredential", - DELETE = "deleteCredential", - DELETE_ALL = "deleteAllCredentials" -} - -lock_utils.LOCK_USERS_CMD = { - ADD = "addUser", - UPDATE = "updateUser", - DELETE = "deleteUser", - DELETE_ALL = "deleteAllUsers" -} - -lock_utils.ZIGBEE_DELETE_ALL_USERS = 0xFF -lock_utils.CRED_TYPE_PIN = "pin" - -local function tbl_contains(array, value) - if value == nil then return false end - for _, element in pairs(array or {}) do - if element == value then - return true - end - end - return false -end - -function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) - local info = additional_info or {} - info.commandName = command_name - info.statusCode = status_code - device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) -end - --- Check if we are currently busy performing a task (or have timed out). If we are busy, --- emit the appropriate events to notify the user and return true. If not, set the current command --- as in progress and set busy to the current time. -function lock_utils.is_device_busy(device, command) - local c_time = os.time() - local busy_since = device:get_field(lock_utils.DRIVER_STATE.BUSY) or false - - if (busy_since == false) or (c_time - busy_since > 10) then - device:set_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS, command) - device:set_field(lock_utils.DRIVER_STATE.BUSY, c_time) - return false - elseif tbl_contains(lock_utils.LOCK_USERS_CMD, command) then - lock_utils.emit_command_result(device, capabilities.lockUsers, command, lock_utils.PIN_STATUS.BUSY) - elseif tbl_contains(lock_utils.LOCK_CREDENTIALS_CMD, command) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, command, lock_utils.PIN_STATUS.BUSY) - end - return true -end - -function lock_utils.clear_busy_state(device, status) - local command = device:get_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS) or {} - local current_credential = device:get_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL) or {} - - if tbl_contains(lock_utils.LOCK_USERS_CMD, command) then - lock_utils.emit_command_result(device, capabilities.lockUsers, command, status, { userIndex = current_credential.userIndex }) - elseif tbl_contains(lock_utils.LOCK_CREDENTIALS_CMD, command) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, command, status, { userIndex = current_credential.userIndex }) - end - - device:set_field(lock_utils.DRIVER_STATE.CURRENT_CREDENTIAL, nil) - device:set_field(lock_utils.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) - device:set_field(lock_utils.DRIVER_STATE.BUSY, false) -end - - -function lock_utils.reload_all_codes(device) - - -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - - -- If we are missing the cached values for these attributes, read them so we can properly manage them locally - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then - device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then - device:send(LockCluster.attributes.MinPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - end - - -- TODO: check what this is doing - if (device:get_field(lock_utils.CHECKING_CODE) == nil) then - device:set_field(lock_utils.CHECKING_CODE, 1) - end - - -- TODO: check what this is doing - device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) -end - - -function lock_utils.get_next_available_user_index(device) - - local used_indices = {} - local current_users = lock_table_utils.get_cached_users(device) - for _, user in pairs(current_users) do - used_indices[user.userIndex] = true - end - - local next_available_user_index - if current_users == {} then - next_available_user_index = INITIAL_INDEX - else - local total_supported_indices = device:get_latest_state("main", - capabilities.lockUsers.ID, - capabilities.lockUsers.totalUsersSupported.NAME - ) or 0 - for checked_index = INITIAL_INDEX, total_supported_indices do - if used_indices[checked_index] == nil then - next_available_user_index = checked_index - break - end - end - end - return next_available_user_index -end - -function lock_utils.get_next_available_credential_index(device) - - local used_indices = {} - local current_credentials = lock_table_utils.get_cached_credentials(device) - for _, credential in pairs(current_credentials) do - used_indices[credential.credentialIndex] = true - end - - local next_available_credential_index - if current_credentials == {} then - next_available_credential_index = INITIAL_INDEX - else - local total_supported_indices = device:get_latest_state("main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.pinUsersSupported.NAME - ) or 0 - for index = INITIAL_INDEX, total_supported_indices do - if used_indices[index] == nil then - next_available_credential_index = index - break - end - end - end - return next_available_credential_index -end - - -function lock_utils.delete_user(device, user_index) - local current_users = lock_table_utils.get_cached_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 - -function lock_utils.update_credential(device, credential_index, user_index, credential_type) - local credentials = lock_table_utils.get_cached_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 - - -return lock_utils From 3d7ba5f8a0b93769d857ba78c4e4fc7db39ac4bc Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sat, 9 May 2026 21:14:40 -0500 Subject: [PATCH 11/33] add constants --- .../zigbee-lock/src/lock_handlers/attributes.lua | 8 ++++---- .../zigbee-lock/src/lock_handlers/commands.lua | 16 +++++++++------- .../zigbee-lock/src/lock_handlers/constants.lua | 2 ++ .../zigbee-lock/src/lock_handlers/utils.lua | 7 +++---- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua index da5bc9e069..f5cbc6ce6a 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua @@ -2,6 +2,7 @@ -- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" +local constants = require "lock_handlers.constants" local socket = require "cosock.socket" local AttributeHandlers = {} @@ -19,14 +20,13 @@ function AttributeHandlers.lock_state(driver, device, value, zb_rx) -- this is where we decide whether or not we need to delay our lock event because we've -- observed it coming before the event (or we're starting to compute the timer) - local DELAY_LOCK_EVENT, MAX_DELAY = "_delay_lock_event", 10 - local delay = device:get_field(DELAY_LOCK_EVENT) or 100 - if (delay < MAX_DELAY) then + local delay = device:get_field(constants.DELAY_LOCK_EVENT) or 100 + if (delay < constants.MAX_DELAY) then device.thread:call_with_delay(delay+.5, function () device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) end) else - device:set_field(DELAY_LOCK_EVENT, socket.gettime()) + device:set_field(constants.DELAY_LOCK_EVENT, socket.gettime()) device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua index 76bfaa9282..1b52d1594c 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -1,12 +1,14 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" local socket = require "cosock.socket" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local constants = require "lock_handlers.constants" local lock_utils = require "lock_handlers.utils" -local tables = require "lock_handlers.tables" -local constants = require "lock_handlers.constants" +local tables = require "lock_handlers.tables" local DELAY_LOCK_EVENT = "_delay_lock_event" local MAX_DELAY = 10 @@ -227,10 +229,10 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), capabilities.lock.ID, capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local preceding_event_time = device:get_field(constants.DELAY_LOCK_EVENT) or 0 local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) + if time_diff < constants.MAX_DELAY then + device:set_field(constants.DELAY_LOCK_EVENT, time_diff) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua index b7d1d9bf80..38e6d4bc53 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua @@ -40,5 +40,7 @@ lock_constants.LOCK_USERS = { } lock_constants.CRED_TYPE_PIN = "pin" +lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" +lock_constants.MAX_DELAY = 10 return lock_constants diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua index f4cae92023..3f06b122b5 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua @@ -1,13 +1,12 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local clusters = require "st.zigbee.zcl.clusters" +local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" -local constants = require "lock_handlers.constants" -local tables = require "lock_handlers.tables" +local constants = require "lock_handlers.constants" -local lock_utils = {} +local lock_utils = {} -- [[ BUSY STATE MANAGEMENT ]] -- From a568835cc7ba13777b7ecd7786d2fb73ca0ae6af Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sat, 9 May 2026 22:58:01 -0500 Subject: [PATCH 12/33] update consts, add test_lock_tables file --- .../src/lock_handlers/commands.lua | 4 - .../zigbee-lock/src/lock_handlers/tables.lua | 32 +- .../zigbee-lock/src/test/test_lock_tables.lua | 382 ++++++++++++++++++ 3 files changed, 398 insertions(+), 20 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua index 1b52d1594c..af88cd62b7 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -10,10 +10,6 @@ local constants = require "lock_handlers.constants" local lock_utils = require "lock_handlers.utils" local tables = require "lock_handlers.tables" -local DELAY_LOCK_EVENT = "_delay_lock_event" -local MAX_DELAY = 10 - - local ResponseHandlers = {} diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua index 360a644312..b499d7cbca 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua @@ -3,7 +3,7 @@ local capabilities = require "st.capabilities" local st_utils = require "st.utils" -local constants = require "lock_handlers.constants" +local consts = require "lock_handlers.constants" local table_utils = {} @@ -78,16 +78,16 @@ end -- entries that exceed the limit are not added. function table_utils.add_entry(device, table_name, entry) local def = resolve_table_def(device, table_name) - if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end - if not validate_entry(device, entry, def.required_keys) then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not def then return consts.COMMAND_RESULT.FAILURE end + if not validate_entry(device, entry, def.required_keys) then return consts.COMMAND_RESULT.FAILURE end local t = table_utils.get_state(device, def) - if not t then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not t then return consts.COMMAND_RESULT.FAILURE end if #t >= table_utils.get_max_entries(device, table_name) then device.log.warn(string.format( "table_helpers: cannot add entry to %q, max entries reached", table_name )) - return constants.COMMAND_RESULT_STATUS.RESOURCE_EXHAUSTED + return consts.COMMAND_RESULT.RESOURCE_EXHAUSTED end -- Object entry: skip if an entry with the same match_key value already exists @@ -98,7 +98,7 @@ function table_utils.add_entry(device, table_name, entry) "table_helpers: entry with %s == %s already exists in %q, skipping", def.match_key, tostring(entry[def.match_key]), table_name )) - return constants.COMMAND_RESULT_STATUS.OCCUPIED + return consts.COMMAND_RESULT.OCCUPIED end end end @@ -106,7 +106,7 @@ function table_utils.add_entry(device, table_name, entry) table.insert(t, st_utils.deep_copy(entry)) device:emit_event(def.attribute(t, {visibility = {displayed = false}})) - return constants.COMMAND_RESULT_STATUS.SUCCESS + return consts.COMMAND_RESULT.SUCCESS end @@ -114,9 +114,9 @@ end --- The entry to update is identified by the match_key parameter in DEFS. function table_utils.update_entry(device, table_name, match_value, updates) local def = resolve_table_def(device, table_name) - if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not def then return consts.COMMAND_RESULT.FAILURE end local t = table_utils.get_state(device, def) - if not t then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not t then return consts.COMMAND_RESULT.FAILURE end for _, entry in ipairs(t) do if entry[def.match_key] == match_value then @@ -124,7 +124,7 @@ function table_utils.update_entry(device, table_name, match_value, updates) entry[k] = v end device:emit_event(def.attribute(t, {visibility = {displayed = false}})) - return constants.COMMAND_RESULT_STATUS.SUCCESS + return consts.COMMAND_RESULT.SUCCESS end end @@ -132,7 +132,7 @@ function table_utils.update_entry(device, table_name, match_value, updates) "table_helpers: no entry found in %q with %s == %s", table_name, def.match_key, tostring(match_value) )) - return constants.COMMAND_RESULT_STATUS.FAILURE + return consts.COMMAND_RESULT.FAILURE end @@ -141,9 +141,9 @@ end -- Returns the deleted entry, or FAILURE if nothing matched. function table_utils.delete_entry(device, table_name, matcher) local def = resolve_table_def(device, table_name) - if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not def then return consts.COMMAND_RESULT.FAILURE end local t = table_utils.get_state(device, def) - if not t then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not t then return consts.COMMAND_RESULT.FAILURE end local predicate = function(entry) return entry[def.match_key] == matcher end @@ -155,15 +155,15 @@ function table_utils.delete_entry(device, table_name, matcher) end end device:emit_event(def.attribute(t, {visibility = {displayed = false}})) - return removed or constants.COMMAND_RESULT_STATUS.FAILURE + return removed or consts.COMMAND_RESULT.FAILURE end -- Delete all entries from a table. function table_utils.delete_all_entries(device, table_name) local def = resolve_table_def(device, table_name) - if not def then return constants.COMMAND_RESULT_STATUS.FAILURE end + if not def then return consts.COMMAND_RESULT.FAILURE end device:emit_event(def.attribute({}, {visibility = {displayed = false}})) - return constants.COMMAND_RESULT_STATUS.SUCCESS + return consts.COMMAND_RESULT.SUCCESS end return table_utils diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua new file mode 100644 index 0000000000..6396dbfcb2 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -0,0 +1,382 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Unit tests for lock_handlers/tables.lua +-- Tests directly call table_utils functions and verify both return values +-- and emitted capability events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local table_utils = require "lock_handlers.tables" +local constants = require "lock_handlers.constants" + +-- --------------------------------------------------------------------------- +-- Shared mock device +-- --------------------------------------------------------------------------- + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- Seed the users table with `entries` and consume the resulting emit_events. +-- After this call the state cache has those entries and the socket is clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + -- Build the expected post-insert table up to this entry. + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for entry userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Seed the credentials table with `entries` and consume the resulting emit_events. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for entry credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- =========================================================================== +-- add_entry +-- =========================================================================== + +test.register_coroutine_test( + "add_entry: adds a new user entry and emits the updated table", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "add_entry: returns OCCUPIED when an entry with the same userIndex already exists", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- Attempt to add a different entry with the same userIndex (match_key) + local duplicate = { userIndex = 1, userType = "unrestricted", userName = "Bob" } + local result = table_utils.add_entry(mock_device, "users", duplicate) + assert(result == constants.COMMAND_RESULT.OCCUPIED, + "Expected OCCUPIED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns RESOURCE_EXHAUSTED when table is at max capacity", + function() + local entries = {} + for i = 1, 20 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local overflow = { userIndex = 21, userType = "guest", userName = "Overflow" } + local result = table_utils.add_entry(mock_device, "users", overflow) + assert(result == constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + "Expected RESOURCE_EXHAUSTED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE when a required key is missing", + function() + -- userType is required for users table + local incomplete = { userIndex = 1 } + local result = table_utils.add_entry(mock_device, "users", incomplete) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.add_entry(mock_device, "nonexistent_table", + { userIndex = 1, userType = "guest" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: adds a credential entry and emits updated credentials table", + function() + local entry = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +-- =========================================================================== +-- update_entry +-- =========================================================================== + +test.register_coroutine_test( + "update_entry: updates an existing user entry and emits updated table", + function() + local original = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ original }) + + local expected_after_update = { + { userIndex = 1, userType = "adminMember", userName = "Alice_Updated" } + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_after_update, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 1, + { userName = "Alice_Updated", userType = "adminMember" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.update_entry(mock_device, "users", 99, + { userName = "Ghost" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: only updates the specified fields, leaves others intact", + function() + local entry1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local entry2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ entry1, entry2 }) + + -- Update only userName of entry 2; userType should remain "guest" + local expected = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 2, userType = "guest", userName = "Bob_Renamed" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 2, { userName = "Bob_Renamed" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.update_entry(mock_device, "bad_table", 1, { userName = "X" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_entry +-- =========================================================================== + +test.register_coroutine_test( + "delete_entry: deletes an existing entry and returns the removed entry", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- After deletion the table is empty + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 1) + -- delete_entry returns the removed entry on success + assert(type(result) == "table", + "Expected deleted entry table, got: " .. tostring(result)) + assert(result.userIndex == 1, + "Expected userIndex == 1 in deleted entry, got: " .. tostring(result.userIndex)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE when no entry matches the match_key value", + function() + -- Table is empty; delete_entry always emits even on miss + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 99) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: remaining entries are intact after a deletion", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + -- Delete the middle entry; expect e1 and e3 remain + local expected_remaining = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 3, userType = "guest", userName = "Carol" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_remaining, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 2) + assert(type(result) == "table" and result.userIndex == 2, + "Expected deleted entry with userIndex==2, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_entry(mock_device, "bad_table", 1) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_all_entries +-- =========================================================================== + +test.register_coroutine_test( + "delete_all_entries: emits an empty users table and returns SUCCESS", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_all_entries: returns SUCCESS even when the table is already empty", + function() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_all_entries: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_all_entries(mock_device, "bad_table") + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: emits an empty credentials table and returns SUCCESS", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + seed_credentials({ cred }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "credentials") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.run_registered_tests() From e97ffc95223a2999a2481f0a5c89bf1a0e2a99ac Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sun, 10 May 2026 00:31:57 -0500 Subject: [PATCH 13/33] use more specific response handlers, ignore programming event more clearly --- drivers/SmartThings/zigbee-lock/src/init.lua | 3 +- .../src/lock_handlers/capabilities.lua | 9 - .../src/lock_handlers/commands.lua | 301 +++++++++--------- .../zigbee-lock/src/lock_handlers/utils.lua | 4 +- 4 files changed, 156 insertions(+), 161 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index f97640eebd..00460604b6 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -72,7 +72,8 @@ local zigbee_lock_driver = { [clusters.DoorLock.client.commands.ClearPINCodeResponse.ID] = command_handlers.clear_pin_code_response, [clusters.DoorLock.client.commands.GetPINCodeResponse.ID] = command_handlers.get_pin_code_response, [clusters.DoorLock.client.commands.ProgrammingEventNotification.ID] = command_handlers.programming_event_notification, - [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = command_handlers.operating_event_notification + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = command_handlers.operating_event_notification, + [clusters.DoorLock.client.commands.SetPINCodeResponse.ID] = command_handlers.set_pin_code_response, } }, attr = { diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index 8a8dc005dc..69b67efefe 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -100,9 +100,6 @@ function CapabilityHandlers.add_credential(driver, device, command) UserTypeEnum.UNRESTRICTED, command.args.credentialData) ) - device.thread:call_with_delay(4, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.userIndex)) - end) end end @@ -117,9 +114,6 @@ function CapabilityHandlers.update_credential(driver, device, command) UserTypeEnum.UNRESTRICTED, command.args.credentialData) ) - device.thread:call_with_delay(4, function() - device:send(LockCluster.server.commands.GetPINCode(device, command.args.userIndex)) - end) end end @@ -134,9 +128,6 @@ function CapabilityHandlers.delete_credential(driver, device, command) lock_utils.set_busy_state(device, command.args.deleteUser or constants.LOCK_CREDENTIALS.DELETE, command.args) device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) device:send(LockCluster.server.commands.ClearPINCode(device, command.args.credentialIndex)) - device.thread:call_with_delay(2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.credentialIndex)) - end) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua index af88cd62b7..da8d036c74 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -12,103 +12,15 @@ local tables = require "lock_handlers.tables" local ResponseHandlers = {} +local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus +-- PASS = 0 +-- FAIL = 1 -local UserStatusEnum = clusters.DoorLock.types.DrlkUserStatus --- AVAILABLE = 0, This user slot is empty / unused --- OCCUPIED_ENABLED = 1, A user exists in that slot and is allowed access --- OCCUPIED_DISABLED = 3, A user exists, but access is disabled --- NOT_SUPPORTED = 0xFF, Lock doesn't support reporting/setting this field - -local function pin_response_after_add_credential(device, user_id, user_status, credential_args_in_use) - local status - if user_status == UserStatusEnum.OCCUPIED_ENABLED then - -- The code was successfully added. - status = tables.add_entry(device, tables.DEFS.credentials, { - userIndex = credential_args_in_use.userIndex, - credentialIndex = user_id, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional - }) - elseif user_status == UserStatusEnum.AVAILABLE then - -- We tried to add a code for some user id, but the user status for that id indicates that the location is still available. - -- Therefore, the code was not added. Besides an internal error, this might happen if the credential was a duplicate, so we should remove the user we created for this code since there is no credential after all. - status = constants.COMMAND_RESULT.DUPLICATE - tables.delete_entry(device, tables.DEFS.users, user_id) -- TODO: should we add a command result for lockUsers as well here? - elseif user_status == UserStatusEnum.OCCUPIED_DISABLED then - status = constants.COMMAND_RESULT.RESOURCE_EXHAUSTED -- We can't add the code because the slot is disabled. - elseif user_status == UserStatusEnum.NOT_SUPPORTED then - status = constants.COMMAND_RESULT.INVALID_COMMAND -- The lock doesn't support adding codes at this user index. - else - status = constants.COMMAND_RESULT.FAILURE -- catch-all for any unexpected user status value - end - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.ADD, status) - lock_utils.clear_busy_state(device) -end - -local function pin_response_after_update_credential(device, user_id, user_status, credential_args_in_use) - local status - if user_status == UserStatusEnum.OCCUPIED_ENABLED then - status = tables.update_entry(device, tables.DEFS.credentials, { - userIndex = credential_args_in_use.userIndex, - credentialIndex = user_id, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional - }) - else - -- if no values were returned for any reason, we should remove the credential and associated user - -- from our table since they are not actually present on the lock. - status = constants.COMMAND_RESULT.INVALID_COMMAND - tables.delete_entry(device, tables.DEFS.credentials, user_id) - tables.delete_entry(device, tables.DEFS.users, credential_args_in_use.userIndex) - end - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, status) - lock_utils.clear_busy_state(device) -end - -local function pin_response_after_syncing_code_from_lock(device, user_id) - -- if an entry already exists at this user index, this will be a no-op. This is just meant to populate our tables with the existing codes on the lock, so we don't need to worry about handling updates vs adds here - local status = tables.add_entry(device, tables.DEFS.users, { - userIndex = user_id, - userName = "User " .. user_id, -- generic default, since we didn't explicitly set a name for this code. - userType = "guest", -- also a generic default. - }) - if status == constants.COMMAND_RESULT.SUCCESS then - -- if the entry was successfully added to the user table, we should also add an entry to the credential table for this code. - tables.add_entry(device, tables.DEFS.credentials, { - userIndex = user_id, - credentialIndex = user_id, - credentialType = lock_utils.CREDENTIAL_TYPE, - credentialName = "User " .. user_id, -- also a generic default. - }) - end - if user_id >= tables.get_max_entries(device, tables.DEFS.credentials) then - device:set_field(constants.SYNC.CODE_INDEX, nil) - lock_utils.clear_busy_state(device) - else - local synced_code_index = device:get_field(constants.SYNC.CODE_INDEX) + 1 - device:set_field(constants.SYNC.CODE_INDEX, synced_code_index) - lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK_CMD, { checkingCode = synced_code_index }) - device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) - end -end - -function ResponseHandlers.get_pin_code_response(driver, device, zb_mess) - -- cached values from capability command - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) - local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) - -- response values - local user_id = tonumber(zb_mess.body.zcl_body.user_id.value) - local user_status = zb_mess.body.zcl_body.user_status.value - - if command_in_progress == constants.LOCK_CREDENTIALS.ADD then - pin_response_after_add_credential(device, user_id, user_status, credential_args_in_use) - elseif command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then - pin_response_after_update_credential(device, user_id, user_status, credential_args_in_use) - elseif command_in_progress == constants.SYNC.CODES_FROM_LOCK_CMD then - pin_response_after_syncing_code_from_lock(device, user_id) - end -end - +local SetCodeStatus = clusters.DoorLock.types.DrlkSetCodeStatus +-- SUCCESS = 0 +-- GENERAL_FAILURE = 1 +-- MEMORY_FULL = 2 +-- DUPLICATE_CODE = 3 local ProgrammingEventCodeEnum = clusters.DoorLock.types.ProgramEventCode -- MASTER_CODE_CHANGED = 1 @@ -118,65 +30,83 @@ local ProgrammingEventCodeEnum = clusters.DoorLock.types.ProgramEventCode -- RFID_CODE_ADDED = 5 -- RFID_CODE_DELETED = 6 -function ResponseHandlers.programming_event_notification(driver, device, zb_mess) +local OperationEventCode = clusters.DoorLock.types.OperationEventCode + +function ResponseHandlers.get_pin_code_response(driver, device, zb_rx) -- cached values from capability command - local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) - local credential_args_in_use = device:get_field(lock_utils.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) -- response values - local user_id = tonumber(zb_mess.body.zcl_body.user_id.value) - local event_code = tonumber(zb_mess.body.zcl_body.program_event_code.value) + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) - -- TODO - if user_id >= 256 then -- Index is incorrectly written, attempt to shift it to get an actual value - user_id = user_id >> 8 + if command_in_progress == constants.SYNC.CODES_FROM_LOCK_CMD then + -- if an entry already exists at this user index, this will be a no-op. + -- This is just meant to populate our tables with the existing codes on the lock, + -- so we don't need to worry about handling updates vs adds here + local status = tables.add_entry(device, tables.DEFS.users, { + userIndex = user_id, + userName = "User " .. user_id, -- generic default, since we didn't explicitly set a name for this code. + userType = "guest", -- also a generic default. + }) + if status == constants.COMMAND_RESULT.SUCCESS then + -- if the entry was successfully added to the user table, we should also add an entry to the credential table for this code. + tables.add_entry(device, tables.DEFS.credentials, { + userIndex = user_id, + credentialIndex = user_id, + credentialType = lock_utils.CREDENTIAL_TYPE, + credentialName = "User " .. user_id, -- also a generic default. + }) + end + if user_id >= tables.get_max_entries(device, tables.DEFS.credentials) then + device:set_field(constants.SYNC.CODE_INDEX, nil) + lock_utils.clear_busy_state(device) + else + local synced_code_index = device:get_field(constants.SYNC.CODE_INDEX) + 1 + device:set_field(constants.SYNC.CODE_INDEX, synced_code_index) + lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK_CMD, { checkingCode = synced_code_index }) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) + end end +end - if event_code == ProgrammingEventCodeEnum.PIN_CODE_ADDED then - if command_in_progress == constants.LOCK_CREDENTIALS.ADD then - -- if we just added/updated a code and got this event, we know that the get_pin_code_response handler will handle - -- updating our tables and emitting command results, so we should return here and avoid duplicating any events. - return +function ResponseHandlers.programming_event_notification(driver, device, zb_rx) + -- if the device is busy, one of our capability commands is in progress, so ignore this response to avoid duplicating the response. + if lock_utils.is_device_busy(device) then return end + + -- response values + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + local event_code = tonumber(zb_rx.body.zcl_body.program_event_code.value) + + if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then + if user_id >= 256 then -- Index is incorrectly written on these devices. Attempt to shift it to get an actual value + user_id = user_id >> 8 end + end + + if event_code == ProgrammingEventCodeEnum.PIN_CODE_ADDED then + -- try to add a new entry to our tables for this code. + -- if an entry already exists for this user index, this will be a no-op. tables.add_entry(device, tables.DEFS.users, { - userIndex = credential_args_in_use.userIndex, - userName = credential_args_in_use.credentialName or ("User " .. user_id), -- if we have a name for this code, use it, otherwise default to a generic name - userType = "guest", -- we don't have a way to get the user type from the lock, so just default to guest + userIndex = user_id, + userName = "User " .. user_id, -- default + userType = "guest", -- default }) tables.add_entry(device, tables.DEFS.credentials, { - userIndex = credential_args_in_use.userIndex, + userIndex = user_id, credentialIndex = user_id, credentialType = lock_utils.CREDENTIAL_TYPE, - credentialName = credential_args_in_use.credentialName or ("User " .. user_id), -- if we have a name for this code, use it, otherwise default to a generic name + credentialName = "User " .. user_id, -- default }) elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_DELETED then - if command_in_progress == constants.LOCK_USERS.DELETE or command_in_progress == constants.LOCK_CREDENTIALS.DELETE then - -- if we just deleted a code and got this event, we know that the clear_pin_code_response handler will handle - -- updating our tables and emitting command results, so we should return here and avoid duplicating any events. - return - end - -- if this did not get triggered by one of our delete commands, then we should remove the code from our tables since it has been deleted from the lock. + -- try to delete the entries in our tables corresponding to this code. + -- if no entries exist for this user index, this will be a no-op. tables.delete_entry(device, tables.DEFS.users, user_id) tables.delete_entry(device, tables.DEFS.credentials, user_id) - elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_CHANGED then - if command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then - -- if we just updated a code and got this event, we know that the get_pin_code_response handler will handle - -- updating our tables and emitting command results, so we should return here and avoid duplicating any events. - return - end - -- if this did not get triggered by one of our update commands, then we should update the code in our tables since it has been updated on the lock. - tables.update_entry(device, tables.DEFS.credentials, { - userIndex = credential_args_in_use.userIndex, - credentialIndex = user_id, - credentialType = lock_utils.CREDENTIAL_TYPE, - credentialName = credential_args_in_use.credentialName or ("User " .. user_id), -- if we have a name for this code, use it, otherwise default to a generic name - }) end end function ResponseHandlers.operating_event_notification(driver, device, zb_rx) local event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) local source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" local METHOD = { [0] = "keypad", [1] = "command", @@ -236,23 +166,49 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) end end -function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_mess) - -- We can now safely clear all credentials, as well as users, from our tables, since the lock has confirmed that all codes have been deleted. - local status - status = tables.delete_all_entries(device, tables.DEFS.users) - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, status) - status = tables.delete_all_entries(device, tables.DEFS.credentials) - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, status) + +function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_rx) + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + + -- apply result and identify command result status + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + user_status = tables.delete_all_entries(device, tables.DEFS.users) + credential_status = tables.delete_all_entries(device, tables.DEFS.credentials) + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = constants.COMMAND_RESULT.FAILURE + credential_status = constants.COMMAND_RESULT.FAILURE + end + + -- emit command results + local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) + if command_in_progress == constants.LOCK_USERS.DELETE_ALL then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, user_status) + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE_ALL then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + end lock_utils.clear_busy_state(device) end -function ResponseHandlers.clear_pin_code_response(driver, device, zb_mess) - -- cached values from capability command - local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) - local credential_args_in_use = device:get_field(lock_utils.CREDENTIAL_ARGS_IN_USE) - local user_status = tables.delete_entry(device, tables.DEFS.users, credential_args_in_use.userIndex) - local credential_status = tables.delete_entry(device, tables.DEFS.credentials, credential_args_in_use.credentialIndex) +function ResponseHandlers.clear_pin_code_response(driver, device, zb_rx) + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + + -- apply result and identify command result status + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + local credential_args_in_use = device:get_field(lock_utils.CREDENTIAL_ARGS_IN_USE) + user_status = tables.delete_entry(device, tables.DEFS.users, credential_args_in_use.userIndex) + credential_status = tables.delete_entry(device, tables.DEFS.credentials, credential_args_in_use.credentialIndex) + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = constants.COMMAND_RESULT.FAILURE + credential_status = constants.COMMAND_RESULT.FAILURE + end + + -- emit command results + local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) if command_in_progress == constants.LOCK_USERS.DELETE then -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, user_status) @@ -260,18 +216,63 @@ function ResponseHandlers.clear_pin_code_response(driver, device, zb_mess) elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE then lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status) end + lock_utils.clear_busy_state(device) end + +function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + -- zb response values + local set_pin_code_status = zb_rx.body.zcl_body.status.value + -- mapped failures states + local RESPONSE_RESULT_MAP = { + [SetCodeStatus.GENERAL_FAILURE] = constants.COMMAND_RESULT.FAILURE, + [SetCodeStatus.MEMORY_FULL] = constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + [SetCodeStatus.DUPLICATE_CODE] = constants.COMMAND_RESULT.DUPLICATE, + } + + -- apply result based on response and identify command result status + local result_status + if set_pin_code_status == SetCodeStatus.SUCCESS then + if command_in_progress == constants.LOCK_CREDENTIALS.ADD then + result_status = tables.add_entry(device, tables.DEFS.credentials, { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.userIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + elseif command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then + result_status = tables.update_entry(device, tables.DEFS.credentials, { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.userIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + end + elseif RESPONSE_RESULT_MAP[set_pin_code_status] then + result_status = RESPONSE_RESULT_MAP[set_pin_code_status] + else + result_status = constants.COMMAND_RESULT.FAILURE + end + + -- emit command result + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status) + lock_utils.clear_busy_state(device) +end + + -- [[ ALARMS CLUSTER COMMANDS ]] -- -function ResponseHandlers.alarm(driver, device, zb_mess) +function ResponseHandlers.alarm(driver, device, zb_rx) local ALARM_REPORT = { [0] = capabilities.lock.lock.unknown(), [1] = capabilities.lock.lock.unknown(), -- Events 16-19 are low battery events, but are presented as descriptionText only } - if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then - device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) + if (ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value]) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua index 3f06b122b5..6985e3d34f 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua @@ -67,7 +67,9 @@ function lock_utils.emit_command_result(device, capability, command_name, status local info = additional_info or {} info.commandName = command_name info.statusCode = status_code - device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) + if capability then + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) + end end return lock_utils From 1cf2a33fe710ae5dc2a44fd2cd2a6e283f10bc38 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sun, 10 May 2026 01:33:28 -0500 Subject: [PATCH 14/33] more fixups, with new tests added --- .../src/lock_handlers/capabilities.lua | 188 +++-- .../src/lock_handlers/commands.lua | 93 ++- .../test/test_lock_credentials_commands.lua | 575 +++++++++++++++ .../src/test/test_lock_pre_configured.lua | 667 ++++++++++++++++++ .../src/test/test_lock_users_commands.lua | 438 ++++++++++++ 5 files changed, 1874 insertions(+), 87 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index 69b67efefe..ba9d44be76 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -30,27 +30,44 @@ end -- [[ LOCK USERS CAPABILITY COMMANDS ]] -- function CapabilityHandlers.add_user(driver, device, command) - local status = lock_utils.is_device_busy(device) and constants.COMMAND_RESULT.BUSY or - tables.add_entry(device, tables.DEFS.users, - { userName = command.args.userName, userType = command.args.userType } - ) - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.ADD, status) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.ADD, constants.COMMAND_RESULT.BUSY) + return + end + + -- Find the smallest positive userIndex not already in the table + local existing = tables.get_state(device, tables.DEFS.users) or {} + local occupied = {} + for _, u in ipairs(existing) do occupied[u.userIndex] = true end + local next_index = 1 + while occupied[next_index] do next_index = next_index + 1 end + + local status = tables.add_entry(device, "users", { + userIndex = next_index, + userName = command.args.userName, + userType = command.args.userType, + }) + local additional_info = status == constants.COMMAND_RESULT.SUCCESS and { userIndex = next_index } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.ADD, status, additional_info) end function CapabilityHandlers.update_user(driver, device, command) local status = lock_utils.is_device_busy(device) and constants.COMMAND_RESULT.BUSY or - tables.update_entry(device, tables.DEFS.users, + tables.update_entry(device, "users", command.args.userIndex, { userName = command.args.userName, userType = command.args.userType } ) - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.UPDATE, status) + local additional_info = status == constants.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.UPDATE, status, additional_info) end function CapabilityHandlers.delete_user(driver, device, command) - -- Note: We are going to hold off on clearing the user until we get - -- the response from the lock confirming that the user has been deleted + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, constants.COMMAND_RESULT.BUSY) + return + end - local get_credential_index_from_associated_user_index = function(device, user_index) + local get_credential_for_user = function(device, user_index) local credentials = tables.get_state(device, tables.DEFS.credentials) if not credentials then return end for _, credential in pairs(credentials) do @@ -60,29 +77,45 @@ function CapabilityHandlers.delete_user(driver, device, command) end end - local associated_credential_index = get_credential_index_from_associated_user_index(device, command.args.userIndex) + local associated_credential_index = get_credential_for_user(device, command.args.userIndex) if associated_credential_index then + -- Set busy state with the full user+credential context BEFORE injecting. + -- Injected capability commands are schema-validated, so extra args like userIndex + -- would be stripped. By setting device fields here we preserve the full context. + lock_utils.set_busy_state(device, constants.LOCK_USERS.DELETE, { + userIndex = command.args.userIndex, + credentialIndex = associated_credential_index, + credentialType = constants.CRED_TYPE_PIN, + }) driver:inject_capability_command(device, { capability = capabilities.lockCredentials.ID, - command = capabilities.lockCredentials.commands.deleteCredential.NAME, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, args = { credentialIndex = associated_credential_index, - credentialType = constants.CRED_TYPE_PIN, - deleteUser = constants.LOCK_USERS.DELETE -- This is injected for logging purposes in the command result + credentialType = constants.CRED_TYPE_PIN, } }) + else + -- No associated credential: delete the user entry directly and report the result + local removed = tables.delete_entry(device, "users", command.args.userIndex) + local status = type(removed) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE + local additional_info = status == constants.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, status, additional_info) end end function CapabilityHandlers.delete_all_users(driver, device, command) - -- Note: We are going to hold off on clearing the users table until we get the response from the lock confirming that all users have been deleted + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) + return + end + -- Set busy state with DELETE_ALL context BEFORE injecting so the response handler + -- knows to clear both tables and emit results for both capabilities. + lock_utils.set_busy_state(device, constants.LOCK_USERS.DELETE_ALL, {}) driver:inject_capability_command(device, { capability = capabilities.lockCredentials.ID, - command = capabilities.lockCredentials.commands.deleteCredential.NAME, - args = { - credentialType = constants.CRED_TYPE_PIN, - deleteAllUsers = constants.LOCK_USERS.DELETE_ALL -- This is injected for logging purposes in the command result - } + command = capabilities.lockCredentials.commands.deleteAllCredentials.NAME, + args = { credentialType = constants.CRED_TYPE_PIN } }) end @@ -92,54 +125,109 @@ end function CapabilityHandlers.add_credential(driver, device, command) if lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.ADD, constants.COMMAND_RESULT.BUSY) - else - lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.ADD, command.args) - device:send(LockCluster.server.commands.SetPINCode(device, - command.args.userIndex, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - command.args.credentialData) - ) + return + end + + -- A userIndex of 0 means "auto-assign the next available slot" + local user_index = command.args.userIndex + if user_index == 0 then + local existing = tables.get_state(device, tables.DEFS.credentials) or {} + local occupied = {} + for _, c in ipairs(existing) do occupied[c.credentialIndex] = true end + user_index = 1 + while occupied[user_index] do user_index = user_index + 1 end end + + -- Store credentialIndex alongside userIndex; for zigbee DoorLock they are the same slot. + lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.ADD, { + userIndex = user_index, + credentialIndex = user_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(LockCluster.server.commands.SetPINCode(device, + user_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) end function CapabilityHandlers.update_credential(driver, device, command) if lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, constants.COMMAND_RESULT.BUSY) - else - lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.UPDATE, command.args) - device:send(LockCluster.server.commands.SetPINCode(device, - command.args.userIndex, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - command.args.credentialData) - ) + return + end + + local credentials = tables.get_state(device, tables.DEFS.credentials) or {} + local found = false + for _, cred in ipairs(credentials) do + if cred.credentialIndex == command.args.credentialIndex then found = true; break end end + if not found then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, constants.COMMAND_RESULT.FAILURE) + return + end + + lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.UPDATE, { + userIndex = command.args.userIndex, + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.userIndex, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) end function CapabilityHandlers.delete_credential(driver, device, command) - if lock_utils.is_device_busy(device) then - if command.args.deleteUser then - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, constants.COMMAND_RESULT.BUSY) - else - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.BUSY) - end + local cmd_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == constants.LOCK_USERS.DELETE then + -- Injected by deleteUser; busy state was already set with the full user+credential context. + -- command.args may not resolve named keys when injected, so read from the stored field instead. + local credential_args = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, credential_args.credentialIndex)) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.BUSY) else - lock_utils.set_busy_state(device, command.args.deleteUser or constants.LOCK_CREDENTIALS.DELETE, command.args) + -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. + local credentials = tables.get_state(device, tables.DEFS.credentials) or {} + local found_cred = nil + for _, cred in ipairs(credentials) do + if cred.credentialIndex == command.args.credentialIndex then + found_cred = cred + break + end + end + if not found_cred then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.FAILURE) + return + end + lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.DELETE, { + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + userIndex = found_cred.userIndex, + }) device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) device:send(LockCluster.server.commands.ClearPINCode(device, command.args.credentialIndex)) end end function CapabilityHandlers.delete_all_credentials(driver, device, command) - if lock_utils.is_device_busy(device) then - if command.args.deleteAllUsers then - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) - else - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) - end + local cmd_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == constants.LOCK_USERS.DELETE_ALL then + -- Injected by deleteAllUsers; busy state was already set with DELETE_ALL context. + device:send(LockCluster.server.commands.ClearAllPINCodes(device)) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) else - lock_utils.set_busy_state(device, command.args.deleteAllUsers or constants.LOCK_CREDENTIALS.DELETE_ALL, command.args) + lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.DELETE_ALL, command.args) device:send(LockCluster.server.commands.ClearAllPINCodes(device)) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua index da8d036c74..9786a08aac 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -38,31 +38,31 @@ function ResponseHandlers.get_pin_code_response(driver, device, zb_rx) -- response values local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) - if command_in_progress == constants.SYNC.CODES_FROM_LOCK_CMD then + if command_in_progress == constants.SYNC.CODES_FROM_LOCK then -- if an entry already exists at this user index, this will be a no-op. -- This is just meant to populate our tables with the existing codes on the lock, -- so we don't need to worry about handling updates vs adds here - local status = tables.add_entry(device, tables.DEFS.users, { + local status = tables.add_entry(device, "users", { userIndex = user_id, userName = "User " .. user_id, -- generic default, since we didn't explicitly set a name for this code. userType = "guest", -- also a generic default. }) if status == constants.COMMAND_RESULT.SUCCESS then -- if the entry was successfully added to the user table, we should also add an entry to the credential table for this code. - tables.add_entry(device, tables.DEFS.credentials, { + tables.add_entry(device, "credentials", { userIndex = user_id, credentialIndex = user_id, - credentialType = lock_utils.CREDENTIAL_TYPE, + credentialType = constants.CRED_TYPE_PIN, credentialName = "User " .. user_id, -- also a generic default. }) end - if user_id >= tables.get_max_entries(device, tables.DEFS.credentials) then + if user_id >= tables.get_max_entries(device, "credentials") then device:set_field(constants.SYNC.CODE_INDEX, nil) lock_utils.clear_busy_state(device) else local synced_code_index = device:get_field(constants.SYNC.CODE_INDEX) + 1 device:set_field(constants.SYNC.CODE_INDEX, synced_code_index) - lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK_CMD, { checkingCode = synced_code_index }) + lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) end end @@ -85,22 +85,22 @@ function ResponseHandlers.programming_event_notification(driver, device, zb_rx) if event_code == ProgrammingEventCodeEnum.PIN_CODE_ADDED then -- try to add a new entry to our tables for this code. -- if an entry already exists for this user index, this will be a no-op. - tables.add_entry(device, tables.DEFS.users, { + tables.add_entry(device, "users", { userIndex = user_id, userName = "User " .. user_id, -- default userType = "guest", -- default }) - tables.add_entry(device, tables.DEFS.credentials, { + tables.add_entry(device, "credentials", { userIndex = user_id, credentialIndex = user_id, - credentialType = lock_utils.CREDENTIAL_TYPE, + credentialType = constants.CRED_TYPE_PIN, credentialName = "User " .. user_id, -- default }) elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_DELETED then -- try to delete the entries in our tables corresponding to this code. -- if no entries exist for this user index, this will be a no-op. - tables.delete_entry(device, tables.DEFS.users, user_id) - tables.delete_entry(device, tables.DEFS.credentials, user_id) + tables.delete_entry(device, "users", user_id) + tables.delete_entry(device, "credentials", user_id) end end @@ -141,7 +141,11 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad local code_id = tonumber(zb_rx.body.zcl_body.user_id.value) local code_name = "Code " .. code_id - local user = lock_utils.get_user(device, code_id) + local users = tables.get_state(device, tables.DEFS.users) or {} + local user = nil + for _, u in ipairs(users) do + if u.userIndex == code_id then user = u; break end + end if user ~= nil then code_name = user.userName end @@ -169,21 +173,24 @@ end function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_rx) local clear_pin_code_status = zb_rx.body.zcl_body.status.value + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) - -- apply result and identify command result status + -- apply result and identify command result statuses local user_status, credential_status if clear_pin_code_status == ResponseStatus.PASS then - user_status = tables.delete_all_entries(device, tables.DEFS.users) - credential_status = tables.delete_all_entries(device, tables.DEFS.credentials) + -- Only clear the users table when this response is for a deleteAllUsers flow. + if command_in_progress == constants.LOCK_USERS.DELETE_ALL then + user_status = tables.delete_all_entries(device, "users") + end + credential_status = tables.delete_all_entries(device, "credentials") elseif clear_pin_code_status == ResponseStatus.FAIL then user_status = constants.COMMAND_RESULT.FAILURE credential_status = constants.COMMAND_RESULT.FAILURE end -- emit command results - local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) if command_in_progress == constants.LOCK_USERS.DELETE_ALL then - -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + -- deleteAllUsers injects deleteAllCredentials, so both command results should be emitted. lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, user_status) lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE_ALL then @@ -195,26 +202,33 @@ end function ResponseHandlers.clear_pin_code_response(driver, device, zb_rx) local clear_pin_code_status = zb_rx.body.zcl_body.status.value + local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) - -- apply result and identify command result status + -- apply result and identify command result statuses local user_status, credential_status if clear_pin_code_status == ResponseStatus.PASS then - local credential_args_in_use = device:get_field(lock_utils.CREDENTIAL_ARGS_IN_USE) - user_status = tables.delete_entry(device, tables.DEFS.users, credential_args_in_use.userIndex) - credential_status = tables.delete_entry(device, tables.DEFS.credentials, credential_args_in_use.credentialIndex) + if command_in_progress == constants.LOCK_USERS.DELETE then + local removed_user = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + user_status = type(removed_user) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE + end + local removed_cred = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + credential_status = type(removed_cred) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE elseif clear_pin_code_status == ResponseStatus.FAIL then user_status = constants.COMMAND_RESULT.FAILURE credential_status = constants.COMMAND_RESULT.FAILURE end -- emit command results - local command_in_progress = device:get_field(lock_utils.COMMAND_IN_PROGRESS) if command_in_progress == constants.LOCK_USERS.DELETE then -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, user_status) - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status) + local user_info = user_status == constants.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status) + local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) end lock_utils.clear_busy_state(device) end @@ -237,19 +251,20 @@ function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) local result_status if set_pin_code_status == SetCodeStatus.SUCCESS then if command_in_progress == constants.LOCK_CREDENTIALS.ADD then - result_status = tables.add_entry(device, tables.DEFS.credentials, { - userIndex = credential_args_in_use.userIndex, - credentialIndex = credential_args_in_use.userIndex, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional }) elseif command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then - result_status = tables.update_entry(device, tables.DEFS.credentials, { - userIndex = credential_args_in_use.userIndex, - credentialIndex = credential_args_in_use.userIndex, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional - }) + result_status = tables.update_entry(device, "credentials", + credential_args_in_use.credentialIndex, + { + userIndex = credential_args_in_use.userIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) end elseif RESPONSE_RESULT_MAP[set_pin_code_status] then result_status = RESPONSE_RESULT_MAP[set_pin_code_status] @@ -258,7 +273,11 @@ function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) end -- emit command result - lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status) + local additional_info = result_status == constants.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) lock_utils.clear_busy_state(device) end diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua new file mode 100644 index 0000000000..5e85292484 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -0,0 +1,575 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockCredentials capability commands: +-- addCredential, updateCredential, deleteCredential, deleteAllCredentials + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_handlers.tables" +local constants = require "lock_handlers.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS) + end + test.wait_for_events() +end + +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS) + end + test.wait_for_events() +end + +-- ============================================================================ +-- addCredential +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", + function() + -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + -- Expect SetPINCode to be sent to the lock + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + -- Lock responds with SUCCESS + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + -- Handler adds the credential to the credentials table + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + -- commandResult with userIndex and credentialIndex + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits failure when the lock returns GENERAL_FAILURE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.GENERAL_FAILURE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits resourceExhausted when the lock returns MEMORY_FULL", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.MEMORY_FULL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits duplicate when the lock returns DUPLICATE_CODE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.DUPLICATE_CODE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: returns busy when another operation is already in progress", + function() + -- Put the device into busy state by starting an addCredential operation + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + -- Second addCredential while first is still pending → should get busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: sends SetPINCode and emits success when the lock acknowledges", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin9" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "newPin9"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + -- update_entry emits the updated credentials table + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns failure immediately when the credential does not exist in the table", + function() + -- No credentials seeded — credentialIndex 99 does not exist + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + -- No zigbee message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- Start first updateCredential to make device busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin1111" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin1111"), + }) + test.wait_for_events() + + -- Second updateCredential while first is pending + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin2222" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: sends ClearPINCode and emits success with indices when the lock returns PASS", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- Only the credentials table should be deleted (deleteCredential does not touch users) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: emits failure when the lock returns FAIL", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure immediately when the credentialIndex does not exist in the table", + function() + -- No credentials seeded — index 5 is unknown + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 5, "pin" } }, + }) + + -- No zigbee messages should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns busy when another operation is already in progress", + function() + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + -- Start first deleteCredential to make device busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + -- Second deleteCredential while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 2, "pin" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials: sends ClearAllPINCodes and emits success when the lock returns PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- deleteAllCredentials only clears credentials; users table is untouched + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: emits failure when the lock returns FAIL", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- First deleteAllCredentials starts the zigbee flow (device is now busy) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + -- Second deleteAllCredentials while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua new file mode 100644 index 0000000000..ad4a5b331c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua @@ -0,0 +1,667 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for lockUsers and lockCredentials commands with state +-- pre-configured before each test. Two users and two credentials are seeded +-- at the start of every test so tests can focus on the various response states +-- produced by the zigbee response handlers in commands.lua. + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_handlers.tables" +local constants = require "lock_handlers.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +-- ── Shared device ────────────────────────────────────────────────────────── + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- ── Seeding helpers ──────────────────────────────────────────────────────── + +local INITIAL_USERS = { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, +} +local INITIAL_CREDS = { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, +} + +-- Seed a list of entries into a named table, consuming the resulting events. +local function seed_table(attribute_fn, table_name, entries) + local accumulated = {} + for _, entry in ipairs(entries) do + table.insert(accumulated, entry) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + attribute_fn(accumulated, { visibility = { displayed = false } })) + ) + assert( + table_utils.add_entry(mock_device, table_name, entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_table: add_entry failed for " .. table_name + ) + end + test.wait_for_events() +end + +-- Pre-configure each test with 2 users and 2 credentials. +local function setup_state() + seed_table(capabilities.lockUsers.users, "users", INITIAL_USERS) + seed_table(capabilities.lockCredentials.credentials, "credentials", INITIAL_CREDS) +end + +-- ============================================================================ +-- addUser — pre-configured device (2 users already present) +-- ============================================================================ + +test.register_coroutine_test( + "addUser (pre-configured): assigns the next available index (3) when two users already exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser — pre-configured device +-- ============================================================================ + +test.register_coroutine_test( + "updateUser (pre-configured): updates Alice's name and emits success with userIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceRenamed", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceRenamed", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser (pre-configured): returns failure for a userIndex that does not exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 10, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser with associated credential — exercises clear_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser (pre-configured, PASS): removes both user and credential and emits success for each", + function() + setup_state() + + -- Delete user 1 who has credential 1 → driver injects deleteCredential → ClearPINCode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser (pre-configured, FAIL): emits failure for both capabilities when the lock rejects", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- addCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "addCredential (pre-configured): succeeds for a new slot and emits userIndex + credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits duplicate when the lock rejects with DUPLICATE_CODE", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin01" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin01"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.DUPLICATE_CODE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits resourceExhausted when the lock returns MEMORY_FULL", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.MEMORY_FULL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential (pre-configured): succeeds and emits success with userIndex and credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin1" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "newPin1"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + -- update_entry re-emits the credentials table (same structure, updated data) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential (pre-configured): returns failure immediately for a non-existent credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential standalone (lockCredentials.DELETE path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential (pre-configured, PASS): removes credential and emits success with indices", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- Only credentials table is modified; users table is not touched + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential (pre-configured, FAIL): emits failure when the lock rejects the clear", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 2, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials standalone (lockCredentials.DELETE_ALL path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials (pre-configured, PASS): clears only credentials and emits success", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- Only credentials table is cleared; users table is untouched (no lockUsers.users event) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials (pre-configured, FAIL): emits failure and leaves tables unchanged", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (lockUsers.DELETE_ALL path — clears both tables) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers (pre-configured, PASS): clears both tables and emits success for both capabilities", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers (pre-configured, FAIL): emits failure for both capabilities when the lock rejects", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua new file mode 100644 index 0000000000..78a8776fa1 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua @@ -0,0 +1,438 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockUsers capability commands: +-- addUser, updateUser, deleteUser, deleteAllUsers + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_handlers.tables" +local constants = require "lock_handlers.constants" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- Directly insert users into the device state via table_utils (mirrors test_lock_tables.lua). +-- Consumes the resulting capability events so the socket queue stays clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Directly insert credentials into the device state. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- Set totalUsersSupported on the mock device and consume the resulting event. +local function set_total_users_supported(n) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) + ) + mock_device:emit_event(capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) + test.wait_for_events() +end + +-- ============================================================================ +-- addUser +-- ============================================================================ + +test.register_coroutine_test( + "addUser: assigns userIndex 1 for the first user and emits a success commandResult", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Alice", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: assigns the next sequential userIndex when users already exist", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: fills a gap left by a deleted user rather than appending beyond max", + function() + -- Seed indices 1 and 3; index 2 is the expected gap to fill + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: returns resourceExhausted when totalUsersSupported has been reached", + function() + set_total_users_supported(2) + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser +-- ============================================================================ + +test.register_coroutine_test( + "updateUser: updates an existing user and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceUpdated", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceUpdated", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: returns failure when the target userIndex does not exist", + function() + -- empty table — nothing to update + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 99, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: can change a user's type as well as name", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Alice", "adminMember" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "adminMember" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser (no associated credential — pure local delete) +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser: removes a user with no credential and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser: returns failure when the target userIndex is not in the users table", + function() + -- empty table + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 99 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + -- delete_entry still emits the (unchanged) users table + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (injects deleteAllCredentials → ClearAllPINCodes zigbee flow) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers: sends ClearAllPINCodes and emits success for both users and credentials on PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + -- deleteAllUsers injects deleteAllCredentials, which sends ClearAllPINCodes + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + local ResponseStatus = DoorLock.types.DrlkPassFailStatus + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers: emits failure for both users and credentials when the lock returns FAIL", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + local ResponseStatus = DoorLock.types.DrlkPassFailStatus + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() From b0a8b0c0f6ce72119f7fb292ff6b5095ef1730a5 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sun, 10 May 2026 16:52:47 -0500 Subject: [PATCH 15/33] add more table helpers, update naming for clarity --- drivers/SmartThings/zigbee-lock/src/init.lua | 4 +- .../src/lock_handlers/capabilities.lua | 50 +--- .../src/lock_handlers/commands.lua | 279 +++++++++--------- .../zigbee-lock/src/lock_handlers/tables.lua | 46 ++- .../zigbee-lock/src/lock_handlers/utils.lua | 2 +- 5 files changed, 193 insertions(+), 188 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 00460604b6..579ead097b 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -39,7 +39,7 @@ function LockLifecycle.init(driver, device) device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) end device.thread:call_with_delay(15, function(d) - lock_utils.reload_all_codes(device) + lock_utils.sync_code_state(device) end) end @@ -53,7 +53,7 @@ function LockLifecycle.do_configure(self, device) device:send(device_management.build_bind_request(device, clusters.Alarms.ID, self.environment_info.hub_zigbee_eui)) device:send(clusters.Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - device.thread:call_with_delay(2, function(d) lock_utils.reload_all_codes(device) end) + device.thread:call_with_delay(2, function(d) lock_utils.sync_code_state(device) end) end local zigbee_lock_driver = { diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index ba9d44be76..c5bd5dc320 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -36,12 +36,7 @@ function CapabilityHandlers.add_user(driver, device, command) end -- Find the smallest positive userIndex not already in the table - local existing = tables.get_state(device, tables.DEFS.users) or {} - local occupied = {} - for _, u in ipairs(existing) do occupied[u.userIndex] = true end - local next_index = 1 - while occupied[next_index] do next_index = next_index + 1 end - + local next_index = tables.next_index(device, "users") local status = tables.add_entry(device, "users", { userIndex = next_index, userName = command.args.userName, @@ -67,17 +62,8 @@ function CapabilityHandlers.delete_user(driver, device, command) return end - local get_credential_for_user = function(device, user_index) - local credentials = tables.get_state(device, tables.DEFS.credentials) - if not credentials then return end - for _, credential in pairs(credentials) do - if credential.userIndex == user_index then - return credential.credentialIndex - end - end - end - - local associated_credential_index = get_credential_for_user(device, command.args.userIndex) + local associated_credential = tables.find_entry_by(device, "credentials", "userIndex", command.args.userIndex) + local associated_credential_index = associated_credential and associated_credential.credentialIndex if associated_credential_index then -- Set busy state with the full user+credential context BEFORE injecting. -- Injected capability commands are schema-validated, so extra args like userIndex @@ -129,14 +115,7 @@ function CapabilityHandlers.add_credential(driver, device, command) end -- A userIndex of 0 means "auto-assign the next available slot" - local user_index = command.args.userIndex - if user_index == 0 then - local existing = tables.get_state(device, tables.DEFS.credentials) or {} - local occupied = {} - for _, c in ipairs(existing) do occupied[c.credentialIndex] = true end - user_index = 1 - while occupied[user_index] do user_index = user_index + 1 end - end + local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex -- Store credentialIndex alongside userIndex; for zigbee DoorLock they are the same slot. lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.ADD, { @@ -159,12 +138,7 @@ function CapabilityHandlers.update_credential(driver, device, command) return end - local credentials = tables.get_state(device, tables.DEFS.credentials) or {} - local found = false - for _, cred in ipairs(credentials) do - if cred.credentialIndex == command.args.credentialIndex then found = true; break end - end - if not found then + if not tables.find_entry(device, "credentials", command.args.credentialIndex) then lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, constants.COMMAND_RESULT.FAILURE) return end @@ -187,8 +161,7 @@ function CapabilityHandlers.delete_credential(driver, device, command) local cmd_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) if cmd_in_progress == constants.LOCK_USERS.DELETE then - -- Injected by deleteUser; busy state was already set with the full user+credential context. - -- command.args may not resolve named keys when injected, so read from the stored field instead. + -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. local credential_args = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) device:send(LockCluster.server.commands.ClearPINCode(device, credential_args.credentialIndex)) @@ -196,14 +169,7 @@ function CapabilityHandlers.delete_credential(driver, device, command) lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.BUSY) else -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. - local credentials = tables.get_state(device, tables.DEFS.credentials) or {} - local found_cred = nil - for _, cred in ipairs(credentials) do - if cred.credentialIndex == command.args.credentialIndex then - found_cred = cred - break - end - end + local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) if not found_cred then lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.FAILURE) return @@ -222,7 +188,7 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command) local cmd_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) if cmd_in_progress == constants.LOCK_USERS.DELETE_ALL then - -- Injected by deleteAllUsers; busy state was already set with DELETE_ALL context. + -- Injected by deleteAllUsers; busy state was already set with LOCK_USERS.DELETE_ALL context. device:send(LockCluster.server.commands.ClearAllPINCodes(device)) elseif lock_utils.is_device_busy(device) then lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua index 9786a08aac..6a46ffb004 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -12,25 +12,132 @@ local tables = require "lock_handlers.tables" local ResponseHandlers = {} -local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus --- PASS = 0 --- FAIL = 1 - -local SetCodeStatus = clusters.DoorLock.types.DrlkSetCodeStatus --- SUCCESS = 0 --- GENERAL_FAILURE = 1 --- MEMORY_FULL = 2 --- DUPLICATE_CODE = 3 - -local ProgrammingEventCodeEnum = clusters.DoorLock.types.ProgramEventCode --- MASTER_CODE_CHANGED = 1 --- PIN_CODE_ADDED = 2 --- PIN_CODE_DELETED = 3 --- PIN_CODE_CHANGED = 4 --- RFID_CODE_ADDED = 5 --- RFID_CODE_DELETED = 6 - -local OperationEventCode = clusters.DoorLock.types.OperationEventCode + +function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + -- zb response values + local set_pin_code_status = zb_rx.body.zcl_body.status.value + + local SetCodeStatus = clusters.DoorLock.types.DrlkSetCodeStatus + -- SUCCESS = 0 + -- GENERAL_FAILURE = 1 + -- MEMORY_FULL = 2 + -- DUPLICATE_CODE = 3 + + -- mapped failures states + local RESPONSE_RESULT_MAP = { + [SetCodeStatus.GENERAL_FAILURE] = constants.COMMAND_RESULT.FAILURE, + [SetCodeStatus.MEMORY_FULL] = constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + [SetCodeStatus.DUPLICATE_CODE] = constants.COMMAND_RESULT.DUPLICATE, + } + + -- apply result based on response and identify command result status + local result_status + if set_pin_code_status == SetCodeStatus.SUCCESS then + if command_in_progress == constants.LOCK_CREDENTIALS.ADD then + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + elseif command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then + result_status = tables.update_entry(device, "credentials", + credential_args_in_use.credentialIndex, + { + userIndex = credential_args_in_use.userIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + end + elseif RESPONSE_RESULT_MAP[set_pin_code_status] then + result_status = RESPONSE_RESULT_MAP[set_pin_code_status] + else + result_status = constants.COMMAND_RESULT.FAILURE + end + + -- emit command result + local additional_info = result_status == constants.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) + lock_utils.clear_busy_state(device) +end + + +function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_rx) + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + + local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus + -- PASS = 0 + -- FAIL = 1 + + -- apply result and identify command result statuses + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + -- Only clear the users table when this response is for a deleteAllUsers flow. + if command_in_progress == constants.LOCK_USERS.DELETE_ALL then + user_status = tables.delete_all_entries(device, "users") + end + credential_status = tables.delete_all_entries(device, "credentials") + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = constants.COMMAND_RESULT.FAILURE + credential_status = constants.COMMAND_RESULT.FAILURE + end + + -- emit command results + if command_in_progress == constants.LOCK_USERS.DELETE_ALL then + -- deleteAllUsers injects deleteAllCredentials, so both command results should be emitted. + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, user_status) + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE_ALL then + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + end + lock_utils.clear_busy_state(device) +end + + +function ResponseHandlers.clear_pin_code_response(driver, device, zb_rx) + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + + local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus + -- PASS = 0 + -- FAIL = 1 + + -- apply result and identify command result statuses + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + if command_in_progress == constants.LOCK_USERS.DELETE then + local removed_user = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + user_status = type(removed_user) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE + end + local removed_cred = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + credential_status = type(removed_cred) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = constants.COMMAND_RESULT.FAILURE + credential_status = constants.COMMAND_RESULT.FAILURE + end + + -- emit command results + if command_in_progress == constants.LOCK_USERS.DELETE then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + local user_info = user_status == constants.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE then + local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + end + lock_utils.clear_busy_state(device) +end + function ResponseHandlers.get_pin_code_response(driver, device, zb_rx) -- cached values from capability command @@ -69,7 +176,7 @@ function ResponseHandlers.get_pin_code_response(driver, device, zb_rx) end function ResponseHandlers.programming_event_notification(driver, device, zb_rx) - -- if the device is busy, one of our capability commands is in progress, so ignore this response to avoid duplicating the response. + -- if the device is busy, one of our capability commands is in progress, so ignore this response to avoid duplicating a response. if lock_utils.is_device_busy(device) then return end -- response values @@ -82,6 +189,14 @@ function ResponseHandlers.programming_event_notification(driver, device, zb_rx) end end + local ProgrammingEventCodeEnum = clusters.DoorLock.types.ProgramEventCode + -- MASTER_CODE_CHANGED = 1 + -- PIN_CODE_ADDED = 2 + -- PIN_CODE_DELETED = 3 + -- PIN_CODE_CHANGED = 4 + -- RFID_CODE_ADDED = 5 + -- RFID_CODE_DELETED = 6 + if event_code == ProgrammingEventCodeEnum.PIN_CODE_ADDED then -- try to add a new entry to our tables for this code. -- if an entry already exists for this user index, this will be a no-op. @@ -115,6 +230,7 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) [4] = "fingerprint", [5] = "bluetooth" } + local OperationEventCode = clusters.DoorLock.types.OperationEventCode local STATUS = { [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), @@ -140,16 +256,8 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) end if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad local code_id = tonumber(zb_rx.body.zcl_body.user_id.value) - local code_name = "Code " .. code_id - local users = tables.get_state(device, tables.DEFS.users) or {} - local user = nil - for _, u in ipairs(users) do - if u.userIndex == code_id then user = u; break end - end - if user ~= nil then - code_name = user.userName - end - + local user = tables.find_entry(device, "users", code_id) + local code_name = user and user.userName or ("Code " .. code_id) event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } end @@ -171,117 +279,6 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) end -function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_rx) - local clear_pin_code_status = zb_rx.body.zcl_body.status.value - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) - - -- apply result and identify command result statuses - local user_status, credential_status - if clear_pin_code_status == ResponseStatus.PASS then - -- Only clear the users table when this response is for a deleteAllUsers flow. - if command_in_progress == constants.LOCK_USERS.DELETE_ALL then - user_status = tables.delete_all_entries(device, "users") - end - credential_status = tables.delete_all_entries(device, "credentials") - elseif clear_pin_code_status == ResponseStatus.FAIL then - user_status = constants.COMMAND_RESULT.FAILURE - credential_status = constants.COMMAND_RESULT.FAILURE - end - - -- emit command results - if command_in_progress == constants.LOCK_USERS.DELETE_ALL then - -- deleteAllUsers injects deleteAllCredentials, so both command results should be emitted. - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, user_status) - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) - elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE_ALL then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) - end - lock_utils.clear_busy_state(device) -end - - -function ResponseHandlers.clear_pin_code_response(driver, device, zb_rx) - local clear_pin_code_status = zb_rx.body.zcl_body.status.value - local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) - - -- apply result and identify command result statuses - local user_status, credential_status - if clear_pin_code_status == ResponseStatus.PASS then - if command_in_progress == constants.LOCK_USERS.DELETE then - local removed_user = tables.delete_entry(device, "users", credential_args_in_use.userIndex) - user_status = type(removed_user) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE - end - local removed_cred = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) - credential_status = type(removed_cred) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE - elseif clear_pin_code_status == ResponseStatus.FAIL then - user_status = constants.COMMAND_RESULT.FAILURE - credential_status = constants.COMMAND_RESULT.FAILURE - end - - -- emit command results - if command_in_progress == constants.LOCK_USERS.DELETE then - -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. - local user_info = user_status == constants.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil - local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, user_status, user_info) - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) - elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE then - local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) - end - lock_utils.clear_busy_state(device) -end - - -function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) - -- cached values from capability command - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) - local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) - -- zb response values - local set_pin_code_status = zb_rx.body.zcl_body.status.value - -- mapped failures states - local RESPONSE_RESULT_MAP = { - [SetCodeStatus.GENERAL_FAILURE] = constants.COMMAND_RESULT.FAILURE, - [SetCodeStatus.MEMORY_FULL] = constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, - [SetCodeStatus.DUPLICATE_CODE] = constants.COMMAND_RESULT.DUPLICATE, - } - - -- apply result based on response and identify command result status - local result_status - if set_pin_code_status == SetCodeStatus.SUCCESS then - if command_in_progress == constants.LOCK_CREDENTIALS.ADD then - result_status = tables.add_entry(device, "credentials", { - userIndex = credential_args_in_use.userIndex, - credentialIndex = credential_args_in_use.credentialIndex, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional - }) - elseif command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then - result_status = tables.update_entry(device, "credentials", - credential_args_in_use.credentialIndex, - { - userIndex = credential_args_in_use.userIndex, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional - }) - end - elseif RESPONSE_RESULT_MAP[set_pin_code_status] then - result_status = RESPONSE_RESULT_MAP[set_pin_code_status] - else - result_status = constants.COMMAND_RESULT.FAILURE - end - - -- emit command result - local additional_info = result_status == constants.COMMAND_RESULT.SUCCESS and { - userIndex = credential_args_in_use.userIndex, - credentialIndex = credential_args_in_use.credentialIndex, - } or nil - lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) - lock_utils.clear_busy_state(device) -end - - -- [[ ALARMS CLUSTER COMMANDS ]] -- function ResponseHandlers.alarm(driver, device, zb_rx) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua index b499d7cbca..4b31571b38 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua @@ -53,10 +53,13 @@ local function validate_entry(device, entry, required_keys) return true end --- Read the current state for a table definition and return a deep-copied array. +-- Read the current state for a table and return a deep-copied array. +-- Accepts either a string table name ("users", "credentials") or a DEFS entry directly. -- Returns nil (with a warning) if the capability is unsupported by the device. --- @return table[] | nil -function table_utils.get_state(device, def) +function table_utils.get_state(device, name_or_def) + local def = type(name_or_def) == "string" and resolve_table_def(device, name_or_def) or name_or_def + if not def then return nil end if not device:supports_capability(def.capability, "main") then device.log.warn(string.format( "table_helpers: device does not support capability %q", def.capability.ID @@ -66,6 +69,45 @@ function table_utils.get_state(device, def) return st_utils.deep_copy(device:get_latest_state("main", def.capability.ID, def.attribute.NAME, {})) end +-- Find an entry in a named table where the match_key equals value. +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry(device, table_name, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[def.match_key] == value then return entry end + end + return nil +end + +-- Find an entry in a named table where entry[key] equals value (arbitrary key search). +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[key] == value then return entry end + end + return nil +end + +-- Return the lowest positive integer not yet used as the match_key in the named table. +-- Used to auto-assign the next available slot for a new entry. +function table_utils.next_index(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return 1 end + local t = table_utils.get_state(device, def) or {} + local occupied = {} + for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end + local idx = 1 + while occupied[idx] do idx = idx + 1 end + return idx +end + function table_utils.get_max_entries(device, table_name) local def = resolve_table_def(device, table_name) if not def then return end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua index 6985e3d34f..8ce9dc67f8 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua @@ -40,7 +40,7 @@ end -- [[ CAPABILITY STATE MANAGEMENT ]] -- -function lock_utils.reload_all_codes(device) +function lock_utils.sync_code_state(device) -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. device:send(clusters.DoorLock.attributes.SendPINOverTheAir:write(device, true)) From 5a5a480edf9a220c44e7c6399154268430d96d84 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sun, 10 May 2026 17:34:40 -0500 Subject: [PATCH 16/33] add programming notification tests, make DEFS private, update lock operation handler --- .../src/lock_handlers/commands.lua | 114 ++++++----- .../zigbee-lock/src/lock_handlers/tables.lua | 4 +- .../src/test/test_lock_programming_events.lua | 187 ++++++++++++++++++ 3 files changed, 254 insertions(+), 51 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua index 6a46ffb004..d556b90476 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -220,62 +220,78 @@ function ResponseHandlers.programming_event_notification(driver, device, zb_rx) end function ResponseHandlers.operating_event_notification(driver, device, zb_rx) - local event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) - local source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" + local op_event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) + local op_event_source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) + + -- get lock event or return + local OpEventCode = clusters.DoorLock.types.OperationEventCode + local OP_EVENT_CODE_CAPABILITY_MAP = { + [OpEventCode.LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() } - local OperationEventCode = clusters.DoorLock.types.OperationEventCode - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + local lock_event = OP_EVENT_CODE_CAPABILITY_MAP[op_event_code] + if not lock_event then return end + lock_event.data = {} + + -- get method of lock event + local OpEventSource = clusters.DoorLock.types.DrlkOperationEventSource + local OP_EVENT_SOURCE_CAPABILITY_MAP = { + [OpEventSource.KEYPAD] = "keypad", + [OpEventSource.COMMAND] = "command", + [OpEventSource.MANUAL] = "manual", + [OpEventSource.RFID] = "rfid", + -- These last two sources are not found in the spec, but they were in the legacy driver + [5] = "fingerprint", + [6] = "bluetooth", } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" + if (op_event_source ~= OpEventSource.KEYPAD and ( + op_event_code == OpEventCode.AUTO_LOCK or + op_event_code == OpEventCode.SCHEDULE_LOCK or + op_event_code == OpEventCode.SCHEDULE_UNLOCK + )) then + lock_event.data.method = "auto" + else + lock_event.data.method = OP_EVENT_SOURCE_CAPABILITY_MAP[op_event_source] or "manual" + end + + -- get stored lockUsers data if applicable + if op_event_source == OpEventSource.KEYPAD and device:supports_capability(capabilities.lockUsers) then + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + local associated_user = tables.find_entry(device, "users", user_id) + if associated_user then + lock_event.data.userIndex = user_id .. "" + lock_event.data.userName = associated_user.userName + lock_event.data.userType = associated_user.userType else - event.data.method = METHOD[source] - end - if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad - local code_id = tonumber(zb_rx.body.zcl_body.user_id.value) - local user = tables.find_entry(device, "users", code_id) - local code_name = user and user.userName or ("Code " .. code_id) - event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + lock_event.data.userIndex = user_id .. "" + lock_event.data.userName = "User " .. user_id -- default 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( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(constants.DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < constants.MAX_DELAY then - device:set_field(constants.DELAY_LOCK_EVENT, time_diff) - 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 + local endpoint_id = zb_rx.address_header.src_endpoint.value + if lock_event.value.value == device:get_latest_state( + device:get_component_id_for_endpoint(endpoint_id), + capabilities.lock.ID, + capabilities.lock.lock.ID + ) then + local preceding_event_time = device:get_field(constants.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < constants.MAX_DELAY then + device:set_field(constants.DELAY_LOCK_EVENT, time_diff) end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) end + + device:emit_event_for_endpoint(endpoint_id, lock_event) end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua index 4b31571b38..97fc91d2ba 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua @@ -16,7 +16,7 @@ local table_utils = {} -- required_keys Keys that must be non-nil when adding an entry -- -table_utils.DEFS = { +local DEFS = { users = { capability = capabilities.lockUsers, attribute = capabilities.lockUsers.users, @@ -35,7 +35,7 @@ table_utils.DEFS = { -- Resolve a table name to its definition. Logs an error and returns nil if unknown. local function resolve_table_def(device, table_name) - local def = table_utils.DEFS[table_name] + local def = DEFS[table_name] if not def then device.log.error(string.format("table_helpers: unknown table %q", table_name)) end diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua new file mode 100644 index 0000000000..e9f78c3164 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -0,0 +1,187 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Tests for the ProgrammingEventNotification handler in lock_handlers/commands.lua. +-- +-- Cases covered: +-- • PIN_CODE_ADDED received while NOT busy (manual addition at the lock) +-- • PIN_CODE_DELETED received while NOT busy (manual deletion at the lock) +-- • PIN_CODE_CHANGED received while NOT busy (manual update — not currently handled) +-- • PIN_CODE_ADDED received while BUSY (notification arrives after our addCredential command) +-- • PIN_CODE_CHANGED received while BUSY (notification arrives after our updateCredential command) +-- • PIN_CODE_DELETED received while BUSY (notification arrives after our deleteCredential command) + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local capabilities = require "st.capabilities" +local constants = require "lock_handlers.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +-- Build a ProgrammingEventNotification ZigBee receive message for the given event code and user ID. +local function build_programming_event(event_code, user_id) + return { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x00, -- program_event_source (keypad) + event_code, + user_id, + "1234", -- PIN (not used by the handler) + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, -- local_alarm_mask + "data" -- user_description + ) + } +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- NOT-BUSY CASES (manual events from the lock, no command in flight) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED while not busy syncs user and credential entries", + function() + -- Route events to the new capabilities handler, not the legacy lockCodes handler. + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + -- Users table receives the new entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + -- Credentials table receives the new entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED while not busy removes user and credential entries", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Set up an existing entry by processing a manual addition first. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Now delete the same entry via a manual deletion event. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_CHANGED while not busy has no effect on table state", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- PIN_CODE_CHANGED is not handled by the notification handler, so no events should + -- be emitted and table state should remain unchanged. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- BUSY CASES (notification arrives while one of our commands is in flight) +-- The handler must be a no-op so we do not double-process the result. +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED while device is busy is ignored", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an addCredential command being in flight. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + -- No capability events should be emitted; the notification is silently dropped. + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_CHANGED while device is busy is ignored", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an updateCredential command being in flight. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.UPDATE, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + -- No capability events should be emitted; the notification is silently dropped. + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED while device is busy is ignored", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate a deleteCredential command being in flight. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.DELETE, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + -- No capability events should be emitted; the notification is silently dropped. + test.wait_for_events() + end +) + +test.run_registered_tests() From 8149e1cdbd26e9c95edf33b6b6e27f663301897a Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Sun, 10 May 2026 19:45:30 -0500 Subject: [PATCH 17/33] fix older tests --- drivers/SmartThings/zigbee-lock/src/init.lua | 3 +- .../zigbee-lock/src/legacy-handlers/init.lua | 253 ++++++++++++------ .../zigbee-lock/src/test/test_zigbee_lock.lua | 209 +++++---------- .../test_zigbee_lock_code_slga_migration.lua | 4 +- .../src/test/test_zigbee_lock_legacy.lua | 6 +- 5 files changed, 253 insertions(+), 222 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 579ead097b..ad342367c7 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 local ZigbeeDriver = require "st.zigbee" @@ -44,6 +44,7 @@ function LockLifecycle.init(driver, device) end function LockLifecycle.do_configure(self, device) + print("@@## Configuring device 2") device:send(device_management.build_bind_request(device, clusters.PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) device:send(clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua index d66f5e583f..4521cf29a1 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -1,17 +1,20 @@ --- Copyright 2026 SmartThings, Inc. +-- Copyright 2022 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- Zigbee Driver utilities +local device_management = require "st.zigbee.device_management" + -- Zigbee Spec Utils local clusters = require "st.zigbee.zcl.clusters" +local Alarm = clusters.Alarms local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration -- Capabilities local capabilities = require "st.capabilities" local Battery = capabilities.battery local Lock = capabilities.lock local LockCodes = capabilities.lockCodes -local LockCredentials = capabilities.lockCredentials -local LockUsers = capabilities.lockUsers -- Enums local UserStatusEnum = LockCluster.types.DrlkUserStatus @@ -41,10 +44,59 @@ local reload_all_codes = function(driver, device, command) device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) end +local refresh = function(driver, device, cmd) + device:refresh() + device:send(LockCluster.attributes.LockState:read(device)) + device:send(Alarm.attributes.AlarmCount:read(device)) + -- we can't determine from fingerprints if devices support lock codes, so + -- here in the driver we'll do a check once to see if the device responds here + -- and if it does, we'll switch it to a profile with lock codes + if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(lock_utils.CHECKED_CODE_SUPPORT) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + -- we won't make this value persist because it's not that important + device:set_field(lock_utils.CHECKED_CODE_SUPPORT, true) + end +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + -- Don't send a reload all codes if this is a part of migration + if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then + device.thread:call_with_delay(2, function(d) + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end) + else + device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) + end +end + +local alarm_handler = function(driver, device, zb_mess) + local ALARM_REPORT = { + [0] = Lock.lock.unknown(), + [1] = Lock.lock.unknown(), + -- Events 16-19 are low battery events, but are presented as descriptionText only + } + if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) + end +end + local get_pin_response_handler = function(driver, device, zb_mess) local event = LockCodes.codeChanged("", { state_change = true }) local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = { codeName = lock_utils.get_code_name(device, code_slot) } + event.data = {codeName = lock_utils.get_code_name(device, code_slot)} if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then -- Code slot is occupied event.value = code_slot .. lock_utils.get_change_type(device, code_slot) @@ -87,7 +139,7 @@ local programming_event_handler = function(driver, device, zb_mess) if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then -- Master code changed event.value = "0 set" - event.data = { codeName = "Master Code" } + event.data = {codeName = "Master Code"} device:emit_event(event) elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then if (zb_mess.body.zcl_body.user_id.value == 0xFF) then @@ -103,12 +155,12 @@ local programming_event_handler = function(driver, device, zb_mess) end end elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then -- Code added or changed local change_type = lock_utils.get_change_type(device, code_slot) local code_name = lock_utils.get_code_name(device, code_slot) event.value = code_slot .. change_type - event.data = { codeName = code_name } + event.data = {codeName = code_name} device:emit_event(event) if (change_type == " set") then local lock_codes = lock_utils.get_lock_codes(device) @@ -146,69 +198,6 @@ local handle_min_code_length = function(driver, device, value) device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) end -local lock_operation_event_handler = function(driver, device, zb_rx) - local event_code = zb_rx.body.zcl_body.operation_event_code.value - local source = zb_rx.body.zcl_body.operation_event_source.value - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" - } - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() - } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" - else - event.data.method = METHOD[source] - end - if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad - local code_id = zb_rx.body.zcl_body.user_id.value - local code_name = "Code " .. code_id - local lock_codes = device:get_field("lockCodes") - if (lock_codes ~= nil and - lock_codes[code_id] ~= nil) then - code_name = lock_codes[code_id] - end - event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } - 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( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) - end - end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) - end -end - local update_codes = function(driver, device, command) local delay = 0 -- args.codes is json @@ -289,9 +278,108 @@ local name_slot = function(driver, device, command) end end -local migrate = function(driver, device, command) - local post_migration_lock_utils = require "zigbee_lock_utils" +local function init(driver, device) + lock_utils.populate_state_from_data(device) + -- temp fix before this can be changed to non-persistent + device:set_field(lock_utils.CODE_STATE, nil, { persist = true }) +end + +-- The following two functions are from the lock defaults. They are in the base driver temporarily +-- until the fix is widely released in the lua libs +local lock_state_handler = function(driver, device, value, zb_rx) + local attr = capabilities.lock.lock + local LOCK_STATE = { + [value.NOT_FULLY_LOCKED] = attr.unknown(), + [value.LOCKED] = attr.locked(), + [value.UNLOCKED] = attr.unlocked(), + [value.UNDEFINED] = attr.unknown(), + } + + -- this is where we decide whether or not we need to delay our lock event because we've + -- observed it coming before the event (or we're starting to compute the timer) + local delay = device:get_field(DELAY_LOCK_EVENT) or 100 + if (delay < MAX_DELAY) then + device.thread:call_with_delay(delay+.5, function () + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end) + else + device:set_field(DELAY_LOCK_EVENT, socket.gettime()) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end +end + +local lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local lock_codes = device:get_field("lockCodes") + if (lock_codes ~= nil and + lock_codes[code_id] ~= nil) then + code_name = lock_codes[code_id] + end + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + 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( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + +local function lock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) +end + +local function unlock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) +end +local migrate = function(driver, device, command) local lock_users = {} local lock_credentials = {} local lock_codes = lock_utils.get_lock_codes(device) @@ -318,6 +406,9 @@ local migrate = function(driver, device, command) min_code_len = code_length end + local post_migration_constants = require "lock_handlers.constants" + local LockUsers = capabilities.lockUsers + local LockCredentials = capabilities.lockCredentials device:emit_event(LockCredentials.minPinCodeLen(min_code_len, { visibility = { displayed = false } })) device:emit_event(LockCredentials.maxPinCodeLen(max_code_len, { visibility = { displayed = false } })) device:emit_event(LockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) @@ -326,7 +417,7 @@ local migrate = function(driver, device, command) device:emit_event(LockUsers.users(lock_users, { visibility = { displayed = false } })) device:emit_event(LockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) - device:set_field(post_migration_lock_utils.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore + device:set_field(post_migration_constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore end local legacy_capabilities_driver = { @@ -338,6 +429,9 @@ local legacy_capabilities_driver = { }, zigbee_handlers = { cluster = { + [Alarm.ID] = { + [Alarm.client.commands.Alarm.ID] = alarm_handler + }, [LockCluster.ID] = { [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, @@ -346,6 +440,7 @@ local legacy_capabilities_driver = { }, attr = { [LockCluster.ID] = { + [LockCluster.attributes.LockState.ID] = lock_state_handler, [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes, @@ -362,12 +457,20 @@ local legacy_capabilities_driver = { [LockCodes.commands.nameSlot.NAME] = name_slot, [LockCodes.commands.migrate.NAME] = migrate, }, + [Lock.ID] = { + [Lock.commands.lock.NAME] = lock, + [Lock.commands.unlock.NAME] = unlock, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh + } }, sub_drivers = require("legacy-handlers.sub_drivers"), - health_check = false, lifecycle_handlers = { - init = function(driver, device) lock_utils.populate_state_from_data(device) end, + doConfigure = do_configure, + init = init, }, + health_check = false, can_handle = require("legacy-handlers.can_handle") } diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua index 1279d71083..243fae5261 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua @@ -12,7 +12,7 @@ local capabilities = require "st.capabilities" local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType -local lock_utils = require "lock_handlers.utils" +local constants = require "lock_handlers.constants" local test_credential_index = 1 local test_credentials = {} @@ -64,7 +64,7 @@ local function init_migration() 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, "SLGA_MIGRATED field should be set to true after migration") + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") end local function add_default_users() @@ -86,7 +86,7 @@ local function add_default_users() "main", capabilities.lockUsers.users( user_list, - { state_change = true, visibility = { displayed = true } } + { visibility = { displayed = false } } ) ) ) @@ -95,13 +95,15 @@ local function add_default_users() "main", capabilities.lockUsers.commandResult( { commandName = "addUser", statusCode = "success", userIndex = i }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) end end +-- Adds a credential via the addCredential capability command and handles the zigbee roundtrip. +-- user_index should be the explicit slot index (1-based) to use for this credential. local function add_credential(user_index, credential_data) test.socket.capability:__queue_receive({ mock_device.id, @@ -122,42 +124,24 @@ local function add_credential(user_index, credential_data) ) } ) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, test_credential_index) - } - ) test.wait_for_events() + -- Lock acknowledges the pin was set test.socket.zigbee:__queue_receive( { mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + DoorLock.client.commands.SetPINCodeResponse.build_test_rx( mock_device, - test_credential_index, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - credential_data + DoorLock.types.DrlkSetCodeStatus.SUCCESS ) } ) table.insert(test_credentials, { userIndex = test_credential_index, credentialIndex = test_credential_index, credentialType = "pin" }) - 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 } }) - ) - ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.lockCredentials.credentials(test_credentials, - { state_change = true, visibility = { displayed = true } }) + { visibility = { displayed = false } }) ) ) test.socket.capability:__expect_send( @@ -166,7 +150,7 @@ local function add_credential(user_index, credential_data) capabilities.lockCredentials.commandResult( { commandName = "addCredential", statusCode = "success", credentialIndex = test_credential_index, userIndex = test_credential_index }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -198,7 +182,7 @@ test.register_coroutine_test( "main", capabilities.lockUsers.commandResult( { commandName = "addUser", statusCode = "resourceExhausted" }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -219,7 +203,7 @@ test.register_coroutine_test( { capability = capabilities.lockUsers.ID, command = "updateUser", - args = { "2", "ChangeUserName", "guest" } + args = { 2, "ChangeUserName", "guest" } }, }) @@ -232,7 +216,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockUsers.users(users, { state_change = true, visibility = { displayed = true } }) + capabilities.lockUsers.users(users, { visibility = { displayed = false } }) ) ) test.socket.capability:__expect_send( @@ -240,7 +224,7 @@ test.register_coroutine_test( "main", capabilities.lockUsers.commandResult( { commandName = "updateUser", statusCode = "success", userIndex = 2 }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -251,7 +235,7 @@ test.register_coroutine_test( { capability = capabilities.lockUsers.ID, command = "updateUser", - args = { "6", "ChangeUserName", "guest" } + args = { 6, "ChangeUserName", "guest" } }, }) test.socket.capability:__expect_send( @@ -259,7 +243,7 @@ test.register_coroutine_test( "main", capabilities.lockUsers.commandResult( { commandName = "updateUser", statusCode = "failure" }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -274,26 +258,25 @@ test.register_coroutine_test( -- create initial users add_default_users() + local users_after_delete = { + { userIndex = 1, userName = "Guest1", userType = "guest" }, + { userIndex = 2, userName = "Guest2", userType = "guest" }, + { userIndex = 4, userName = "Guest4", userType = "guest" }, + } + -- success test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockUsers.ID, command = "deleteUser", - args = { "3" } + args = { 3 } }, }) - - local users = { - { userIndex = 1, userName = "Guest1", userType = "guest" }, - { userIndex = 2, userName = "Guest2", userType = "guest" }, - { userIndex = 4, userName = "Guest4", userType = "guest" }, - } - test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockUsers.users(users, { state_change = true, visibility = { displayed = true } }) + capabilities.lockUsers.users(users_after_delete, { visibility = { displayed = false } }) ) ) test.socket.capability:__expect_send( @@ -301,26 +284,33 @@ test.register_coroutine_test( "main", capabilities.lockUsers.commandResult( { commandName = "deleteUser", statusCode = "success", userIndex = 3 }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) - -- failure - try updating non existent userIndex + -- failure - try deleting non existent userIndex test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockUsers.ID, command = "deleteUser", - args = { "3" } + args = { 3 } }, }) + -- delete_entry always emits the (unchanged) users table even on failure + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(users_after_delete, { visibility = { displayed = false } }) + ) + ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.lockUsers.commandResult( { commandName = "deleteUser", statusCode = "failure" }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -348,7 +338,7 @@ test.register_coroutine_test( { capability = capabilities.lockCredentials.ID, command = "updateCredential", - args = { "4", "4", "pin", "abc123" } + args = { 4, 4, "pin", "abc123" } }, }) test.socket.capability:__expect_send( @@ -356,7 +346,7 @@ test.register_coroutine_test( "main", capabilities.lockCredentials.commandResult( { commandName = "updateCredential", statusCode = "failure" }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -368,7 +358,7 @@ test.register_coroutine_test( { capability = capabilities.lockCredentials.ID, command = "updateCredential", - args = { "1", "1", "pin", "changedPin123" } + args = { 1, 1, "pin", "changedPin123" } }, }) test.socket.zigbee:__expect_send( @@ -382,38 +372,17 @@ test.register_coroutine_test( ) } ) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) test.wait_for_events() + -- Lock acknowledges the update test.socket.zigbee:__queue_receive( { mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + DoorLock.client.commands.SetPINCodeResponse.build_test_rx( mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "abc123" + DoorLock.types.DrlkSetCodeStatus.SUCCESS ) } ) - 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", @@ -421,7 +390,7 @@ test.register_coroutine_test( { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, - { state_change = true, visibility = { displayed = true } } + { visibility = { displayed = false } } ) ) ) @@ -430,7 +399,7 @@ test.register_coroutine_test( "main", capabilities.lockCredentials.commandResult( { commandName = "updateCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -442,9 +411,9 @@ test.register_coroutine_test( "deleteCredential command received and commandResult is success", function() init_migration() - add_credential(0, "abc123") - add_credential(0, "test123") - add_credential(0, "321test") + add_credential(1, "abc123") + add_credential(2, "test123") + add_credential(3, "321test") -- try to delete credential with wrong index and expect a failure test.socket.capability:__queue_receive({ @@ -452,7 +421,7 @@ test.register_coroutine_test( { capability = capabilities.lockCredentials.ID, command = "deleteCredential", - args = { "4", "pin" } + args = { 4, "pin" } }, }) test.socket.capability:__expect_send( @@ -460,7 +429,7 @@ test.register_coroutine_test( "main", capabilities.lockCredentials.commandResult( { commandName = "deleteCredential", statusCode = "failure" }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -472,7 +441,7 @@ test.register_coroutine_test( { capability = capabilities.lockCredentials.ID, command = "deleteCredential", - args = { "1", "pin" } + args = { 1, "pin" } }, }) test.socket.zigbee:__expect_send({ @@ -481,37 +450,15 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.mock_time.advance_time(2) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) test.wait_for_events() + -- Lock acknowledges the clear test.socket.zigbee:__queue_receive({ mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx( mock_device, - 0x01, - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - "" + DoorLock.types.DrlkPassFailStatus.PASS ) }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users( - { - { userIndex = 2, userType = "guest", userName = "Guest2" }, - { userIndex = 3, userType = "guest", userName = "Guest3" } - }, - { state_change = true, visibility = { displayed = true } } - ) - ) - ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -520,7 +467,7 @@ test.register_coroutine_test( { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, { userIndex = 3, credentialIndex = 3, credentialType = "pin" } }, - { state_change = true, visibility = { displayed = true } } + { visibility = { displayed = false } } ) ) ) @@ -529,7 +476,7 @@ test.register_coroutine_test( "main", capabilities.lockCredentials.commandResult( { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) @@ -541,9 +488,9 @@ test.register_coroutine_test( "deleteAllCredentials command received and commandResult is success", function() init_migration() - add_credential(0, "abc123") - add_credential(0, "test123") - add_credential(0, "321test") + add_credential(1, "abc123") + add_credential(2, "test123") + add_credential(3, "321test") test.socket.capability:__queue_receive({ mock_device.id, @@ -554,49 +501,25 @@ test.register_coroutine_test( }, }) - test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) - }) - - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) + mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) - test.wait_for_events() - test.mock_time.advance_time(2) + + -- Lock acknowledges that all pins were cleared test.socket.zigbee:__queue_receive({ mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx( mock_device, - 0x01, - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - "" + DoorLock.types.DrlkPassFailStatus.PASS ) }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users( - { - { userIndex = 2, userType = "guest", userName = "Guest2" }, - { userIndex = 3, userType = "guest", userName = "Guest3" } - }, - { 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" }, - { userIndex = 3, credentialIndex = 3, credentialType = "pin" } - }, - { state_change = true, visibility = { displayed = true } } + {}, + { visibility = { displayed = false } } ) ) ) @@ -605,7 +528,7 @@ test.register_coroutine_test( "main", capabilities.lockCredentials.commandResult( { commandName = "deleteAllCredentials", statusCode = "success" }, - { state_change = true, visibility = { displayed = true } } + { state_change = true, visibility = { displayed = false } } ) ) ) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua index af8c8f20ce..504b3e91db 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua @@ -9,7 +9,7 @@ local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" local DoorLock = clusters.DoorLock local capabilities = require "st.capabilities" -local lock_utils = require "zigbee_lock_utils" +local constants = require "lock_handlers.constants" local json = require "st.json" @@ -63,7 +63,7 @@ test.register_coroutine_test( 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.lockCodes.migrated(true, { visibility = { displayed = false } }))) test.wait_for_events() - assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") end ) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua index 12f8331253..2c30603a1e 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua @@ -18,6 +18,7 @@ local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType local ProgrammingEventCode = DoorLock.types.ProgramEventCode +local consts = require "lock_handlers.constants" local json = require "dkjson" local mock_device = test.mock_device.build_test_zigbee_device( @@ -25,7 +26,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device)end + test.mock_device.add_test_device(mock_device) +end test.set_test_init_function(test_init) @@ -42,6 +44,8 @@ end test.register_coroutine_test( "Configure should configure all necessary attributes and begin reading codes", function() + mock_device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, false, { persist = true }) + test.wait_for_events() test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.wait_for_events() From 9200a495c1c9e1cddafb0cbc6d20865fbef181bf Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 10:49:00 -0500 Subject: [PATCH 18/33] update samsungsds handling --- drivers/SmartThings/zigbee-lock/src/init.lua | 1 - .../src/lock_handlers/commands.lua | 12 ++++----- .../zigbee-lock/src/samsungsds/init.lua | 25 +++---------------- .../src/test/test_zigbee_samsungsds.lua | 14 +++++++++++ 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index ad342367c7..92fecc4d89 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -44,7 +44,6 @@ function LockLifecycle.init(driver, device) end function LockLifecycle.do_configure(self, device) - print("@@## Configuring device 2") device:send(device_management.build_bind_request(device, clusters.PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) device:send(clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua index d556b90476..aa7f77dd51 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua @@ -244,13 +244,13 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) -- get method of lock event local OpEventSource = clusters.DoorLock.types.DrlkOperationEventSource local OP_EVENT_SOURCE_CAPABILITY_MAP = { - [OpEventSource.KEYPAD] = "keypad", - [OpEventSource.COMMAND] = "command", - [OpEventSource.MANUAL] = "manual", - [OpEventSource.RFID] = "rfid", + [OpEventSource.KEYPAD] = "keypad", + [OpEventSource.RF] = "command", + [OpEventSource.MANUAL] = "manual", + [OpEventSource.RFID] = "rfid", -- These last two sources are not found in the spec, but they were in the legacy driver - [5] = "fingerprint", - [6] = "bluetooth", + [4] = "fingerprint", + [5] = "bluetooth", } if (op_event_source ~= OpEventSource.KEYPAD and ( op_event_code == OpEventCode.AUTO_LOCK or diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index fdb7c514aa..fca6c272db 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -11,7 +11,6 @@ local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Lock = capabilities.lock local constants = require "lock_handlers.constants" -local lock_utils = require "lock_handlers.utils" local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F local SAMSUNG_SDS_MFR_CODE = 0x0003 @@ -54,25 +53,9 @@ local function emit_event_if_latest_state_missing(device, component, capability, end end -local function load_device_state(device) - local slga_migrated = device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) or false - if slga_migrated then - lock_utils.reload_tables(device) - else - local legacy_lock_utils = require "legacy-handlers.legacy_lock_utils" - legacy_lock_utils.populate_state_from_data(device) - end -end - local device_added = function(self, 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 migration event in the datastore - device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) - end + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. + emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) device:emit_event(capabilities.battery.battery(100)) end @@ -86,10 +69,10 @@ end local battery_init = battery_defaults.build_linear_voltage_init(4.0, 6.0) local device_init = function(driver, device, event) + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. battery_init(driver, device, event) device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) - load_device_state(device) end local samsung_sds_driver = { @@ -120,7 +103,7 @@ local samsung_sds_driver = { added = device_added, init = device_init }, - can_handle = require("samsungsds.can_handle") + can_handle = require("samsungsds.can_handle"), } return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua index fe76380bba..da9807aae7 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua @@ -50,6 +50,20 @@ end test.set_test_init_function(test_init) +local constants = require "lock_handlers.constants" +test.register_coroutine_test( + "Device init function handler", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init"}) + test.socket.capability:__set_channel_ordering("relaxed") + test.wait_for_events() + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "Device init did not set migrated field to true") + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes", function() From 4c4fa54ea01bbb96d8e8e218bec4cb86fa3e8247 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 11:03:00 -0500 Subject: [PATCH 19/33] remove unnecessary test (post-migration yale is not handled differently) --- .../zigbee-lock/src/test/test_zigbee_yale.lua | 497 ------------------ 1 file changed, 497 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua deleted file mode 100644 index 3ac6d09e86..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua +++ /dev/null @@ -1,497 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Mock out globals -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" - -local PowerConfiguration = clusters.PowerConfiguration -local Alarm = clusters.Alarms - -local DoorLock = clusters.DoorLock -local DoorLockUserStatus = DoorLock.types.DrlkUserStatus -local DoorLockUserType = DoorLock.types.DrlkUserType -local ProgrammingEventCode = DoorLock.types.ProgramEventCode - -test.disable_startup_messages() - - -local mock_device = test.mock_device.build_test_zigbee_device({ - profile = t_utils.get_profile_definition("base-lock.yml"), - _provisioning_state = "TYPED", - zigbee_endpoints = { - [1] = { id = 1, manufacturer = "Yale", server_clusters = { 0x0001 } } - } -}) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init_default() - test.disable_startup_messages() - test.mock_device.add_test_device(mock_device) - 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.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read( - mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) -end - -local function test_init_lifecycle_event(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init"}) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( - mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported - :build_test_attr_report(mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) - test.wait_for_events() -end - -test.set_test_init_function(test_init_default) - -local expect_reload_all_codes_messages = function() - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, - true) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfTotalUsersSupported:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) -end - -test.register_coroutine_test( - "Configure should configure all necessary attributes and begin reading codes", - function() - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining - :configure_reporting(mock_device, - 600, - 21600, - 1) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - DoorLock.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, - 0, - 3600, - 0) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - Alarm.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, - 0, - 21600, - 0) }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.wait_for_events() - - test.mock_time.advance_time(2) - expect_reload_all_codes_messages() - end -) - -test.register_coroutine_test( - "Adding a credential should succeed and report users, credentials, and command result.", - function() - test_init_lifecycle_event(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - test.wait_for_events() - - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { 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( - "Updating a credential should succeed and report users, credentials, and command result.", - function() - test_init_lifecycle_event(mock_device) - -- add credential first - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", args = { 0, "guest", "pin", "1234" } } }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - test.wait_for_events() - - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { 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 } } - ) - ) - ) - - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.wait_for_events() - - -- update the credential - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "updateCredential", - args = { "1", "1", "pin", "changedPin123" } - }, - }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "changedPin123" - ) - } - ) - test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") - test.mock_time.advance_time(4) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.GetPINCode(mock_device, 1) - } - ) - test.wait_for_events() - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "abc123" - ) - } - ) - 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.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() - end -) - -test.register_coroutine_test( - "The lock reporting a single code has been set and then deleted should be handled", - function() - test_init_lifecycle_event(mock_device) - -- add credential - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_ADDED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) - }) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { state_change = true, visibility = { displayed = true } })) - ) - test.wait_for_events() - - -- delete the credential - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.AVAILABLE, - 0x0000, - "data" - ) - }) - 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_message_test( - "The lock reporting master code changed", - { - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.MASTER_CODE_CHANGED - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", - capabilities.lockCredentials.commandResult({ commandName = "updateCredential", statusCode = "success" }, - { state_change = true, visibility = { displayed = true } })) - } - } -) - -test.register_coroutine_test( - "The lock reporting all codes have been deleted should be handled", - function() - test_init_lifecycle_event(mock_device) - -- add a credential - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_ADDED, - 1, - "1234", - DoorLockUserType.UNRESTRICTED, - DoorLockUserStatus.OCCUPIED_ENABLED, - 0x0000, - "data" - ) - }) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { state_change = true, visibility = { displayed = true } })) - ) - test.wait_for_events() - - -- delete all credentials - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( - mock_device, - 0x0, - ProgrammingEventCode.PIN_CODE_DELETED, - 0xFFFF - ) - }) - 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( - "Out of band get pin call should add credential if it doesn't exist (happens during reload all codes).", - function() - test_init_lifecycle_event(mock_device) - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.GetPINCodeResponse.build_test_rx( - mock_device, - 0x01, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "1234" - ) - } - ) - 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({ { credentialIndex = 1, credentialType = "pin", userIndex = 1 } }, - { state_change = true, visibility = { displayed = true } }) - ) - ) - end -) - -test.run_registered_tests() From 4f372c954d3e1bbcd39a859e1cfc480b69e77528 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 11:40:04 -0500 Subject: [PATCH 20/33] update generic profile migration logic for new users --- drivers/SmartThings/zigbee-lock/src/init.lua | 40 +++++++++++++++---- .../src/lock_handlers/attributes.lua | 12 +++++- .../zigbee-lock/src/lock_handlers/utils.lua | 2 +- .../src/test/test_generic_lock_migration.lua | 11 +++++ .../zigbee-lock/src/test/test_zigbee_lock.lua | 2 - 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 92fecc4d89..510ff2b33a 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -23,6 +23,7 @@ function LockLifecycle.device_added(driver, device) device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore end + -- set initial state driver:inject_capability_command(device, { capability = capabilities.refresh.ID, command = capabilities.refresh.commands.refresh.NAME, @@ -31,16 +32,20 @@ function LockLifecycle.device_added(driver, device) end function LockLifecycle.init(driver, device) - if device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true and device:supports_capability(capabilities.lockCodes) then - -- ensure lockCodes capability state is reflected correctly for migrated devices + local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) + if lock_pins_supported_by_profile and device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true then + -- ensure lockCodes capability state is reflected correctly for already migrated devices device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - end - if device:supports_capability(capabilities.lockCredentials) then + -- then, set device state device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + device.thread:call_with_delay(15, function(d) + lock_utils.sync_device_state(device) + end) + elseif not lock_pins_supported_by_profile then + -- generically fingerprinted profiles do not have any codes/users/credentials capabilities. + -- We should check its PIN users if it should be re-profiled. + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) end - device.thread:call_with_delay(15, function(d) - lock_utils.sync_code_state(device) - end) end function LockLifecycle.do_configure(self, device) @@ -53,7 +58,25 @@ function LockLifecycle.do_configure(self, device) device:send(device_management.build_bind_request(device, clusters.Alarms.ID, self.environment_info.hub_zigbee_eui)) device:send(clusters.Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - device.thread:call_with_delay(2, function(d) lock_utils.sync_code_state(device) end) + if device:supports_capability(capabilities.lockCredentials) then + device.thread:call_with_delay(2, function(d) lock_utils.sync_device_state(device) end) + end +end + +function LockLifecycle.info_changed(driver, device, event, args) + local profile_switched = device.profile.id ~= args.old_st_store.profile.id + if profile_switched and device:supports_capability(capabilities.lockCodes) then + -- ensure all slga migration steps are run, and that the latest device state is synced to the driver. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + end + lock_utils.sync_device_state(device) + device.thread:call_with_delay(15, function(d) + lock_utils.sync_device_state(device) + end) + end end local zigbee_lock_driver = { @@ -61,6 +84,7 @@ local zigbee_lock_driver = { added = LockLifecycle.device_added, init = LockLifecycle.init, doConfigure = LockLifecycle.do_configure, + infoChanged = LockLifecycle.info_changed, }, zigbee_handlers = { cluster = { diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua index f5cbc6ce6a..4edb992cb8 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua @@ -40,8 +40,16 @@ function AttributeHandlers.min_pin_code_length(driver, device, value) end function AttributeHandlers.number_of_pin_users_supported(driver, device, value) - device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) - device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + if not device:supports_capability_by_id(capabilities.lockCodes.ID) and value.value > 0 then + -- this device was generically fingerprinted, but supports PIN users, so we should migrate it. + device:try_update_metadata({ profile = "base-lock" }) + end + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) + end + if device:supports_capability(capabilities.lockUsers) then + device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + end end return AttributeHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua index 8ce9dc67f8..b19c9d9c94 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua @@ -40,7 +40,7 @@ end -- [[ CAPABILITY STATE MANAGEMENT ]] -- -function lock_utils.sync_code_state(device) +function lock_utils.sync_device_state(device) -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. device:send(clusters.DoorLock.attributes.SendPINOverTheAir:write(device, true)) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua index 4580101cd0..344efcccb0 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua @@ -6,6 +6,7 @@ local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock @@ -31,6 +32,16 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device)}) test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 8)}) mock_device:expect_metadata_update({profile = "base-lock"}) + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") })) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) end, { min_api_version = 17 diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua index 243fae5261..edac9a51c4 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua @@ -16,7 +16,6 @@ local constants = require "lock_handlers.constants" local test_credential_index = 1 local test_credentials = {} -local test_users = {} local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("base-lock.yml"), @@ -28,7 +27,6 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init_new_capabilities() test_credential_index = 1 test_credentials = {} - test_users = {} test.mock_device.add_test_device(mock_device) end From ef4cd63546d028ae5a4d9aa198e6dfa49eb17375 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 11:51:57 -0500 Subject: [PATCH 21/33] update paths --- drivers/SmartThings/zigbee-lock/src/init.lua | 35 ++-- .../src/legacy-handlers/can_handle.lua | 4 +- .../zigbee-lock/src/legacy-handlers/init.lua | 2 +- .../src/lock_handlers/attributes.lua | 55 ------ .../src/lock_handlers/capabilities.lua | 68 ++++---- .../{commands.lua => zigbee_responses.lua} | 161 +++++++++++------- .../constants.lua | 0 .../{lock_handlers => lock_utils}/tables.lua | 4 +- .../{lock_handlers => lock_utils}/utils.lua | 24 +-- .../zigbee-lock/src/samsungsds/init.lua | 6 +- .../test/test_lock_credentials_commands.lua | 4 +- .../src/test/test_lock_pre_configured.lua | 4 +- .../src/test/test_lock_programming_events.lua | 2 +- .../zigbee-lock/src/test/test_lock_tables.lua | 6 +- .../src/test/test_lock_users_commands.lua | 4 +- .../zigbee-lock/src/test/test_zigbee_lock.lua | 2 +- .../test_zigbee_lock_code_slga_migration.lua | 2 +- .../src/test/test_zigbee_lock_legacy.lua | 2 +- .../src/test/test_zigbee_samsungsds.lua | 2 +- .../src/yale-fingerprint-lock/can_handle.lua | 4 +- 20 files changed, 191 insertions(+), 200 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua rename drivers/SmartThings/zigbee-lock/src/lock_handlers/{commands.lua => zigbee_responses.lua} (62%) rename drivers/SmartThings/zigbee-lock/src/{lock_handlers => lock_utils}/constants.lua (100%) rename drivers/SmartThings/zigbee-lock/src/{lock_handlers => lock_utils}/tables.lua (98%) rename drivers/SmartThings/zigbee-lock/src/{lock_handlers => lock_utils}/utils.lua (74%) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 510ff2b33a..65dcf48a60 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -7,10 +7,9 @@ local device_management = require "st.zigbee.device_management" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" -local constants = require "lock_handlers.constants" -local lock_utils = require "lock_handlers.utils" -local command_handlers = require "lock_handlers.commands" -local attribute_handlers = require "lock_handlers.attributes" +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local zigbee_handlers = require "lock_handlers.zigbee_responses" local capability_handlers = require "lock_handlers.capabilities" local LockLifecycle = {} @@ -21,7 +20,7 @@ function LockLifecycle.device_added(driver, device) -- 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(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore end -- set initial state driver:inject_capability_command(device, { @@ -33,7 +32,7 @@ end function LockLifecycle.init(driver, device) local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) - if lock_pins_supported_by_profile and device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true then + if lock_pins_supported_by_profile and device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) == true then -- ensure lockCodes capability state is reflected correctly for already migrated devices device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) -- then, set device state @@ -68,7 +67,7 @@ function LockLifecycle.info_changed(driver, device, event, args) if profile_switched and device:supports_capability(capabilities.lockCodes) then -- ensure all slga migration steps are run, and that the latest device state is synced to the driver. device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) if device:supports_capability(capabilities.lockCredentials) then device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) end @@ -89,23 +88,23 @@ local zigbee_lock_driver = { zigbee_handlers = { cluster = { [clusters.Alarms.ID] = { - [clusters.Alarms.client.commands.Alarm.ID] = command_handlers.alarm + [clusters.Alarms.client.commands.Alarm.ID] = zigbee_handlers.alarm }, [clusters.DoorLock.ID] = { - [clusters.DoorLock.client.commands.ClearAllPINCodesResponse.ID] = command_handlers.clear_all_pin_codes_response, - [clusters.DoorLock.client.commands.ClearPINCodeResponse.ID] = command_handlers.clear_pin_code_response, - [clusters.DoorLock.client.commands.GetPINCodeResponse.ID] = command_handlers.get_pin_code_response, - [clusters.DoorLock.client.commands.ProgrammingEventNotification.ID] = command_handlers.programming_event_notification, - [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = command_handlers.operating_event_notification, - [clusters.DoorLock.client.commands.SetPINCodeResponse.ID] = command_handlers.set_pin_code_response, + [clusters.DoorLock.client.commands.ClearAllPINCodesResponse.ID] = zigbee_handlers.clear_all_pin_codes_response, + [clusters.DoorLock.client.commands.ClearPINCodeResponse.ID] = zigbee_handlers.clear_pin_code_response, + [clusters.DoorLock.client.commands.GetPINCodeResponse.ID] = zigbee_handlers.get_pin_code_response, + [clusters.DoorLock.client.commands.ProgrammingEventNotification.ID] = zigbee_handlers.programming_event_notification, + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = zigbee_handlers.operating_event_notification, + [clusters.DoorLock.client.commands.SetPINCodeResponse.ID] = zigbee_handlers.set_pin_code_response, } }, attr = { [clusters.DoorLock.ID] = { - [clusters.DoorLock.attributes.LockState.ID] = attribute_handlers.lock_state, - [clusters.DoorLock.attributes.MaxPINCodeLength.ID] = attribute_handlers.max_pin_code_length, - [clusters.DoorLock.attributes.MinPINCodeLength.ID] = attribute_handlers.min_pin_code_length, - [clusters.DoorLock.attributes.NumberOfPINUsersSupported.ID] = attribute_handlers.number_of_pin_users_supported, + [clusters.DoorLock.attributes.LockState.ID] = zigbee_handlers.lock_state, + [clusters.DoorLock.attributes.MaxPINCodeLength.ID] = zigbee_handlers.max_pin_code_length, + [clusters.DoorLock.attributes.MinPINCodeLength.ID] = zigbee_handlers.min_pin_code_length, + [clusters.DoorLock.attributes.NumberOfPINUsersSupported.ID] = zigbee_handlers.number_of_pin_users_supported, } } }, diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua index e81d05a92e..d0f244cc04 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua @@ -2,8 +2,8 @@ -- Licensed under the Apache License, Version 2.0 return function(opts, driver, device, ...) - local constants = require "lock_handlers.constants" - local slga_migrated = device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) or false + local consts = require "lock_utils.constants" + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false if not slga_migrated then local subdriver = require("legacy-handlers") return true, subdriver diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua index 4521cf29a1..7af05f429a 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -406,7 +406,7 @@ local migrate = function(driver, device, command) min_code_len = code_length end - local post_migration_constants = require "lock_handlers.constants" + local post_migration_constants = require "lock_utils.constants" local LockUsers = capabilities.lockUsers local LockCredentials = capabilities.lockCredentials device:emit_event(LockCredentials.minPinCodeLen(min_code_len, { visibility = { displayed = false } })) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua deleted file mode 100644 index 4edb992cb8..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/attributes.lua +++ /dev/null @@ -1,55 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local capabilities = require "st.capabilities" -local constants = require "lock_handlers.constants" -local socket = require "cosock.socket" - -local AttributeHandlers = {} - --- [[ DOOR LOCK CLUSTER ATTRIBUTES ]] -- - -function AttributeHandlers.lock_state(driver, device, value, zb_rx) - local attr = capabilities.lock.lock - local LOCK_STATE = { - [value.NOT_FULLY_LOCKED] = attr.unknown(), - [value.LOCKED] = attr.locked(), - [value.UNLOCKED] = attr.unlocked(), - [value.UNDEFINED] = attr.unknown(), - } - - -- this is where we decide whether or not we need to delay our lock event because we've - -- observed it coming before the event (or we're starting to compute the timer) - local delay = device:get_field(constants.DELAY_LOCK_EVENT) or 100 - if (delay < constants.MAX_DELAY) then - device.thread:call_with_delay(delay+.5, function () - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) - end) - else - device:set_field(constants.DELAY_LOCK_EVENT, socket.gettime()) - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) - end -end - -function AttributeHandlers.max_pin_code_length(driver, device, value) - device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) -end - -function AttributeHandlers.min_pin_code_length(driver, device, value) - device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) -end - -function AttributeHandlers.number_of_pin_users_supported(driver, device, value) - if not device:supports_capability_by_id(capabilities.lockCodes.ID) and value.value > 0 then - -- this device was generically fingerprinted, but supports PIN users, so we should migrate it. - device:try_update_metadata({ profile = "base-lock" }) - end - if device:supports_capability(capabilities.lockCredentials) then - device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) - end - if device:supports_capability(capabilities.lockUsers) then - device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) - end -end - -return AttributeHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index c5bd5dc320..c34f8e8256 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -3,9 +3,9 @@ local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" -local lock_utils = require "lock_handlers.utils" -local tables = require "lock_handlers.tables" -local constants = require "lock_handlers.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local consts = require "lock_utils.constants" local Alarm = clusters.Alarms local LockCluster = clusters.DoorLock @@ -31,7 +31,7 @@ end function CapabilityHandlers.add_user(driver, device, command) if lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.ADD, constants.COMMAND_RESULT.BUSY) + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.BUSY) return end @@ -42,23 +42,23 @@ function CapabilityHandlers.add_user(driver, device, command) userName = command.args.userName, userType = command.args.userType, }) - local additional_info = status == constants.COMMAND_RESULT.SUCCESS and { userIndex = next_index } or nil - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.ADD, status, additional_info) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = next_index } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, status, additional_info) end function CapabilityHandlers.update_user(driver, device, command) - local status = lock_utils.is_device_busy(device) and constants.COMMAND_RESULT.BUSY or + local status = lock_utils.is_device_busy(device) and consts.COMMAND_RESULT.BUSY or tables.update_entry(device, "users", command.args.userIndex, { userName = command.args.userName, userType = command.args.userType } ) - local additional_info = status == constants.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } or nil - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.UPDATE, status, additional_info) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, status, additional_info) end function CapabilityHandlers.delete_user(driver, device, command) if lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, constants.COMMAND_RESULT.BUSY) + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.BUSY) return end @@ -68,40 +68,40 @@ function CapabilityHandlers.delete_user(driver, device, command) -- Set busy state with the full user+credential context BEFORE injecting. -- Injected capability commands are schema-validated, so extra args like userIndex -- would be stripped. By setting device fields here we preserve the full context. - lock_utils.set_busy_state(device, constants.LOCK_USERS.DELETE, { + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE, { userIndex = command.args.userIndex, credentialIndex = associated_credential_index, - credentialType = constants.CRED_TYPE_PIN, + credentialType = consts.CRED_TYPE_PIN, }) driver:inject_capability_command(device, { capability = capabilities.lockCredentials.ID, command = capabilities.lockCredentials.commands.deleteCredential.NAME, args = { credentialIndex = associated_credential_index, - credentialType = constants.CRED_TYPE_PIN, + credentialType = consts.CRED_TYPE_PIN, } }) else -- No associated credential: delete the user entry directly and report the result local removed = tables.delete_entry(device, "users", command.args.userIndex) - local status = type(removed) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE - local additional_info = status == constants.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } or nil - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, status, additional_info) + local status = type(removed) == "table" and consts.COMMAND_RESULT.SUCCESS or consts.COMMAND_RESULT.FAILURE + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, status, additional_info) end end function CapabilityHandlers.delete_all_users(driver, device, command) if lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) return end -- Set busy state with DELETE_ALL context BEFORE injecting so the response handler -- knows to clear both tables and emit results for both capabilities. - lock_utils.set_busy_state(device, constants.LOCK_USERS.DELETE_ALL, {}) + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE_ALL, {}) driver:inject_capability_command(device, { capability = capabilities.lockCredentials.ID, command = capabilities.lockCredentials.commands.deleteAllCredentials.NAME, - args = { credentialType = constants.CRED_TYPE_PIN } + args = { credentialType = consts.CRED_TYPE_PIN } }) end @@ -110,7 +110,7 @@ end function CapabilityHandlers.add_credential(driver, device, command) if lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.ADD, constants.COMMAND_RESULT.BUSY) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) return end @@ -118,7 +118,7 @@ function CapabilityHandlers.add_credential(driver, device, command) local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex -- Store credentialIndex alongside userIndex; for zigbee DoorLock they are the same slot. - lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.ADD, { + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { userIndex = user_index, credentialIndex = user_index, credentialType = command.args.credentialType, @@ -134,16 +134,16 @@ end function CapabilityHandlers.update_credential(driver, device, command) if lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, constants.COMMAND_RESULT.BUSY) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.BUSY) return end if not tables.find_entry(device, "credentials", command.args.credentialIndex) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.UPDATE, constants.COMMAND_RESULT.FAILURE) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) return end - lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.UPDATE, { + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { userIndex = command.args.userIndex, credentialIndex = command.args.credentialIndex, credentialType = command.args.credentialType, @@ -158,23 +158,23 @@ function CapabilityHandlers.update_credential(driver, device, command) end function CapabilityHandlers.delete_credential(driver, device, command) - local cmd_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) - if cmd_in_progress == constants.LOCK_USERS.DELETE then + if cmd_in_progress == consts.LOCK_USERS.DELETE then -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. - local credential_args = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) device:send(LockCluster.server.commands.ClearPINCode(device, credential_args.credentialIndex)) elseif lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.BUSY) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) else -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) if not found_cred then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, constants.COMMAND_RESULT.FAILURE) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) return end - lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.DELETE, { + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { credentialIndex = command.args.credentialIndex, credentialType = command.args.credentialType, userIndex = found_cred.userIndex, @@ -185,15 +185,15 @@ function CapabilityHandlers.delete_credential(driver, device, command) end function CapabilityHandlers.delete_all_credentials(driver, device, command) - local cmd_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) - if cmd_in_progress == constants.LOCK_USERS.DELETE_ALL then + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then -- Injected by deleteAllUsers; busy state was already set with LOCK_USERS.DELETE_ALL context. device:send(LockCluster.server.commands.ClearAllPINCodes(device)) elseif lock_utils.is_device_busy(device) then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, constants.COMMAND_RESULT.BUSY) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) else - lock_utils.set_busy_state(device, constants.LOCK_CREDENTIALS.DELETE_ALL, command.args) + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE_ALL, command.args) device:send(LockCluster.server.commands.ClearAllPINCodes(device)) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua similarity index 62% rename from drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua rename to drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua index aa7f77dd51..4cae0cb0a3 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -6,17 +6,17 @@ local socket = require "cosock.socket" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" -local constants = require "lock_handlers.constants" -local lock_utils = require "lock_handlers.utils" -local tables = require "lock_handlers.tables" +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" -local ResponseHandlers = {} +local ZigbeeHandlers = {} -function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) +function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) -- cached values from capability command - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) - local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) -- zb response values local set_pin_code_status = zb_rx.body.zcl_body.status.value @@ -28,22 +28,22 @@ function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) -- mapped failures states local RESPONSE_RESULT_MAP = { - [SetCodeStatus.GENERAL_FAILURE] = constants.COMMAND_RESULT.FAILURE, - [SetCodeStatus.MEMORY_FULL] = constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, - [SetCodeStatus.DUPLICATE_CODE] = constants.COMMAND_RESULT.DUPLICATE, + [SetCodeStatus.GENERAL_FAILURE] = consts.COMMAND_RESULT.FAILURE, + [SetCodeStatus.MEMORY_FULL] = consts.COMMAND_RESULT.RESOURCE_EXHAUSTED, + [SetCodeStatus.DUPLICATE_CODE] = consts.COMMAND_RESULT.DUPLICATE, } -- apply result based on response and identify command result status local result_status if set_pin_code_status == SetCodeStatus.SUCCESS then - if command_in_progress == constants.LOCK_CREDENTIALS.ADD then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then result_status = tables.add_entry(device, "credentials", { userIndex = credential_args_in_use.userIndex, credentialIndex = credential_args_in_use.credentialIndex, credentialType = credential_args_in_use.credentialType, credentialName = credential_args_in_use.credentialName, -- optional }) - elseif command_in_progress == constants.LOCK_CREDENTIALS.UPDATE then + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then result_status = tables.update_entry(device, "credentials", credential_args_in_use.credentialIndex, { @@ -55,11 +55,11 @@ function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) elseif RESPONSE_RESULT_MAP[set_pin_code_status] then result_status = RESPONSE_RESULT_MAP[set_pin_code_status] else - result_status = constants.COMMAND_RESULT.FAILURE + result_status = consts.COMMAND_RESULT.FAILURE end -- emit command result - local additional_info = result_status == constants.COMMAND_RESULT.SUCCESS and { + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex, credentialIndex = credential_args_in_use.credentialIndex, } or nil @@ -68,9 +68,9 @@ function ResponseHandlers.set_pin_code_response(driver, device, zb_rx) end -function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_rx) +function ZigbeeHandlers.clear_all_pin_codes_response(driver, device, zb_rx) local clear_pin_code_status = zb_rx.body.zcl_body.status.value - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus -- PASS = 0 @@ -80,31 +80,31 @@ function ResponseHandlers.clear_all_pin_codes_response(driver, device, zb_rx) local user_status, credential_status if clear_pin_code_status == ResponseStatus.PASS then -- Only clear the users table when this response is for a deleteAllUsers flow. - if command_in_progress == constants.LOCK_USERS.DELETE_ALL then + if command_in_progress == consts.LOCK_USERS.DELETE_ALL then user_status = tables.delete_all_entries(device, "users") end credential_status = tables.delete_all_entries(device, "credentials") elseif clear_pin_code_status == ResponseStatus.FAIL then - user_status = constants.COMMAND_RESULT.FAILURE - credential_status = constants.COMMAND_RESULT.FAILURE + user_status = consts.COMMAND_RESULT.FAILURE + credential_status = consts.COMMAND_RESULT.FAILURE end -- emit command results - if command_in_progress == constants.LOCK_USERS.DELETE_ALL then + if command_in_progress == consts.LOCK_USERS.DELETE_ALL then -- deleteAllUsers injects deleteAllCredentials, so both command results should be emitted. - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE_ALL, user_status) - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) - elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE_ALL then - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, user_status) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE_ALL then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, credential_status) end lock_utils.clear_busy_state(device) end -function ResponseHandlers.clear_pin_code_response(driver, device, zb_rx) +function ZigbeeHandlers.clear_pin_code_response(driver, device, zb_rx) local clear_pin_code_status = zb_rx.body.zcl_body.status.value - local credential_args_in_use = device:get_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus -- PASS = 0 @@ -113,39 +113,39 @@ function ResponseHandlers.clear_pin_code_response(driver, device, zb_rx) -- apply result and identify command result statuses local user_status, credential_status if clear_pin_code_status == ResponseStatus.PASS then - if command_in_progress == constants.LOCK_USERS.DELETE then + if command_in_progress == consts.LOCK_USERS.DELETE then local removed_user = tables.delete_entry(device, "users", credential_args_in_use.userIndex) - user_status = type(removed_user) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE + user_status = type(removed_user) == "table" and consts.COMMAND_RESULT.SUCCESS or consts.COMMAND_RESULT.FAILURE end local removed_cred = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) - credential_status = type(removed_cred) == "table" and constants.COMMAND_RESULT.SUCCESS or constants.COMMAND_RESULT.FAILURE + credential_status = type(removed_cred) == "table" and consts.COMMAND_RESULT.SUCCESS or consts.COMMAND_RESULT.FAILURE elseif clear_pin_code_status == ResponseStatus.FAIL then - user_status = constants.COMMAND_RESULT.FAILURE - credential_status = constants.COMMAND_RESULT.FAILURE + user_status = consts.COMMAND_RESULT.FAILURE + credential_status = consts.COMMAND_RESULT.FAILURE end -- emit command results - if command_in_progress == constants.LOCK_USERS.DELETE then + if command_in_progress == consts.LOCK_USERS.DELETE then -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. - local user_info = user_status == constants.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil - local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil - lock_utils.emit_command_result(device, capabilities.lockUsers, constants.LOCK_USERS.DELETE, user_status, user_info) - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) - elseif command_in_progress == constants.LOCK_CREDENTIALS.DELETE then - local cred_info = credential_status == constants.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil - lock_utils.emit_command_result(device, capabilities.lockCredentials, constants.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE then + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) end lock_utils.clear_busy_state(device) end -function ResponseHandlers.get_pin_code_response(driver, device, zb_rx) +function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) -- cached values from capability command - local command_in_progress = device:get_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) -- response values local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) - if command_in_progress == constants.SYNC.CODES_FROM_LOCK then + if command_in_progress == consts.SYNC.CODES_FROM_LOCK then -- if an entry already exists at this user index, this will be a no-op. -- This is just meant to populate our tables with the existing codes on the lock, -- so we don't need to worry about handling updates vs adds here @@ -154,28 +154,28 @@ function ResponseHandlers.get_pin_code_response(driver, device, zb_rx) userName = "User " .. user_id, -- generic default, since we didn't explicitly set a name for this code. userType = "guest", -- also a generic default. }) - if status == constants.COMMAND_RESULT.SUCCESS then + if status == consts.COMMAND_RESULT.SUCCESS then -- if the entry was successfully added to the user table, we should also add an entry to the credential table for this code. tables.add_entry(device, "credentials", { userIndex = user_id, credentialIndex = user_id, - credentialType = constants.CRED_TYPE_PIN, + credentialType = consts.CRED_TYPE_PIN, credentialName = "User " .. user_id, -- also a generic default. }) end if user_id >= tables.get_max_entries(device, "credentials") then - device:set_field(constants.SYNC.CODE_INDEX, nil) + device:set_field(consts.SYNC.CODE_INDEX, nil) lock_utils.clear_busy_state(device) else - local synced_code_index = device:get_field(constants.SYNC.CODE_INDEX) + 1 - device:set_field(constants.SYNC.CODE_INDEX, synced_code_index) - lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) + local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 + device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) end end end -function ResponseHandlers.programming_event_notification(driver, device, zb_rx) +function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) -- if the device is busy, one of our capability commands is in progress, so ignore this response to avoid duplicating a response. if lock_utils.is_device_busy(device) then return end @@ -208,7 +208,7 @@ function ResponseHandlers.programming_event_notification(driver, device, zb_rx) tables.add_entry(device, "credentials", { userIndex = user_id, credentialIndex = user_id, - credentialType = constants.CRED_TYPE_PIN, + credentialType = consts.CRED_TYPE_PIN, credentialName = "User " .. user_id, -- default }) elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_DELETED then @@ -219,7 +219,7 @@ function ResponseHandlers.programming_event_notification(driver, device, zb_rx) end end -function ResponseHandlers.operating_event_notification(driver, device, zb_rx) +function ZigbeeHandlers.operating_event_notification(driver, device, zb_rx) local op_event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) local op_event_source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) @@ -284,10 +284,10 @@ function ResponseHandlers.operating_event_notification(driver, device, zb_rx) capabilities.lock.ID, capabilities.lock.lock.ID ) then - local preceding_event_time = device:get_field(constants.DELAY_LOCK_EVENT) or 0 + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 local time_diff = socket.gettime() - preceding_event_time - if time_diff < constants.MAX_DELAY then - device:set_field(constants.DELAY_LOCK_EVENT, time_diff) + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) end end @@ -297,7 +297,7 @@ end -- [[ ALARMS CLUSTER COMMANDS ]] -- -function ResponseHandlers.alarm(driver, device, zb_rx) +function ZigbeeHandlers.alarm(driver, device, zb_rx) local ALARM_REPORT = { [0] = capabilities.lock.lock.unknown(), [1] = capabilities.lock.lock.unknown(), @@ -308,4 +308,51 @@ function ResponseHandlers.alarm(driver, device, zb_rx) end end -return ResponseHandlers + +-- [[ DOOR LOCK CLUSTER ATTRIBUTES ]] -- + +function ZigbeeHandlers.lock_state(driver, device, value, zb_rx) + local attr = capabilities.lock.lock + local LOCK_STATE = { + [value.NOT_FULLY_LOCKED] = attr.unknown(), + [value.LOCKED] = attr.locked(), + [value.UNLOCKED] = attr.unlocked(), + [value.UNDEFINED] = attr.unknown(), + } + + -- this is where we decide whether or not we need to delay our lock event because we've + -- observed it coming before the event (or we're starting to compute the timer) + local delay = device:get_field(consts.DELAY_LOCK_EVENT) or 100 + if (delay < consts.MAX_DELAY) then + device.thread:call_with_delay(delay+.5, function () + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end) + else + device:set_field(consts.DELAY_LOCK_EVENT, socket.gettime()) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end +end + +function ZigbeeHandlers.max_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function ZigbeeHandlers.min_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function ZigbeeHandlers.number_of_pin_users_supported(driver, device, value) + if not device:supports_capability_by_id(capabilities.lockCodes.ID) and value.value > 0 then + -- this device was generically fingerprinted, but supports PIN users, so we should migrate it. + device:try_update_metadata({ profile = "base-lock" }) + end + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) + end + if device:supports_capability(capabilities.lockUsers) then + device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + end +end + + +return ZigbeeHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/lock_handlers/constants.lua rename to drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua similarity index 98% rename from drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua rename to drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua index 97fc91d2ba..6602848f09 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -2,8 +2,8 @@ -- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" -local st_utils = require "st.utils" -local consts = require "lock_handlers.constants" +local st_utils = require "st.utils" +local consts = require "lock_utils.constants" local table_utils = {} diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua similarity index 74% rename from drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua rename to drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua index b19c9d9c94..89c177275a 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua @@ -3,7 +3,7 @@ local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" -local constants = require "lock_handlers.constants" +local consts = require "lock_utils.constants" local lock_utils = {} @@ -14,7 +14,7 @@ local lock_utils = {} -- If busy, return true. If not busy, clear any stale state and return false. function lock_utils.is_device_busy(device) local c_time = os.time() - local busy_since = device:get_field(constants.DRIVER_STATE.BUSY) or false + local busy_since = device:get_field(consts.DRIVER_STATE.BUSY) or false if (busy_since == false) or (c_time - busy_since > 10) then lock_utils.clear_busy_state(device) @@ -25,16 +25,16 @@ end -- Set states that may be required when in busy state function lock_utils.set_busy_state(device, command_name, command_args) - device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) - device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) - device:set_field(constants.DRIVER_STATE.BUSY, os.time()) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) + device:set_field(consts.DRIVER_STATE.BUSY, os.time()) end -- Clear states that were set when in busy state function lock_utils.clear_busy_state(device) - device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) - device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) - device:set_field(constants.DRIVER_STATE.BUSY, false) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) + device:set_field(consts.DRIVER_STATE.BUSY, false) end @@ -56,11 +56,11 @@ function lock_utils.sync_device_state(device) device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) end - if (device:get_field(constants.SYNC.CODE_INDEX) == nil) then -- if this value is nil, we haven't started syncing codes from the lock yet, so start the process - device:set_field(constants.SYNC.CODE_INDEX, 1) + if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then -- if this value is nil, we haven't started syncing codes from the lock yet, so start the process + device:set_field(consts.SYNC.CODE_INDEX, 1) end - lock_utils.set_busy_state(device, constants.SYNC.CODES_FROM_LOCK) - device:send(clusters.DoorLock.server.commands.GetPINCode(device, device:get_field(constants.SYNC.CODE_INDEX))) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, device:get_field(consts.SYNC.CODE_INDEX))) end function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index fca6c272db..446536a2ca 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -10,7 +10,7 @@ local cluster_base = require "st.zigbee.cluster_base" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Lock = capabilities.lock -local constants = require "lock_handlers.constants" +local consts = require "lock_utils.constants" local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F local SAMSUNG_SDS_MFR_CODE = 0x0003 @@ -54,7 +54,7 @@ local function emit_event_if_latest_state_missing(device, component, capability, end local device_added = function(self, device) - device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) device:emit_event(capabilities.battery.battery(100)) @@ -69,7 +69,7 @@ end local battery_init = battery_defaults.build_linear_voltage_init(4.0, 6.0) local device_init = function(driver, device, event) - device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. battery_init(driver, device, event) device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua index 5e85292484..871ec7e585 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -10,8 +10,8 @@ local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local DoorLock = clusters.DoorLock -local table_utils = require "lock_handlers.tables" -local constants = require "lock_handlers.constants" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua index ad4a5b331c..864a4d3d0e 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua @@ -12,8 +12,8 @@ local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local DoorLock = clusters.DoorLock -local table_utils = require "lock_handlers.tables" -local constants = require "lock_handlers.constants" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua index e9f78c3164..147e5c59ae 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -18,7 +18,7 @@ local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" local DoorLock = clusters.DoorLock local capabilities = require "st.capabilities" -local constants = require "lock_handlers.constants" +local constants = require "lock_utils.constants" local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua index 6396dbfcb2..82cc3164ee 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -1,7 +1,7 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -- --- Unit tests for lock_handlers/tables.lua +-- Unit tests for lock_utils/tables.lua -- Tests directly call table_utils functions and verify both return values -- and emitted capability events. @@ -9,8 +9,8 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local capabilities = require "st.capabilities" -local table_utils = require "lock_handlers.tables" -local constants = require "lock_handlers.constants" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" -- --------------------------------------------------------------------------- -- Shared mock device diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua index 78a8776fa1..070947291e 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua @@ -10,8 +10,8 @@ local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local DoorLock = clusters.DoorLock -local table_utils = require "lock_handlers.tables" -local constants = require "lock_handlers.constants" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua index edac9a51c4..3aabe03a62 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua @@ -12,7 +12,7 @@ local capabilities = require "st.capabilities" local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType -local constants = require "lock_handlers.constants" +local constants = require "lock_utils.constants" local test_credential_index = 1 local test_credentials = {} diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua index 504b3e91db..50cb548010 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua @@ -9,7 +9,7 @@ local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" local DoorLock = clusters.DoorLock local capabilities = require "st.capabilities" -local constants = require "lock_handlers.constants" +local constants = require "lock_utils.constants" local json = require "st.json" diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua index 2c30603a1e..c3b316f486 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua @@ -18,7 +18,7 @@ local DoorLockUserStatus = DoorLock.types.DrlkUserStatus local DoorLockUserType = DoorLock.types.DrlkUserType local ProgrammingEventCode = DoorLock.types.ProgramEventCode -local consts = require "lock_handlers.constants" +local consts = require "lock_utils.constants" local json = require "dkjson" local mock_device = test.mock_device.build_test_zigbee_device( diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua index da9807aae7..882d8f5efe 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua @@ -50,7 +50,7 @@ end test.set_test_init_function(test_init) -local constants = require "lock_handlers.constants" +local constants = require "lock_utils.constants" test.register_coroutine_test( "Device init function handler", function() diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua index 4b04b2aaf6..48a6521059 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua @@ -2,8 +2,8 @@ -- Licensed under the Apache License, Version 2.0 local yale_fingerprint_lock_models = function(opts, driver, device) - local constants = require "lock_handlers.constants" - local slga_migrated = device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) or false + local consts = require "lock_utils.constants" + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false if not slga_migrated then return false end local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do From 08729657c482610a152729c9e09bb178437a3c72 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 12:00:58 -0500 Subject: [PATCH 22/33] update test tables --- .../zigbee-lock/src/test/test_lock_tables.lua | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua index 82cc3164ee..f6b3282cde 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -379,4 +379,198 @@ test.register_coroutine_test( end ) +-- =========================================================================== +-- find_entry +-- =========================================================================== + +test.register_coroutine_test( + "find_entry: returns the matching entry when it exists", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry(mock_device, "users", 2) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + assert(result.userName == "Bob", + "Expected userName == 'Bob', got: " .. tostring(result.userName)) + end +) + +test.register_coroutine_test( + "find_entry: returns nil when no entry matches the value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry(mock_device, "users", 99) + assert(result == nil, + "Expected nil for missing entry, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry: returns nil for an unknown table name", + function() + local result = table_utils.find_entry(mock_device, "bad_table", 1) + assert(result == nil, + "Expected nil for unknown table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry: finds a credential entry by credentialIndex", + function() + local cred = { userIndex = 1, credentialIndex = 5, credentialType = "pin" } + seed_credentials({ cred }) + + local result = table_utils.find_entry(mock_device, "credentials", 5) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.credentialIndex == 5, + "Expected credentialIndex == 5, got: " .. tostring(result.credentialIndex)) + end +) + +-- =========================================================================== +-- find_entry_by +-- =========================================================================== + +test.register_coroutine_test( + "find_entry_by: returns the entry matching an arbitrary key", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "adminMember", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Bob") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns nil when no entry matches", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Nobody") + assert(result == nil, + "Expected nil for unmatched key/value, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns nil for an unknown table name", + function() + local result = table_utils.find_entry_by(mock_device, "bad_table", "userIndex", 1) + assert(result == nil, + "Expected nil for unknown table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns the first matching entry when multiple entries share the same value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userType", "guest") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 1, + "Expected first match userIndex == 1, got: " .. tostring(result.userIndex)) + end +) + +-- =========================================================================== +-- next_index +-- =========================================================================== + +test.register_coroutine_test( + "next_index: returns 1 when the table is empty", + function() + local result = table_utils.next_index(mock_device, "users") + assert(result == 1, + "Expected 1 for empty table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns the next sequential index after a contiguous range", + function() + local entries = {} + for i = 1, 3 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 4, + "Expected 4 after indices 1-3, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns the lowest gap when indices are non-contiguous", + function() + -- Insert entries at indices 1 and 3, leaving a gap at 2 + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e3 }) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 2, + "Expected 2 as the lowest gap, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns 1 for an unknown table name", + function() + local result = table_utils.next_index(mock_device, "bad_table") + assert(result == 1, + "Expected 1 for unknown table, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- get_max_entries +-- =========================================================================== + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "users") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "get_max_entries: returns nil for an unknown table name", + function() + local result = table_utils.get_max_entries(mock_device, "bad_table") + assert(result == nil, + "Expected nil for unknown table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 for the credentials table when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "credentials") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + test.run_registered_tests() From 2289db3dd95b30569aaeacb520baa78870ea0f2c Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 12:36:18 -0500 Subject: [PATCH 23/33] remove unnecessary tests --- .../zigbee-lock/src/test/test_lock_tables.lua | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua index f6b3282cde..f1d8c2e088 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -412,15 +412,6 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "find_entry: returns nil for an unknown table name", - function() - local result = table_utils.find_entry(mock_device, "bad_table", 1) - assert(result == nil, - "Expected nil for unknown table, got: " .. tostring(result)) - end -) - test.register_coroutine_test( "find_entry: finds a credential entry by credentialIndex", function() @@ -466,15 +457,6 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "find_entry_by: returns nil for an unknown table name", - function() - local result = table_utils.find_entry_by(mock_device, "bad_table", "userIndex", 1) - assert(result == nil, - "Expected nil for unknown table, got: " .. tostring(result)) - end -) - test.register_coroutine_test( "find_entry_by: returns the first matching entry when multiple entries share the same value", function() @@ -519,6 +501,7 @@ test.register_coroutine_test( end ) +-- this should not happen during normal operation. test.register_coroutine_test( "next_index: returns the lowest gap when indices are non-contiguous", function() @@ -533,15 +516,6 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "next_index: returns 1 for an unknown table name", - function() - local result = table_utils.next_index(mock_device, "bad_table") - assert(result == 1, - "Expected 1 for unknown table, got: " .. tostring(result)) - end -) - -- =========================================================================== -- get_max_entries -- =========================================================================== @@ -555,15 +529,6 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "get_max_entries: returns nil for an unknown table name", - function() - local result = table_utils.get_max_entries(mock_device, "bad_table") - assert(result == nil, - "Expected nil for unknown table, got: " .. tostring(result)) - end -) - test.register_coroutine_test( "get_max_entries: returns the default of 20 for the credentials table when the attribute is not set", function() From 3e96592c214ba672150cd442d3ffe6668883aa3e Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 12:44:34 -0500 Subject: [PATCH 24/33] consolidate similar battery test files, remove extra tests --- ...rter.lua => test_bad_battery_reporter.lua} | 38 ++ ...st_generic_fingerprint_profile_update.lua} | 0 .../zigbee-lock/src/test/test_zigbee_lock.lua | 537 ------------------ .../test_zigbee_yale-bad-battery-reporter.lua | 41 -- 4 files changed, 38 insertions(+), 578 deletions(-) rename drivers/SmartThings/zigbee-lock/src/test/{test_yale_fingerprint_bad_battery_reporter.lua => test_bad_battery_reporter.lua} (59%) rename drivers/SmartThings/zigbee-lock/src/test/{test_generic_lock_migration.lua => test_generic_fingerprint_profile_update.lua} (100%) delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua b/drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua similarity index 59% rename from drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua index fcae7eda03..61fb28ce8d 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua @@ -50,4 +50,42 @@ test.register_message_test( } ) +local mock_device_yrd = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints ={ + [1] = { + id = 1, + manufacturer ="Yale", + model ="YRD220/240 TSDB", + server_clusters = {} + } + } +}) + +local function test_init_yrd() + test.mock_device.add_test_device(mock_device_yrd) +end + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device_yrd.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_yrd, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_yrd:generate_test_message("main", capabilities.battery.battery(55)) + } + }, + { + test_init = test_init_yrd, + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua deleted file mode 100644 index 3aabe03a62..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ /dev/null @@ -1,537 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Mock out globals -local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local t_utils = require "integration_test.utils" - -local clusters = require "st.zigbee.zcl.clusters" -local DoorLock = clusters.DoorLock -local capabilities = require "st.capabilities" - -local DoorLockUserStatus = DoorLock.types.DrlkUserStatus -local DoorLockUserType = DoorLock.types.DrlkUserType -local constants = require "lock_utils.constants" - -local test_credential_index = 1 -local test_credentials = {} -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - } -) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init_new_capabilities() - test_credential_index = 1 - test_credentials = {} - test.mock_device.add_test_device(mock_device) -end - -local function init_migration() - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( - mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report( - mock_device, 8) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.maxCodeLength(8, { visibility = { displayed = false } }))) - test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported - :build_test_attr_report(mock_device, 4) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) - test.wait_for_events() - 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(8, { 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({}, { 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.users({}, { 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.lockCodes.migrated(true, { visibility = { displayed = false } }))) - test.wait_for_events() - assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") -end - -local function add_default_users() - local user_list = {} - for i = 1, 4 do - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "addUser", - args = { "Guest" .. i, "guest" } - }, - }) - -- add to the user list that is now expected - table.insert(user_list, { userIndex = i, userType = "guest", userName = "Guest" .. i }) - - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users( - user_list, - { visibility = { displayed = false } } - ) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "addUser", statusCode = "success", userIndex = i }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - end -end - --- Adds a credential via the addCredential capability command and handles the zigbee roundtrip. --- user_index should be the explicit slot index (1-based) to use for this credential. -local function add_credential(user_index, credential_data) - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "addCredential", - args = { user_index, "guest", "pin", credential_data } - }, - }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - test_credential_index, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - credential_data - ) - } - ) - test.wait_for_events() - -- Lock acknowledges the pin was set - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.SetPINCodeResponse.build_test_rx( - mock_device, - DoorLock.types.DrlkSetCodeStatus.SUCCESS - ) - } - ) - 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, - { visibility = { displayed = false } }) - ) - ) - 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 = false } } - ) - ) - ) - test.wait_for_events() - test_credential_index = test_credential_index + 1 -end - -test.set_test_init_function(test_init_new_capabilities) - -test.register_coroutine_test( - "Add User command received and commandResult is success until totalUsersSupported reached", - function() - -- make sure we have migrated and are using the new capabilities - init_migration() - -- create initial max users - add_default_users() - - -- 5th addUser call - totalUsersSupported is passsed and now commandResult should be resourceExhausted - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "addUser", - args = { "TestUser", "guest" } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "addUser", statusCode = "resourceExhausted" }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - end -) - -test.register_coroutine_test( - "Update User command reports a commandResult of success unless user index doesn't exist", - function() - -- make sure we have migrated and are using the new capabilities - init_migration() - -- create initial users - add_default_users() - - -- success - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "updateUser", - args = { 2, "ChangeUserName", "guest" } - }, - }) - - local users = { - { userIndex = 1, userName = "Guest1", userType = "guest" }, - { userIndex = 2, userName = "ChangeUserName", userType = "guest" }, - { userIndex = 3, userName = "Guest3", userType = "guest" }, - { userIndex = 4, userName = "Guest4", userType = "guest" }, - } - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users(users, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "success", userIndex = 2 }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - - -- failure - try updating non existent userIndex - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "updateUser", - args = { 6, "ChangeUserName", "guest" } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "updateUser", statusCode = "failure" }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - end -) - -test.register_coroutine_test( - "Delete User command reports a commandResult of success unless user index doesn't exist", - function() - -- make sure we have migrated and are using the new capabilities - init_migration() - -- create initial users - add_default_users() - - local users_after_delete = { - { userIndex = 1, userName = "Guest1", userType = "guest" }, - { userIndex = 2, userName = "Guest2", userType = "guest" }, - { userIndex = 4, userName = "Guest4", userType = "guest" }, - } - - -- success - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "deleteUser", - args = { 3 } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users(users_after_delete, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "deleteUser", statusCode = "success", userIndex = 3 }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - - -- failure - try deleting non existent userIndex - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockUsers.ID, - command = "deleteUser", - args = { 3 } - }, - }) - -- delete_entry always emits the (unchanged) users table even on failure - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.users(users_after_delete, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockUsers.commandResult( - { commandName = "deleteUser", statusCode = "failure" }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - end -) - - -test.register_coroutine_test( - "addCredential command received and commandResult is success", - function() - init_migration() - add_credential(0, "abc123") - end -) - -test.register_coroutine_test( - "updateCredential command received and commandResult is success", - function() - init_migration() - add_credential(0, "abc123") - - -- try to update the wrong credentialIndex (4) first and expect a failure - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "updateCredential", - args = { 4, 4, "pin", "abc123" } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.commandResult( - { commandName = "updateCredential", statusCode = "failure" }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - test.wait_for_events() - - -- try to update the right credential - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "updateCredential", - args = { 1, 1, "pin", "changedPin123" } - }, - }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - DoorLock.server.commands.SetPINCode(mock_device, - 1, - DoorLockUserStatus.OCCUPIED_ENABLED, - DoorLockUserType.UNRESTRICTED, - "changedPin123" - ) - } - ) - test.wait_for_events() - -- Lock acknowledges the update - test.socket.zigbee:__queue_receive( - { - mock_device.id, - DoorLock.client.commands.SetPINCodeResponse.build_test_rx( - mock_device, - DoorLock.types.DrlkSetCodeStatus.SUCCESS - ) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials( - { - { userIndex = 1, credentialIndex = 1, credentialType = "pin" } - }, - { visibility = { displayed = false } } - ) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.commandResult( - { commandName = "updateCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - test.wait_for_events() - end -) - -test.register_coroutine_test( - "deleteCredential command received and commandResult is success", - function() - init_migration() - add_credential(1, "abc123") - add_credential(2, "test123") - add_credential(3, "321test") - - -- try to delete credential with wrong index and expect a failure - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "deleteCredential", - args = { 4, "pin" } - }, - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.commandResult( - { commandName = "deleteCredential", statusCode = "failure" }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - test.wait_for_events() - - -- try to delete credential with correct index - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "deleteCredential", - args = { 1, "pin" } - }, - }) - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) - }) - test.wait_for_events() - -- Lock acknowledges the clear - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ClearPINCodeResponse.build_test_rx( - mock_device, - DoorLock.types.DrlkPassFailStatus.PASS - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials( - { - { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, - { userIndex = 3, credentialIndex = 3, credentialType = "pin" } - }, - { visibility = { displayed = false } } - ) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.commandResult( - { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - test.wait_for_events() - end -) - -test.register_coroutine_test( - "deleteAllCredentials command received and commandResult is success", - function() - init_migration() - add_credential(1, "abc123") - add_credential(2, "test123") - add_credential(3, "321test") - - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = capabilities.lockCredentials.ID, - command = "deleteAllCredentials", - args = {} - }, - }) - - test.socket.zigbee:__expect_send({ - mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) - }) - test.wait_for_events() - - -- Lock acknowledges that all pins were cleared - test.socket.zigbee:__queue_receive({ - mock_device.id, - DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx( - mock_device, - DoorLock.types.DrlkPassFailStatus.PASS - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.credentials( - {}, - { visibility = { displayed = false } } - ) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCredentials.commandResult( - { commandName = "deleteAllCredentials", statusCode = "success" }, - { state_change = true, visibility = { displayed = false } } - ) - ) - ) - test.wait_for_events() - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua deleted file mode 100644 index 0b083f8050..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua +++ /dev/null @@ -1,41 +0,0 @@ --- Copyright 2022 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Mock out globals -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local PowerConfiguration = clusters.PowerConfiguration - -local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), zigbee_endpoints ={ [1] = {id = 1, manufacturer ="Yale", model ="YRD220/240 TSDB", server_clusters = {}} } }) - -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device)end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Battery percentage report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, - 55) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) - } - }, - { - min_api_version = 17 - } -) - -test.run_registered_tests() From 3f8ef87de4daa58b964719b83c0c76dcc87908af Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 13:59:14 -0500 Subject: [PATCH 25/33] add more tests, rename some test files --- .../src/test/test_init_lifecycle_handlers.lua | 436 ++++++++++++++++++ ....lua => test_lock_code_slga_migration.lua} | 0 .../src/test/test_lock_programming_events.lua | 234 +++++++++- .../src/test/test_lock_users_commands.lua | 211 +++++++++ ... => test_zigbee_yale_fingerprint-lock.lua} | 0 5 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua rename drivers/SmartThings/zigbee-lock/src/test/{test_zigbee_lock_code_slga_migration.lua => test_lock_code_slga_migration.lua} (100%) rename drivers/SmartThings/zigbee-lock/src/test/{test_zigbee_yale-fingerprint-lock.lua => test_zigbee_yale_fingerprint-lock.lua} (100%) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua new file mode 100644 index 0000000000..c439e303fc --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua @@ -0,0 +1,436 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the four lifecycle handlers defined in init.lua: +-- added (device_added), doConfigure (do_configure), +-- infoChanged (info_changed), init (LockLifecycle.init) + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration +local Alarms = clusters.Alarms +local constants = require "lock_utils.constants" + +-- ── Shared mock devices ──────────────────────────────────────────────────── +-- base-lock profile: lock + lockCodes + lockCredentials + lockUsers + battery +local mock_device_base = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +-- Same profile but provisioning_state = "TYPED" (freshly fingerprinted) +local mock_device_typed = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + _provisioning_state = "TYPED", +}) + +-- lock-battery profile: lock + battery only (no lockCodes / lockCredentials) +local mock_device_battery = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +-- Helper: make a test_init function that suppresses startup messages and +-- registers the given device. +local function make_test_init(device) + return function() + test.disable_startup_messages() + test.mock_device.add_test_device(device) + end +end + +-- Helper: expect the five zigbee messages produced by sync_device_state on a +-- freshly-started device (no cached capability state, CODE_INDEX starts at 1). +local function expect_sync_device_state(device) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.SendPINOverTheAir:write(device, true) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MaxPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MinPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 1) }) +end + +-- Helper: expect the messages produced by the legacy reload_all_codes path after +-- the 2-second doConfigure timer fires on a non-SLGA_MIGRATED device. +-- Unlike sync_device_state, reload_all_codes emits scanCodes("Scanning") and +-- starts iterating from code slot 0 (CHECKING_CODE = 0). +local function expect_reload_all_codes_messages(device) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.SendPINOverTheAir:write(device, true) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MaxPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MinPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(device) }) + test.socket.capability:__expect_send( + device:generate_test_message("main", + capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) + ) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 0) }) +end + +-- Helper: expect the six zigbee messages sent by do_configure for any device. +local function expect_do_configure_zigbee(device) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1), + }) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, DoorLock.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0), + }) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, Alarms.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0), + }) +end + +-- ============================================================================ +-- added (device_added) +-- ============================================================================ + +test.register_coroutine_test( + "added: TYPED device with lockCodes emits migrated event, persists SLGA_MIGRATED, and injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + + -- Migrated event is emitted before the injected refresh + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler inline + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.LockState:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + Alarms.attributes.AlarmCount:read(mock_device_typed), + }) + test.wait_for_events() + + assert( + mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be true after added fires for a TYPED device" + ) + end, + { test_init = make_test_init(mock_device_typed) } +) + +test.register_coroutine_test( + "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "added" }) + + -- No migrated capability event expected + test.socket.zigbee:__expect_send({ + mock_device_base.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_base), + }) + test.socket.zigbee:__expect_send({ + mock_device_base.id, + DoorLock.attributes.LockState:read(mock_device_base), + }) + test.socket.zigbee:__expect_send({ + mock_device_base.id, + Alarms.attributes.AlarmCount:read(mock_device_base), + }) + test.wait_for_events() + + assert( + mock_device_base:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) ~= true, + "SLGA_MIGRATED must NOT be set for a non-TYPED device" + ) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "added: device without lockCodes does NOT emit migrated event but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + + -- No migrated capability event expected. + -- For non-SLGA_MIGRATED devices the legacy-handlers refresh fires, which reads + -- NumberOfPINUsersSupported when the device has no lockCodes and no cached code support. + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.LockState:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + Alarms.attributes.AlarmCount:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_battery), + }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- doConfigure (do_configure) +-- ============================================================================ + +test.register_coroutine_test( + "doConfigure: SLGA_MIGRATED device with lockCredentials sends bind/configure then calls sync_device_state after 2-second delay", + function() + -- Pre-seed SLGA_MIGRATED so the main driver's do_configure fires (not legacy-handlers). + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_base) + mock_device_base:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + -- Timer fires: sync_device_state is called (GetPINCode slot 1) + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(mock_device_base) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "doConfigure: SLGA_MIGRATED device without lockCredentials sends bind/configure but does NOT create sync timer", + function() + -- Pre-seed SLGA_MIGRATED so the main driver's do_configure fires. + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_battery) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + -- lock-battery has no lockCredentials, so no 2-second timer is created. + end, + { test_init = make_test_init(mock_device_battery) } +) + +test.register_coroutine_test( + "doConfigure: non-SLGA_MIGRATED device triggers legacy reloadAllCodes (with scanCodes emit) after 2-second delay", + function() + -- mock_device_typed has no SLGA_MIGRATED → legacy-handlers' do_configure fires, + -- which injects a reloadAllCodes capability command after a 2-second delay. + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_typed) + mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + -- Timer fires: legacy reload_all_codes runs, iterating from code slot 0. + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_reload_all_codes_messages(mock_device_typed) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_typed) } +) + +-- ============================================================================ +-- infoChanged (info_changed) +-- Each test uses a per-test fresh device (upvalue pattern) to avoid +-- raw_st_data contamination from generate_info_changed across tests. +-- init is triggered first so the driver loads the device into device_cache, +-- allowing infoChanged to correctly identify the old profile. +-- ============================================================================ + +do + local dev + test.register_coroutine_test( + "infoChanged: switching from non-lockCodes to lockCodes+lockCredentials profile triggers full SLGA migration and two syncs", + function() + -- Warm up device_cache with the original (lock-battery) profile via init. + -- For a non-SLGA_MIGRATED device, legacy-handlers' init fires (no zigbee sends). + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- Switch to base-lock (lockCodes + lockCredentials) + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.device_lifecycle:__queue_receive( + dev:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") }) + ) + -- Migration events + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + -- Immediate sync_device_state + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(dev) + test.wait_for_events() + + assert( + dev:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be set after infoChanged profile switch" + ) + + -- Delayed sync_device_state (15 s) + test.mock_time.advance_time(15) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(dev) + test.wait_for_events() + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +do + local dev + test.register_coroutine_test( + "infoChanged: no profile change does nothing", + function() + -- Warm up device_cache + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- infoChanged with no profile change + test.socket.device_lifecycle:__queue_receive(dev:generate_info_changed({})) + test.wait_for_events() + -- No capability events, no zigbee sends expected + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +do + local dev + test.register_coroutine_test( + "infoChanged: profile switched away from lockCodes (to non-lockCodes profile) does nothing", + function() + -- Warm up device_cache with base-lock + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- Switch to lock-battery (no lockCodes) + test.socket.device_lifecycle:__queue_receive( + dev:generate_info_changed({ profile = t_utils.get_profile_definition("lock-battery.yml") }) + ) + test.wait_for_events() + -- profile_switched is true, but new profile does not have lockCodes, + -- so no migration events or sync sends are expected + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +-- ============================================================================ +-- init (LockLifecycle.init) +-- ============================================================================ + +test.register_coroutine_test( + "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials then syncs after 15-second delay", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(15, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + -- Delayed sync fires after 15 s + test.mock_time.advance_time(15) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(mock_device_base) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: device with lockCodes but SLGA_MIGRATED not set does nothing", + function() + -- SLGA_MIGRATED is not set; lockCodes is supported; elseif branch does not apply + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.wait_for_events() + -- No capability events and no zigbee sends expected + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: SLGA_MIGRATED device without lockCodes sends NumberOfPINUsersSupported read to detect re-profiling", + function() + -- Pre-seed SLGA_MIGRATED=true so legacy-handlers is bypassed and the main driver's + -- init fires. lock-battery has no lockCodes, so the elseif branch executes and + -- reads NumberOfPINUsersSupported to detect whether the device should be re-profiled. + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_battery), + }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua index 147e5c59ae..74fe92de6d 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -10,6 +10,13 @@ -- • PIN_CODE_ADDED received while BUSY (notification arrives after our addCredential command) -- • PIN_CODE_CHANGED received while BUSY (notification arrives after our updateCredential command) -- • PIN_CODE_DELETED received while BUSY (notification arrives after our deleteCredential command) +-- • PIN_CODE_ADDED received after BUSY ends (late notification from our SetPINCode; credential +-- not double-added) +-- • PIN_CODE_ADDED received after BUSY ends (both entries already exist; complete no-op) +-- • PIN_CODE_DELETED received after BUSY ends (late notification from our ClearPINCode; credential +-- already deleted) +-- • PIN_CODE_CHANGED received after BUSY ends (late notification from our SetPINCode update; +-- not handled, no effect) local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -20,9 +27,11 @@ local DoorLock = clusters.DoorLock local capabilities = require "st.capabilities" local constants = require "lock_utils.constants" -local DoorLockUserStatus = DoorLock.types.DrlkUserStatus -local DoorLockUserType = DoorLock.types.DrlkUserType +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType local ProgrammingEventCode = DoorLock.types.ProgramEventCode +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), @@ -184,4 +193,225 @@ test.register_coroutine_test( end ) +-- ───────────────────────────────────────────────────────────────────────────── +-- POST-BUSY CASES (busy state was already cleared by the ZigBee response +-- handler before the ProgrammingEventNotification arrives; the notification was +-- sent by the lock in response to our own SetPINCode / ClearPINCode command) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "Late PIN_CODE_ADDED after addCredential: credential not double-added; user newly created by notification", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Complete an addCredential flow so the credentials table already has the entry. + -- Note: set_pin_code_response only adds a credential entry, NOT a user entry. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- Credential added; commandResult emitted; busy state cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late ProgrammingEventNotification PIN_CODE_ADDED for the same slot arrives after busy ends. + -- The credential is already in the table → add_entry returns OCCUPIED → no second credentials event. + -- The user entry does not exist yet → add_entry succeeds → users event is emitted. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_ADDED when both user and credential already exist: both add_entry calls are no-ops", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Populate both tables by processing a not-busy PIN_CODE_ADDED (simulates the completed command state). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late notification for the same slot; both add_entry calls return OCCUPIED → no events. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_DELETED after deleteCredential: credential already removed; user cleaned up by notification", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Populate both tables (user at index 1, credential at index 1). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Run a standalone deleteCredential flow; this removes the credential but leaves the user. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + -- Credential entry removed; user entry stays. Busy state cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late PIN_CODE_DELETED for the same slot arrives after busy ends. + -- The user entry is still present → delete_entry removes it and emits the empty users table. + -- The credential is already gone → delete_entry finds nothing but still emits the empty credentials table. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_CHANGED after updateCredential: not handled by notification handler, no events emitted", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Seed user and credential so updateCredential has an existing entry to modify. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Complete an updateCredential flow so the credential entry is updated and busy state is cleared. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "5678" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- update_entry emits the credentials table (credentialName preserved from original seed). + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late PIN_CODE_CHANGED notification arrives after busy ends. + -- The notification handler does not handle PIN_CODE_CHANGED → no events emitted. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + test.wait_for_events() + end +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua index 070947291e..278bd6b453 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua @@ -435,4 +435,215 @@ test.register_coroutine_test( end ) +-- ============================================================================ +-- State-consistency: add → delete → re-add (indices 1, 2, 3 lifecycle) +-- ============================================================================ +-- +-- These tests verify that the users and credentials tables stay in sync +-- through a full lifecycle: populate three slots, remove the middle one, +-- then re-add a user and credential into the freed slot. The goal is to +-- confirm there is no stale index state that would cause duplicate entries, +-- wrong slot assignment, or mismatched user↔credential links. + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +test.register_coroutine_test( + "State-consistency: add users 1-3, deleteUser 2 (no credential), re-add user reclaims index 2", + function() + -- Populate three user slots directly (no credentials, so deleteUser will take + -- the no-ZigBee path and delete the user entry locally). + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + -- Delete user at index 2; no credential is linked so this is a pure local delete. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3}, so it assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "State-consistency: add users+credentials 1-3, deleteUser 2 (with credential), re-add user+credential reclaims index 2 cleanly", + function() + -- Populate three user and credential slots. + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }) + + -- Delete user at index 2. The handler finds a linked credential and injects + -- deleteCredential, which sends ClearPINCode to the lock. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + -- Lock acknowledges the deletion. + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + -- clear_pin_code_response (LOCK_USERS.DELETE path): deletes user first, then credential. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.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 = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3} and assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a credential for the new user at slot 2. The old credential at + -- credentialIndex 2 was cleanly removed, so add_entry must succeed without + -- returning OCCUPIED or any stale-state error. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "9999" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "9999"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- Credential is freshly added at index 2 alongside the retained entries at 1 and 3. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 2, credentialIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua From 8f92e864f5a7641adbbc4e7313c237f7d056ff95 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 15:27:50 -0500 Subject: [PATCH 26/33] keep persisted store of tables at all times --- drivers/SmartThings/zigbee-lock/src/init.lua | 5 + .../zigbee-lock/src/lock_utils/tables.lua | 108 +++++++--- .../zigbee-lock/src/test/test_lock_tables.lua | 196 ++++++++++++++++++ 3 files changed, 275 insertions(+), 34 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 65dcf48a60..6e9f3acfcf 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -9,6 +9,7 @@ local capabilities = require "st.capabilities" local consts = require "lock_utils.constants" local lock_utils = require "lock_utils.utils" +local table_utils = require "lock_utils.tables" local zigbee_handlers = require "lock_handlers.zigbee_responses" local capability_handlers = require "lock_handlers.capabilities" @@ -31,6 +32,10 @@ function LockLifecycle.device_added(driver, device) end function LockLifecycle.init(driver, device) + -- Restore users/credentials capability state from the persistent store in case + -- the capability state cache was wiped since the last driver run. + table_utils.restore_from_persistent_store(device) + local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) if lock_pins_supported_by_profile and device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) == true then -- ensure lockCodes capability state is reflected correctly for already migrated devices diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua index 6602848f09..c7fcbf2b80 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -1,35 +1,38 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" -local st_utils = require "st.utils" -local consts = require "lock_utils.constants" +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT local table_utils = {} -- DEFS describes how each capability-backed state table is structured: -- --- capability SmartThings capability (used for device support checks) --- attribute Capability attribute function (used to emit state) --- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table --- match_key Key used to identify entries in flat tables --- required_keys Keys that must be non-nil when adding an entry +-- capability SmartThings capability (used for device support checks) +-- attribute Capability attribute function (used to emit state) +-- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table +-- match_key Key used to identify entries in flat tables +-- required_keys Keys that must be non-nil when adding an entry +-- persistent_field device:set_field key used to back up the table across restarts -- local DEFS = { users = { - capability = capabilities.lockUsers, - attribute = capabilities.lockUsers.users, - max_entries = capabilities.lockUsers.totalUsersSupported, - match_key = "userIndex", - required_keys = {"userIndex", "userType"}, + capability = capabilities.lockUsers, + attribute = capabilities.lockUsers.users, + max_entries = capabilities.lockUsers.totalUsersSupported, + match_key = "userIndex", + required_keys = {"userIndex", "userType"}, + persistent_field = "persistedUsers", }, credentials = { - capability = capabilities.lockCredentials, - attribute = capabilities.lockCredentials.credentials, - max_entries = capabilities.lockCredentials.pinUsersSupported, - match_key = "credentialIndex", - required_keys = {"userIndex", "credentialIndex", "credentialType"}, + capability = capabilities.lockCredentials, + attribute = capabilities.lockCredentials.credentials, + max_entries = capabilities.lockCredentials.pinUsersSupported, + match_key = "credentialIndex", + required_keys = {"userIndex", "credentialIndex", "credentialType"}, + persistent_field = "persistedCredentials", } } @@ -53,9 +56,19 @@ local function validate_entry(device, entry, required_keys) return true end +-- Write the current table contents to the device's persistent field store so that +-- the state survives driver restarts and can be restored if the capability state +-- cache is wiped. +local function persist_table(device, def, data) + device:set_field(def.persistent_field, data, { persist = true }) +end + -- Read the current state for a table and return a deep-copied array. -- Accepts either a string table name ("users", "credentials") or a DEFS entry directly. -- Returns nil (with a warning) if the capability is unsupported by the device. +-- When the capability state cache has been wiped (get_latest_state returns nil), +-- falls back to the persistent field store so that callers always receive the +-- last-known state rather than an empty table. --- @return table[] | nil function table_utils.get_state(device, name_or_def) local def = type(name_or_def) == "string" and resolve_table_def(device, name_or_def) or name_or_def @@ -66,7 +79,13 @@ function table_utils.get_state(device, name_or_def) )) return end - return st_utils.deep_copy(device:get_latest_state("main", def.capability.ID, def.attribute.NAME, {})) + local state = device:get_latest_state("main", def.capability.ID, def.attribute.NAME) + if state ~= nil then + return st_utils.deep_copy(state) + end + -- Capability state cache is absent (e.g. after a hub reboot); fall back to the + -- persistent store so that callers see the last-known table contents. + return st_utils.deep_copy(device:get_field(def.persistent_field) or {}) end -- Find an entry in a named table where the match_key equals value. @@ -120,16 +139,16 @@ end -- entries that exceed the limit are not added. function table_utils.add_entry(device, table_name, entry) local def = resolve_table_def(device, table_name) - if not def then return consts.COMMAND_RESULT.FAILURE end - if not validate_entry(device, entry, def.required_keys) then return consts.COMMAND_RESULT.FAILURE end + if not def then return COMMAND_RESULT.FAILURE end + if not validate_entry(device, entry, def.required_keys) then return COMMAND_RESULT.FAILURE end local t = table_utils.get_state(device, def) - if not t then return consts.COMMAND_RESULT.FAILURE end + if not t then return COMMAND_RESULT.FAILURE end if #t >= table_utils.get_max_entries(device, table_name) then device.log.warn(string.format( "table_helpers: cannot add entry to %q, max entries reached", table_name )) - return consts.COMMAND_RESULT.RESOURCE_EXHAUSTED + return COMMAND_RESULT.RESOURCE_EXHAUSTED end -- Object entry: skip if an entry with the same match_key value already exists @@ -140,7 +159,7 @@ function table_utils.add_entry(device, table_name, entry) "table_helpers: entry with %s == %s already exists in %q, skipping", def.match_key, tostring(entry[def.match_key]), table_name )) - return consts.COMMAND_RESULT.OCCUPIED + return COMMAND_RESULT.OCCUPIED end end end @@ -148,7 +167,8 @@ function table_utils.add_entry(device, table_name, entry) table.insert(t, st_utils.deep_copy(entry)) device:emit_event(def.attribute(t, {visibility = {displayed = false}})) - return consts.COMMAND_RESULT.SUCCESS + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS end @@ -156,9 +176,9 @@ end --- The entry to update is identified by the match_key parameter in DEFS. function table_utils.update_entry(device, table_name, match_value, updates) local def = resolve_table_def(device, table_name) - if not def then return consts.COMMAND_RESULT.FAILURE end + if not def then return COMMAND_RESULT.FAILURE end local t = table_utils.get_state(device, def) - if not t then return consts.COMMAND_RESULT.FAILURE end + if not t then return COMMAND_RESULT.FAILURE end for _, entry in ipairs(t) do if entry[def.match_key] == match_value then @@ -166,7 +186,8 @@ function table_utils.update_entry(device, table_name, match_value, updates) entry[k] = v end device:emit_event(def.attribute(t, {visibility = {displayed = false}})) - return consts.COMMAND_RESULT.SUCCESS + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS end end @@ -174,7 +195,7 @@ function table_utils.update_entry(device, table_name, match_value, updates) "table_helpers: no entry found in %q with %s == %s", table_name, def.match_key, tostring(match_value) )) - return consts.COMMAND_RESULT.FAILURE + return COMMAND_RESULT.FAILURE end @@ -183,9 +204,9 @@ end -- Returns the deleted entry, or FAILURE if nothing matched. function table_utils.delete_entry(device, table_name, matcher) local def = resolve_table_def(device, table_name) - if not def then return consts.COMMAND_RESULT.FAILURE end + if not def then return COMMAND_RESULT.FAILURE end local t = table_utils.get_state(device, def) - if not t then return consts.COMMAND_RESULT.FAILURE end + if not t then return COMMAND_RESULT.FAILURE end local predicate = function(entry) return entry[def.match_key] == matcher end @@ -197,15 +218,34 @@ function table_utils.delete_entry(device, table_name, matcher) end end device:emit_event(def.attribute(t, {visibility = {displayed = false}})) - return removed or consts.COMMAND_RESULT.FAILURE + persist_table(device, def, t) + return removed or COMMAND_RESULT.FAILURE end -- Delete all entries from a table. function table_utils.delete_all_entries(device, table_name) local def = resolve_table_def(device, table_name) - if not def then return consts.COMMAND_RESULT.FAILURE end + if not def then return COMMAND_RESULT.FAILURE end device:emit_event(def.attribute({}, {visibility = {displayed = false}})) - return consts.COMMAND_RESULT.SUCCESS + persist_table(device, def, {}) + return COMMAND_RESULT.SUCCESS +end + +-- Restore capability state from the persistent field store. +-- Called during init to re-emit table events if the capability state cache +-- has been wiped (e.g. after a hub reboot). Only emits for tables that have +-- persisted data, are in a nil state, and whose capability is supported by the device. +function table_utils.restore_from_persistent_store(device) + for _, internal in ipairs(DEFS) do + if device:supports_capability(internal.capability, "main") and + device:get_latest_state("main", internal.capability.ID, internal.attribute.NAME) == nil + then + local persisted = st_utils.deep_copy(device:get_field(internal.persistent_field)) + if persisted and #persisted > 0 then + device:emit_event(internal.attribute(persisted, {visibility = {displayed = false}})) + end + end + end end return table_utils diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua index f1d8c2e088..7cdf54427c 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -9,6 +9,7 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local capabilities = require "st.capabilities" +local st_utils = require "st.utils" local table_utils = require "lock_utils.tables" local constants = require "lock_utils.constants" @@ -538,4 +539,199 @@ test.register_coroutine_test( end ) +-- =========================================================================== +-- Persistence — mutations write to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: add_entry writes the new users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "users", entry) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 1 and persisted[1].userName == "Alice", + "Persisted user data does not match the added entry") + end +) + +test.register_coroutine_test( + "persist: update_entry writes the updated users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userType = "adminMember", userName = "Alice" } }, + { visibility = { displayed = false } })) + ) + table_utils.update_entry(mock_device, "users", 1, { userType = "adminMember" }) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after update") + assert(persisted[1].userType == "adminMember", + "Persisted user type was not updated, got: " .. tostring(persisted[1].userType)) + end +) + +test.register_coroutine_test( + "persist: delete_entry removes the entry from the persistent store immediately", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ e2 }, { visibility = { displayed = false } })) + ) + table_utils.delete_entry(mock_device, "users", 1) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after delete, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 2, + "Expected remaining user to have userIndex == 2, got: " .. tostring(persisted[1].userIndex)) + end +) + +test.register_coroutine_test( + "persist: delete_all_entries writes an empty table to the persistent store", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + table_utils.delete_all_entries(mock_device, "users") + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 0, + "Expected empty persistent store after delete_all, got: " .. tostring(persisted and #persisted)) + end +) + +test.register_coroutine_test( + "persist: add_entry writes the new credentials table to the persistent store immediately", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "credentials", cred) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedCredentials")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted credential, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].credentialIndex == 1, + "Persisted credential index does not match") + end +) + +-- =========================================================================== +-- Persistence — get_state falls back to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: get_state returns data from the persistent store when capability state cache is absent", + function() + -- The device was loaded with a pre-seeded persistent field (set in test_init + -- below), but no capability event has been emitted for users, so get_latest_state + -- returns nil. get_state must fall back to the persistent store. + local state = table_utils.get_state(mock_device, "users") + assert(type(state) == "table" and #state == 1, + "Expected 1 user from persistent-store fallback, got: " .. tostring(state and #state)) + assert(state[1].userIndex == 1 and state[1].userName == "Alice", + "Fallback data does not match pre-seeded persistent entry") + end, + { + test_init = function() + -- Pre-seed persistent store BEFORE add_test_device so that wrapped_init + -- copies the field into the device's persistent_store on startup. + mock_device:set_field( + constants.PERSISTENT_STORE.USERS, + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +-- =========================================================================== +-- Persistence — restore_from_persistent_store re-emits stored tables +-- =========================================================================== + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits users capability event for stored data", + function() + local stored = { { userIndex = 1, userType = "guest", userName = "Alice" } } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(stored, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end, + { + test_init = function() + mock_device:set_field( + constants.PERSISTENT_STORE.USERS, + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits credentials capability event for stored data", + function() + local stored = { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(stored, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end, + { + test_init = function() + mock_device:set_field( + constants.PERSISTENT_STORE.CREDENTIALS, + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store is a no-op when the persistent store is empty", + function() + -- No capability events expected when there is nothing in the persistent store. + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end +) + test.run_registered_tests() From edf9783f4c09193d0cb6d57473dab5ea88849754 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 16:42:23 -0500 Subject: [PATCH 27/33] update migration logic to persist data and keep credential/user indices the same --- drivers/SmartThings/zigbee-lock/src/init.lua | 4 +- .../zigbee-lock/src/legacy-handlers/init.lua | 77 +++++++++++-------- .../test/test_lock_code_slga_migration.lua | 7 +- .../test_zigbee_yale_fingerprint-lock.lua | 6 +- 4 files changed, 54 insertions(+), 40 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 6e9f3acfcf..2df9403306 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -41,7 +41,7 @@ function LockLifecycle.init(driver, device) -- ensure lockCodes capability state is reflected correctly for already migrated devices device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) -- then, set device state - device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) device.thread:call_with_delay(15, function(d) lock_utils.sync_device_state(device) end) @@ -74,7 +74,7 @@ function LockLifecycle.info_changed(driver, device, event, args) device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) if device:supports_capability(capabilities.lockCredentials) then - device:emit_event(capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) end lock_utils.sync_device_state(device) device.thread:call_with_delay(15, function(d) diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua index 7af05f429a..a86f6d8fd4 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -380,44 +380,57 @@ local function unlock(driver, device, command) end local migrate = function(driver, device, command) - local lock_users = {} - local lock_credentials = {} - local lock_codes = lock_utils.get_lock_codes(device) - local ordered_codes = {} + local post_migration_consts = require "lock_utils.constants" + local LockUsers = capabilities.lockUsers + local LockCredentials = capabilities.lockCredentials - for code in pairs(lock_codes) do - table.insert(ordered_codes, code) - end + -- set supported credentials + device:emit_event(LockCredentials.supportedCredentials({ post_migration_consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) - table.sort(ordered_codes) - for index, code_slot in ipairs(ordered_codes) do - table.insert(lock_users, { userIndex = index, userType = "guest", userName = lock_codes[code_slot] }) - table.insert(lock_credentials, { userIndex = index, credentialIndex = tonumber(code_slot), credentialType = "pin" }) + -- migrate max/min credential length + local cached_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local cached_min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME, 4) + local cached_max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME, 8) + if cached_code_length then + cached_max_code_len = cached_code_length + cached_min_code_len = cached_code_length 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, 8) - local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME, 0) - if (code_length ~= nil) then - max_code_len = code_length - min_code_len = code_length + device:emit_event(LockCredentials.minPinCodeLen(cached_min_code_len, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.maxPinCodeLen(cached_max_code_len, { visibility = { displayed = false } })) + + -- migrate total codes supported + local cached_max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME, 0) + device:emit_event(LockCredentials.pinUsersSupported(cached_max_codes, { visibility = { displayed = false } })) + device:emit_event(LockUsers.totalUsersSupported(cached_max_codes, { visibility = { displayed = false } })) + + -- migrate stored lock codes slots and user names + local users, credentials = {}, {} + local cached_lock_codes = lock_utils.get_lock_codes(device) + local ordered_lock_codes = {} + for code_slot_str, code_name in pairs(cached_lock_codes) do + local code_slot_num = tonumber(code_slot_str) + if code_slot_num then + table.insert(ordered_lock_codes, { slot = code_slot_num, name = code_name }) + end + end + table.sort(ordered_lock_codes, function(a, b) return a.slot < b.slot end) + for _, code_info in ipairs(ordered_lock_codes) do + local user_id = code_info.slot + local code_name = code_info.name + if user_id then + table.insert(users, { userIndex = user_id, userType = "guest", userName = code_name }) + table.insert(credentials, { userIndex = user_id, credentialIndex = user_id, credentialType = post_migration_consts.CRED_TYPE_PIN }) + end end + -- manually ensure user/cred state is persisted, then emit events to populate the new capabilities with the migrated data. + device:set_field("persistedUsers", users, { persist = true }) + device:set_field("persistedCredentials", credentials, { persist = true }) + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.credentials(credentials, { visibility = { displayed = false } })) - local post_migration_constants = require "lock_utils.constants" - local LockUsers = capabilities.lockUsers - local LockCredentials = capabilities.lockCredentials - device:emit_event(LockCredentials.minPinCodeLen(min_code_len, { visibility = { displayed = false } })) - device:emit_event(LockCredentials.maxPinCodeLen(max_code_len, { visibility = { displayed = false } })) - device:emit_event(LockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) - device:emit_event(LockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) - device:emit_event(LockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) - device:emit_event(LockUsers.users(lock_users, { visibility = { displayed = false } })) - device:emit_event(LockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + -- set and persist the migration complete flag and emit migrated event. Legacy subdriver will not be used again after this. device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) - device:set_field(post_migration_constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore + device:set_field(post_migration_consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) end local legacy_capabilities_driver = { diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua index 50cb548010..c14c2f7ae7 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua @@ -54,13 +54,14 @@ test.register_coroutine_test( test.wait_for_events() -- Validate `migrate` command functionality. test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(5, { 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.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.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=5, userName="Steven", userType="guest"}}, { 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=5}}, { 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(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua index 7e1f9ed9c6..4cf0b275e1 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua @@ -19,13 +19,13 @@ local function test_init() local function test_init_new_capabilities() test.mock_device.add_test_device(mock_device) test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(8, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(0, { 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.users({}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(0, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) end From 2dd21e1a6b086f4ac4c75af219e9323c5f4155a7 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 17:11:48 -0500 Subject: [PATCH 28/33] add more zigbee response tests --- .../src/test/test_zigbee_responses.lua | 554 ++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua new file mode 100644 index 0000000000..5857cd184c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -0,0 +1,554 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Additional tests for lock_handlers/zigbee_responses.lua to improve coverage. +-- Covers: +-- • get_pin_code_response: sync codes from lock flow +-- • programming_event_notification: Yale/ASSA ABLOY user_id >= 256 shift +-- • operating_event_notification: keypad source with lockUsers capability +-- • operating_event_notification: schedule events with non-keypad source (auto method) +-- • alarm: alarm codes 0 and 1 +-- • lock_state: attribute handler with delay logic +-- • max_pin_code_length / min_pin_code_length: attribute handlers +-- • number_of_pin_users_supported: attribute handler with profile migration + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local DoorLock = clusters.DoorLock +local Alarms = clusters.Alarms +local constants = require "lock_utils.constants" +local table_utils = require "lock_utils.tables" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local OperationEventCode = DoorLock.types.OperationEventCode +local OperationEventSource = DoorLock.types.DrlkOperationEventSource +local ProgrammingEventCode = DoorLock.types.ProgramEventCode +local LockState = DoorLock.attributes.LockState + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +local mock_device_yale = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Yale", + model = "YRD256", + server_clusters = { DoorLock.ID }, + }, + }, +}) + +local mock_device_no_lock_codes = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-without-codes.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_yale) + test.mock_device.add_test_device(mock_device_no_lock_codes) +end + +test.set_test_init_function(test_init) + +-- Helper: build OperatingEventNotification +local function build_operating_event(device, event_code, event_source, user_id) + return { + device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + device, + event_source, + event_code, + user_id or 0, + "1234", + 0x0000, + "data" + ), + } +end + +-- Helper: build ProgrammingEventNotification +local function build_programming_event(device, event_code, user_id) + return { + device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + device, + 0x00, + event_code, + user_id, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ), + } +end + +-- ============================================================================ +-- get_pin_code_response: sync codes from lock +-- ============================================================================ + +test.register_coroutine_test( + "get_pin_code_response: syncs codes from lock and requests next code", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Set up the sync state + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- Receive GetPINCodeResponse for user_id 1 + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, -- user_id + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" -- PIN code + ), + }) + + -- Should add user entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + -- Should add credential entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + )) + ) + -- Should request next code + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 2), + }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "get_pin_code_response: completes sync when max entries reached", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Set up the sync state at the last code index + -- get_max_entries defaults to 20 when attribute is missing, so use user_id 20 + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + -- Receive GetPINCodeResponse for user_id 20 (at default max) + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 20, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ), + }) + + -- Should add entries + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 20, userName = "User 20", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 20, credentialIndex = 20, credentialType = "pin", credentialName = "User 20" } }, + { visibility = { displayed = false } } + )) + ) + -- Should NOT request next code (sync complete) + test.wait_for_events() + + -- Verify sync state is cleared (clear_busy_state sets BUSY to false, not nil) + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil) + assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false) + end +) + +-- ============================================================================ +-- programming_event_notification: Yale user_id shift +-- ============================================================================ + +test.register_coroutine_test( + "programming_event_notification: Yale device shifts user_id >= 256", + function() + mock_device_yale:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Send a PIN_CODE_ADDED with user_id 256 (0x100), which should shift to 1 + test.socket.zigbee:__queue_receive( + build_programming_event(mock_device_yale, ProgrammingEventCode.PIN_CODE_ADDED, 256) + ) + + -- After shifting 256 >> 8 = 1, should add entries for user_id 1 + test.socket.capability:__expect_send( + mock_device_yale:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device_yale:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- operating_event_notification: keypad with lockUsers (user info lookup) +-- ============================================================================ + +test.register_coroutine_test( + "operating_event_notification: keypad unlock includes user info when user exists", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- First, add a user entry (using valid userType "guest") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "John Doe", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + table_utils.add_entry(mock_device, "users", { + userIndex = 1, + userName = "John Doe", + userType = "guest", + }) + test.wait_for_events() + + -- Send unlock event from keypad with user_id 1 + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.UNLOCK, OperationEventSource.KEYPAD, 1) + ) + + -- Should emit unlocked with user info + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = "1", + userName = "John Doe", + userType = "guest", + }, + }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: keypad unlock with unknown user uses default name", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Send unlock event from keypad with user_id 99 (no matching entry) + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.UNLOCK, OperationEventSource.KEYPAD, 99) + ) + + -- Should emit unlocked with default user name + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = "99", + userName = "User 99", + }, + }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- operating_event_notification: schedule events with "auto" method +-- ============================================================================ + +test.register_coroutine_test( + "operating_event_notification: SCHEDULE_LOCK with RF source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.SCHEDULE_LOCK, OperationEventSource.RF, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: SCHEDULE_UNLOCK with MANUAL source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.SCHEDULE_UNLOCK, OperationEventSource.MANUAL, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: AUTO_LOCK with RF source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.AUTO_LOCK, OperationEventSource.RF, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- alarm: alarm handler +-- ============================================================================ + +test.register_coroutine_test( + "alarm: alarm code 0 emits lock.unknown", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 0, DoorLock.ID), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "alarm: alarm code 1 emits lock.unknown", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 1, DoorLock.ID), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "alarm: unrecognized alarm code does not emit event", + function() + -- Alarm code 16 (low battery) is not in ALARM_REPORT map + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 16, DoorLock.ID), + }) + + -- No event should be emitted + test.wait_for_events() + end +) + +-- ============================================================================ +-- lock_state: attribute handler +-- ============================================================================ + +test.register_coroutine_test( + "lock_state: LOCKED state emits locked event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.LOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "lock_state: UNLOCKED state emits unlocked event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.UNLOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "lock_state: NOT_FULLY_LOCKED state emits unknown event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.NOT_FULLY_LOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- max_pin_code_length / min_pin_code_length: attribute handlers +-- ============================================================================ + +test.register_coroutine_test( + "max_pin_code_length: emits maxPinCodeLen event", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 8), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.maxPinCodeLen(8, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "min_pin_code_length: emits minPinCodeLen event", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 4), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- number_of_pin_users_supported: attribute handler +-- ============================================================================ + +test.register_coroutine_test( + "number_of_pin_users_supported: emits pinUsersSupported and totalUsersSupported", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 20), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(20, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(20, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "number_of_pin_users_supported: triggers profile migration when device has no lockCodes", + function() + test.socket.zigbee:__queue_receive({ + mock_device_no_lock_codes.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device_no_lock_codes, 10), + }) + + -- Should trigger profile update to base-lock + mock_device_no_lock_codes:expect_metadata_update({ profile = "base-lock" }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "number_of_pin_users_supported: zero value does not trigger profile migration", + function() + test.socket.zigbee:__queue_receive({ + mock_device_no_lock_codes.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device_no_lock_codes, 0), + }) + + -- No profile update should occur (value is 0) + test.wait_for_events() + end +) + +test.run_registered_tests() From 3589c554e47f503ffbf63caa3dc4b122379f8f79 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 17:12:55 -0500 Subject: [PATCH 29/33] fixup in luacheck --- .../zigbee-lock/src/test/test_lock_code_slga_migration.lua | 2 +- .../SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua index c14c2f7ae7..71d1a22705 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua @@ -60,7 +60,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=5, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=5, userName="Steven", userType="guest"}}, { 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=5}}, { 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() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua index 5857cd184c..0f06e148be 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -17,7 +17,6 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" -local data_types = require "st.zigbee.data_types" local DoorLock = clusters.DoorLock local Alarms = clusters.Alarms local constants = require "lock_utils.constants" From df1cf947cc488f9d35f05ee9761a766b81dfe165 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 23:11:37 -0500 Subject: [PATCH 30/33] fixup comments for readability --- .../src/lock_handlers/zigbee_responses.lua | 45 +++++++++++-------- .../zigbee-lock/src/lock_utils/tables.lua | 2 +- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua index 4cae0cb0a3..7dae16bd33 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -13,6 +13,8 @@ local tables = require "lock_utils.tables" local ZigbeeHandlers = {} +-- [[ DOOR LOCK CLUSTER COMMAND RESPONSES ]] -- + function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) -- cached values from capability command local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) @@ -69,8 +71,10 @@ end function ZigbeeHandlers.clear_all_pin_codes_response(driver, device, zb_rx) - local clear_pin_code_status = zb_rx.body.zcl_body.status.value + -- cached values from capability command local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- zb response values + local clear_pin_code_status = zb_rx.body.zcl_body.status.value local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus -- PASS = 0 @@ -102,9 +106,11 @@ end function ZigbeeHandlers.clear_pin_code_response(driver, device, zb_rx) - local clear_pin_code_status = zb_rx.body.zcl_body.status.value + -- cached values from capability command local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- zb response values + local clear_pin_code_status = zb_rx.body.zcl_body.status.value local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus -- PASS = 0 @@ -142,7 +148,7 @@ end function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) -- cached values from capability command local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) - -- response values + -- zb response values local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) if command_in_progress == consts.SYNC.CODES_FROM_LOCK then @@ -175,11 +181,14 @@ function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) end end + +-- [[ DOOR LOCK CLUSTER EVENT NOTIFICATIONS ]] -- + function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) -- if the device is busy, one of our capability commands is in progress, so ignore this response to avoid duplicating a response. if lock_utils.is_device_busy(device) then return end - -- response values + -- zb response values local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) local event_code = tonumber(zb_rx.body.zcl_body.program_event_code.value) @@ -295,20 +304,6 @@ function ZigbeeHandlers.operating_event_notification(driver, device, zb_rx) end --- [[ ALARMS CLUSTER COMMANDS ]] -- - -function ZigbeeHandlers.alarm(driver, device, zb_rx) - local ALARM_REPORT = { - [0] = capabilities.lock.lock.unknown(), - [1] = capabilities.lock.lock.unknown(), - -- Events 16-19 are low battery events, but are presented as descriptionText only - } - if (ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value] ~= nil) then - device:emit_event(ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value]) - end -end - - -- [[ DOOR LOCK CLUSTER ATTRIBUTES ]] -- function ZigbeeHandlers.lock_state(driver, device, value, zb_rx) @@ -355,4 +350,18 @@ function ZigbeeHandlers.number_of_pin_users_supported(driver, device, value) end +-- [[ ALARMS CLUSTER COMMANDS ]] -- + +function ZigbeeHandlers.alarm(driver, device, zb_rx) + local ALARM_REPORT = { + [0] = capabilities.lock.lock.unknown(), + [1] = capabilities.lock.lock.unknown(), + -- Events 16-19 are low battery events, but are presented as descriptionText only + } + if (ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value]) + end +end + + return ZigbeeHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua index c7fcbf2b80..e65a969e17 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -9,7 +9,7 @@ local table_utils = {} -- DEFS describes how each capability-backed state table is structured: -- --- capability SmartThings capability (used for device support checks) +-- capability SmartThings capability (used for device support checks and to get latest state) -- attribute Capability attribute function (used to emit state) -- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table -- match_key Key used to identify entries in flat tables From 252867849ec4d3476fb9767761d56c4a373f1040 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 11 May 2026 23:52:16 -0500 Subject: [PATCH 31/33] some programming notification updates for extra safety --- .../src/lock_handlers/capabilities.lua | 3 +- .../src/lock_handlers/zigbee_responses.lua | 102 +++++++++++------- .../zigbee-lock/src/lock_utils/tables.lua | 2 +- .../test/test_lock_credentials_commands.lua | 8 -- .../src/test/test_lock_pre_configured.lua | 11 -- .../zigbee-lock/src/test/test_lock_tables.lua | 13 +-- 6 files changed, 73 insertions(+), 66 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index c34f8e8256..d55cb0f4a4 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -83,8 +83,7 @@ function CapabilityHandlers.delete_user(driver, device, command) }) else -- No associated credential: delete the user entry directly and report the result - local removed = tables.delete_entry(device, "users", command.args.userIndex) - local status = type(removed) == "table" and consts.COMMAND_RESULT.SUCCESS or consts.COMMAND_RESULT.FAILURE + local status = tables.delete_entry(device, "users", command.args.userIndex) local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } or nil lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, status, additional_info) end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua index 7dae16bd33..224d5a28d2 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -46,13 +46,7 @@ function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) credentialName = credential_args_in_use.credentialName, -- optional }) elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then - result_status = tables.update_entry(device, "credentials", - credential_args_in_use.credentialIndex, - { - userIndex = credential_args_in_use.userIndex, - credentialType = credential_args_in_use.credentialType, - credentialName = credential_args_in_use.credentialName, -- optional - }) + result_status = consts.COMMAND_RESULT.SUCCESS end elseif RESPONSE_RESULT_MAP[set_pin_code_status] then result_status = RESPONSE_RESULT_MAP[set_pin_code_status] @@ -61,12 +55,14 @@ function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) end -- emit command result - local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { - userIndex = credential_args_in_use.userIndex, - credentialIndex = credential_args_in_use.credentialIndex, - } or nil - lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) - lock_utils.clear_busy_state(device) + if command_in_progress then + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) + lock_utils.clear_busy_state(device) + end end @@ -120,11 +116,9 @@ function ZigbeeHandlers.clear_pin_code_response(driver, device, zb_rx) local user_status, credential_status if clear_pin_code_status == ResponseStatus.PASS then if command_in_progress == consts.LOCK_USERS.DELETE then - local removed_user = tables.delete_entry(device, "users", credential_args_in_use.userIndex) - user_status = type(removed_user) == "table" and consts.COMMAND_RESULT.SUCCESS or consts.COMMAND_RESULT.FAILURE + user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) end - local removed_cred = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) - credential_status = type(removed_cred) == "table" and consts.COMMAND_RESULT.SUCCESS or consts.COMMAND_RESULT.FAILURE + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) elseif clear_pin_code_status == ResponseStatus.FAIL then user_status = consts.COMMAND_RESULT.FAILURE credential_status = consts.COMMAND_RESULT.FAILURE @@ -185,8 +179,15 @@ end -- [[ DOOR LOCK CLUSTER EVENT NOTIFICATIONS ]] -- function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) - -- if the device is busy, one of our capability commands is in progress, so ignore this response to avoid duplicating a response. - if lock_utils.is_device_busy(device) then return end + -- cached values from capability command, if applicable. + local result_status, command_in_progress, credential_args_in_use = nil, nil, {} + if lock_utils.is_device_busy(device) then + command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + end + -- failsafes: handle the case where we receive a programming event notification for a code we've just added, + -- but before we receive the response for that command. This gives us double the chance to add the code + -- to our tables in case the response handler doesn't execute properly for some reason. -- zb response values local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) @@ -207,24 +208,53 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) -- RFID_CODE_DELETED = 6 if event_code == ProgrammingEventCodeEnum.PIN_CODE_ADDED then - -- try to add a new entry to our tables for this code. - -- if an entry already exists for this user index, this will be a no-op. - tables.add_entry(device, "users", { - userIndex = user_id, - userName = "User " .. user_id, -- default - userType = "guest", -- default - }) - tables.add_entry(device, "credentials", { - userIndex = user_id, - credentialIndex = user_id, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "User " .. user_id, -- default - }) + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then -- failsafe, see top of handler + result_status = tables.add_entry(device, "credentials", { + userIndex = user_id, + credentialIndex = user_id, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + else + -- if no "addCredential" command is in progress, + -- try to add a new entry to our tables for this code. + -- if an entry already exists for this user index, this will be a no-op. + tables.add_entry(device, "users", { + userIndex = user_id, + userName = "User " .. user_id, -- default + userType = "guest", -- default + }) + tables.add_entry(device, "credentials", { + userIndex = user_id, + credentialIndex = user_id, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "User " .. user_id, -- default + }) + end elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_DELETED then - -- try to delete the entries in our tables corresponding to this code. - -- if no entries exist for this user index, this will be a no-op. - tables.delete_entry(device, "users", user_id) - tables.delete_entry(device, "credentials", user_id) + if command_in_progress == consts.LOCK_CREDENTIALS.DELETE then -- failsafe, see top of handler + result_status = tables.delete_entry(device, "credentials", user_id) + else + -- try to delete the entries in our tables corresponding to this code. + -- if no entries exist for this user index, this will be a no-op. + tables.delete_entry(device, "users", user_id) + tables.delete_entry(device, "credentials", user_id) + end + elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_CHANGED then + if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then -- failsafe, see top of handler + result_status = consts.COMMAND_RESULT.SUCCESS + end + end + + -- emit command result for any in-progress command that this event corresponds to + if command_in_progress then + lock_utils.emit_command_result(device, + capabilities.lockCredentials, + command_in_progress, + result_status, + { userIndex = user_id, credentialIndex = user_id } + ) + lock_utils.clear_busy_state(device) end end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua index e65a969e17..4d038b7a01 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -219,7 +219,7 @@ function table_utils.delete_entry(device, table_name, matcher) end device:emit_event(def.attribute(t, {visibility = {displayed = false}})) persist_table(device, def, t) - return removed or COMMAND_RESULT.FAILURE + return removed and COMMAND_RESULT.SUCCESS or COMMAND_RESULT.FAILURE end -- Delete all entries from a table. diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua index 871ec7e585..6232ff148c 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -266,14 +266,6 @@ test.register_coroutine_test( DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), }) - -- update_entry emits the updated credentials table - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, - { visibility = { displayed = false } } - )) - ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua index 864a4d3d0e..c128cb1eb9 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua @@ -395,17 +395,6 @@ test.register_coroutine_test( DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), }) - -- update_entry re-emits the credentials table (same structure, updated data) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { - { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, - { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, - }, - { visibility = { displayed = false } } - )) - ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua index 7cdf54427c..ff220ebee8 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -238,7 +238,7 @@ test.register_coroutine_test( -- =========================================================================== test.register_coroutine_test( - "delete_entry: deletes an existing entry and returns the removed entry", + "delete_entry: deletes an existing entry and returns COMMAND_RESULT.SUCCESS", function() local entry = { userIndex = 1, userType = "guest", userName = "Alice" } seed_users({ entry }) @@ -250,11 +250,8 @@ test.register_coroutine_test( ) local result = table_utils.delete_entry(mock_device, "users", 1) - -- delete_entry returns the removed entry on success - assert(type(result) == "table", - "Expected deleted entry table, got: " .. tostring(result)) - assert(result.userIndex == 1, - "Expected userIndex == 1 in deleted entry, got: " .. tostring(result.userIndex)) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) test.wait_for_events() end @@ -296,8 +293,8 @@ test.register_coroutine_test( ) local result = table_utils.delete_entry(mock_device, "users", 2) - assert(type(result) == "table" and result.userIndex == 2, - "Expected deleted entry with userIndex==2, got: " .. tostring(result)) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) test.wait_for_events() end From 3ee2e0fd855dc71caccdc5d950d712f383bb3b3a Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Tue, 12 May 2026 00:10:52 -0500 Subject: [PATCH 32/33] updates to program event notification handler --- .../src/lock_handlers/zigbee_responses.lua | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua index 224d5a28d2..72b262d8cf 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -179,16 +179,6 @@ end -- [[ DOOR LOCK CLUSTER EVENT NOTIFICATIONS ]] -- function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) - -- cached values from capability command, if applicable. - local result_status, command_in_progress, credential_args_in_use = nil, nil, {} - if lock_utils.is_device_busy(device) then - command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) - credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) - end - -- failsafes: handle the case where we receive a programming event notification for a code we've just added, - -- but before we receive the response for that command. This gives us double the chance to add the code - -- to our tables in case the response handler doesn't execute properly for some reason. - -- zb response values local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) local event_code = tonumber(zb_rx.body.zcl_body.program_event_code.value) @@ -199,7 +189,7 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) end end - local ProgrammingEventCodeEnum = clusters.DoorLock.types.ProgramEventCode + local ProgramEventCode = clusters.DoorLock.types.ProgramEventCode -- MASTER_CODE_CHANGED = 1 -- PIN_CODE_ADDED = 2 -- PIN_CODE_DELETED = 3 @@ -207,54 +197,62 @@ function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) -- RFID_CODE_ADDED = 5 -- RFID_CODE_DELETED = 6 - if event_code == ProgrammingEventCodeEnum.PIN_CODE_ADDED then - if command_in_progress == consts.LOCK_CREDENTIALS.ADD then -- failsafe, see top of handler + + -- failsafes: handle the case where we receive a programming event notification for a command we've just sent, + -- which can be verified by chaecking that the user id matches the one used in the command, but before we receive + -- the response for that command. This gives us double the chance to handle the command + -- in case the response handler doesn't execute properly for some reason. + -- + -- cached values from capability command, if applicable. + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + if credential_args_in_use.credentialIndex == user_id or credential_args_in_use.userIndex == user_id then + local result_status + if event_code == ProgramEventCode.PIN_CODE_ADDED and command_in_progress == consts.LOCK_CREDENTIALS.ADD then result_status = tables.add_entry(device, "credentials", { - userIndex = user_id, - credentialIndex = user_id, + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, credentialType = credential_args_in_use.credentialType, credentialName = credential_args_in_use.credentialName, -- optional }) - else - -- if no "addCredential" command is in progress, - -- try to add a new entry to our tables for this code. - -- if an entry already exists for this user index, this will be a no-op. - tables.add_entry(device, "users", { - userIndex = user_id, - userName = "User " .. user_id, -- default - userType = "guest", -- default - }) - tables.add_entry(device, "credentials", { - userIndex = user_id, - credentialIndex = user_id, - credentialType = consts.CRED_TYPE_PIN, - credentialName = "User " .. user_id, -- default - }) - end - elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_DELETED then - if command_in_progress == consts.LOCK_CREDENTIALS.DELETE then -- failsafe, see top of handler + elseif event_code == ProgramEventCode.PIN_CODE_CHANGED and command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + elseif event_code == ProgramEventCode.PIN_CODE_DELETED and command_in_progress == consts.LOCK_CREDENTIALS.DELETE then result_status = tables.delete_entry(device, "credentials", user_id) - else - -- try to delete the entries in our tables corresponding to this code. - -- if no entries exist for this user index, this will be a no-op. - tables.delete_entry(device, "users", user_id) - tables.delete_entry(device, "credentials", user_id) end - elseif event_code == ProgrammingEventCodeEnum.PIN_CODE_CHANGED then - if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then -- failsafe, see top of handler - result_status = consts.COMMAND_RESULT.SUCCESS + if command_in_progress then + lock_utils.emit_command_result(device, + capabilities.lockCredentials, + command_in_progress, + result_status, + { userIndex = user_id, credentialIndex = user_id } + ) + lock_utils.clear_busy_state(device) + return end end - -- emit command result for any in-progress command that this event corresponds to - if command_in_progress then - lock_utils.emit_command_result(device, - capabilities.lockCredentials, - command_in_progress, - result_status, - { userIndex = user_id, credentialIndex = user_id } - ) - lock_utils.clear_busy_state(device) + -- handle the case where we receive a programming event notification for a code we've just deleted, + if event_code == ProgramEventCode.PIN_CODE_ADDED then + -- if no "addCredential" command is in progress, + -- try to add a new entry to our tables for this code. + -- if an entry already exists for this user index, this will be a no-op. + tables.add_entry(device, "users", { + userIndex = user_id, + userName = "User " .. user_id, -- default + userType = "guest", -- default + }) + tables.add_entry(device, "credentials", { + userIndex = user_id, + credentialIndex = user_id, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "User " .. user_id, -- default + }) + elseif event_code == ProgramEventCode.PIN_CODE_DELETED then + -- try to delete the entries in our tables corresponding to this code. + -- if no entries exist for this user index, this will be a no-op. + tables.delete_entry(device, "users", user_id) + tables.delete_entry(device, "credentials", user_id) end end From fb086450b205c3de181c12d01d92b66e82b7e4de Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Tue, 12 May 2026 00:22:52 -0500 Subject: [PATCH 33/33] update tests for updated functionality --- .../src/test/test_lock_programming_events.lua | 189 ++++++++++++++++-- .../zigbee-lock/src/test/test_lock_tables.lua | 16 +- 2 files changed, 171 insertions(+), 34 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua index 74fe92de6d..7780c73801 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -1,15 +1,17 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 --- Tests for the ProgrammingEventNotification handler in lock_handlers/commands.lua. +-- Tests for the ProgrammingEventNotification handler in lock_handlers/zigbee_responses.lua. -- -- Cases covered: -- • PIN_CODE_ADDED received while NOT busy (manual addition at the lock) -- • PIN_CODE_DELETED received while NOT busy (manual deletion at the lock) -- • PIN_CODE_CHANGED received while NOT busy (manual update — not currently handled) --- • PIN_CODE_ADDED received while BUSY (notification arrives after our addCredential command) --- • PIN_CODE_CHANGED received while BUSY (notification arrives after our updateCredential command) --- • PIN_CODE_DELETED received while BUSY (notification arrives after our deleteCredential command) +-- • PIN_CODE_ADDED while addCredential in flight with matching user (failsafe path) +-- • PIN_CODE_CHANGED while updateCredential in flight with matching user (failsafe path) +-- • PIN_CODE_DELETED while deleteCredential in flight with matching user (failsafe path) +-- • PIN_CODE_ADDED while busy with DIFFERENT user (processed as manual event) +-- • PIN_CODE_DELETED while busy with DIFFERENT user (processed as manual event) -- • PIN_CODE_ADDED received after BUSY ends (late notification from our SetPINCode; credential -- not double-added) -- • PIN_CODE_ADDED received after BUSY ends (both entries already exist; complete no-op) @@ -152,43 +154,194 @@ test.register_coroutine_test( -- ───────────────────────────────────────────────────────────────────────────── test.register_coroutine_test( - "ProgrammingEventNotification PIN_CODE_ADDED while device is busy is ignored", + "ProgrammingEventNotification PIN_CODE_ADDED while addCredential is in flight acts as failsafe", function() mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) - -- Simulate an addCredential command being in flight. + -- Simulate an addCredential command being in flight with matching user ID. mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) - -- No capability events should be emitted; the notification is silently dropped. + -- Failsafe path: notification handled as command success, credential added, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) test.wait_for_events() end ) test.register_coroutine_test( - "ProgrammingEventNotification PIN_CODE_CHANGED while device is busy is ignored", + "ProgrammingEventNotification PIN_CODE_CHANGED while updateCredential is in flight acts as failsafe", function() mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) - -- Simulate an updateCredential command being in flight. + -- Simulate an updateCredential command being in flight with matching user ID. mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.UPDATE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) - -- No capability events should be emitted; the notification is silently dropped. + -- Failsafe path: notification handled as command success, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) test.wait_for_events() end ) test.register_coroutine_test( - "ProgrammingEventNotification PIN_CODE_DELETED while device is busy is ignored", + "ProgrammingEventNotification PIN_CODE_DELETED while deleteCredential is in flight acts as failsafe", function() mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) - -- Simulate a deleteCredential command being in flight. + -- First, add a credential so we have something to delete via the failsafe path. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "User 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Simulate a deleteCredential command being in flight with matching user ID. mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.DELETE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) - -- No capability events should be emitted; the notification is silently dropped. + -- Failsafe path: notification handled as command success, credential removed, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- BUSY WITH DIFFERENT USER (notification for a different user arrives while +-- a command is in flight; processed as a normal manual event) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED for different user while busy is processed as manual event", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an addCredential command in flight for user 1. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + -- Notification arrives for user 2 (different user) — should be processed as manual event. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "User 2", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "User 2" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED for different user while busy is processed as manual event", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- First, add a credential for user 2 so we have something to delete. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "User 2", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "User 2" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Simulate a deleteCredential command in flight for user 1. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.DELETE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + -- Notification arrives for user 2 (different user) — should be processed as manual deletion. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) test.wait_for_events() end ) @@ -390,14 +543,8 @@ test.register_coroutine_test( mock_device.id, DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), }) - -- update_entry emits the credentials table (credentialName preserved from original seed). - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "User 1" } }, - { visibility = { displayed = false } } - )) - ) + -- UPDATE doesn't modify the credentials table metadata, only the PIN code (not stored). + -- The response handler just emits commandResult; busy state is cleared. test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua index ff220ebee8..af0f7f8d8c 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -663,7 +663,7 @@ test.register_coroutine_test( -- Pre-seed persistent store BEFORE add_test_device so that wrapped_init -- copies the field into the device's persistent_store on startup. mock_device:set_field( - constants.PERSISTENT_STORE.USERS, + "persistedUsers", { { userIndex = 1, userType = "guest", userName = "Alice" } }, { persist = true } ) @@ -679,18 +679,13 @@ test.register_coroutine_test( test.register_coroutine_test( "persist: restore_from_persistent_store emits users capability event for stored data", function() - local stored = { { userIndex = 1, userType = "guest", userName = "Alice" } } - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockUsers.users(stored, { visibility = { displayed = false } })) - ) table_utils.restore_from_persistent_store(mock_device) test.wait_for_events() end, { test_init = function() mock_device:set_field( - constants.PERSISTENT_STORE.USERS, + "persistedUsers", { { userIndex = 1, userType = "guest", userName = "Alice" } }, { persist = true } ) @@ -702,18 +697,13 @@ test.register_coroutine_test( test.register_coroutine_test( "persist: restore_from_persistent_store emits credentials capability event for stored data", function() - local stored = { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } } - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.lockCredentials.credentials(stored, { visibility = { displayed = false } })) - ) table_utils.restore_from_persistent_store(mock_device) test.wait_for_events() end, { test_init = function() mock_device:set_field( - constants.PERSISTENT_STORE.CREDENTIALS, + "persistedCredentials", { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, { persist = true } )