From 288c3b774b5a8d7b6dd11490ff096e35eeca7141 Mon Sep 17 00:00:00 2001 From: Tom Manley Date: Wed, 21 Oct 2026 13:06:52 -0500 Subject: [PATCH 1/4] zwave-lock: DEBUG scan Yale user code slots to find the master code DEBUG ONLY, do not merge. On refresh, Yale (0x0129) locks walk User Code slots 1-255 and log each non-empty slot (status and code), plus a summary of occupied and unresponsive slots. Reports consumed by the scan are not added to the lockUsers/lockCredentials tables. Co-Authored-By: Claude Opus 5.5 --- .../zwave-lock/src/debug_code_scan.lua | 108 +++++++++++++++++ .../src/lock_handlers/capabilities.lua | 3 + .../src/lock_handlers/zwave_responses.lua | 3 + .../src/test/test_debug_code_scan.lua | 110 ++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 drivers/SmartThings/zwave-lock/src/debug_code_scan.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua diff --git a/drivers/SmartThings/zwave-lock/src/debug_code_scan.lua b/drivers/SmartThings/zwave-lock/src/debug_code_scan.lua new file mode 100644 index 0000000000..40245b5335 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/debug_code_scan.lua @@ -0,0 +1,108 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- DEBUG ONLY, do not merge: walks every User Code slot on a Yale lock and logs the slots +-- that are not empty, to find which slot holds the master code. Started by a refresh. +-- Reports consumed by the scan are not added to the lockUsers/lockCredentials tables. + +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local lock_utils = require "lock_utils.utils" + +local YALE_MFR = 0x0129 +local FIRST_SLOT = 1 +local LAST_SLOT = 255 +local RESPONSE_TIMEOUT = 5 -- seconds to wait for a slot's report before moving on +local BUSY_RETRY_DELAY = 5 -- seconds to wait while another lock operation is in progress + +local SCAN_INDEX = "debugScanCodeIndex" +local SCAN_TIMER = "debugScanCodeTimer" +local SCAN_FOUND = "debugScanCodesFound" +local SCAN_NO_RESPONSE = "debugScanCodesNoResponse" + +local debug_code_scan = {} + +local function cancel_timer(device) + local timer = device:get_field(SCAN_TIMER) + if timer then + device.thread:cancel_timer(timer) + device:set_field(SCAN_TIMER, nil) + end +end + +local function finish(device) + local found = device:get_field(SCAN_FOUND) or {} + local no_response = device:get_field(SCAN_NO_RESPONSE) or {} + device.log.info(string.format("debug code scan: complete, %d non-empty slot(s): [%s]", + #found, table.concat(found, ", "))) + if #no_response > 0 then + device.log.info(string.format("debug code scan: %d slot(s) did not respond: [%s]", + #no_response, table.concat(no_response, ", "))) + end + device:set_field(SCAN_INDEX, nil) + device:set_field(SCAN_FOUND, nil) + device:set_field(SCAN_NO_RESPONSE, nil) +end + +local request_slot + +local function advance(device) + cancel_timer(device) + local next_slot = device:get_field(SCAN_INDEX) + 1 + if next_slot > LAST_SLOT then + finish(device) + else + device:set_field(SCAN_INDEX, next_slot) + request_slot(device) + end +end + +request_slot = function(device) + local slot = device:get_field(SCAN_INDEX) + if slot == nil then return end + if lock_utils.is_device_busy(device) then + -- another operation (e.g. the post-pairing sync) is using UserCode reports, so wait for it to finish + device:set_field(SCAN_TIMER, device.thread:call_with_delay(BUSY_RETRY_DELAY, function() + device:set_field(SCAN_TIMER, nil) + request_slot(device) + end)) + return + end + device:send(UserCode:Get({ user_identifier = slot })) + device:set_field(SCAN_TIMER, device.thread:call_with_delay(RESPONSE_TIMEOUT, function() + device:set_field(SCAN_TIMER, nil) -- this timer has fired, so there is nothing to cancel + local no_response = device:get_field(SCAN_NO_RESPONSE) or {} + table.insert(no_response, slot) + device:set_field(SCAN_NO_RESPONSE, no_response) + advance(device) + end)) +end + +function debug_code_scan.start(device) + if device.zwave_manufacturer_id ~= YALE_MFR or device:get_field(SCAN_INDEX) ~= nil then return end + device.log.info(string.format("debug code scan: starting, slots %d-%d", FIRST_SLOT, LAST_SLOT)) + device:set_field(SCAN_INDEX, FIRST_SLOT) + device:set_field(SCAN_FOUND, {}) + device:set_field(SCAN_NO_RESPONSE, {}) + request_slot(device) +end + +-- Returns true if the report was for the slot currently being scanned and was consumed. +function debug_code_scan.handle_report(device, cmd) + local slot = device:get_field(SCAN_INDEX) + if slot == nil or cmd.args.user_identifier ~= slot or lock_utils.is_device_busy(device) then + return false + end + local status = cmd.args.user_id_status + if status ~= UserCode.user_id_status.AVAILABLE then + device.log.info(string.format("debug code scan: slot %d status=%s code=%q", + slot, UserCode._reflect_user_id_status[status] or tostring(status), tostring(cmd.args.user_code))) + local found = device:get_field(SCAN_FOUND) or {} + table.insert(found, slot) + device:set_field(SCAN_FOUND, found) + end + advance(device) + return true +end + +return debug_code_scan diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua index 13d45bc4dd..2d69dbd594 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -5,6 +5,7 @@ local capabilities = require "st.capabilities" local lock_utils = require "lock_utils.utils" local tables = require "lock_utils.tables" local consts = require "lock_utils.constants" +local debug_code_scan = require "debug_code_scan" --- @type st.zwave.CommandClass.DoorLock local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) @@ -247,6 +248,8 @@ function CapabilityHandlers.refresh(driver, device, cmd) device:send(UserCode:UsersNumberGet({})) end end + + debug_code_scan.start(device) end return CapabilityHandlers diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua index 7822ab2bfe..1cb5b2c087 100644 --- a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -15,6 +15,7 @@ local TamperDefaults = require "st.zwave.defaults.tamperAlert" local consts = require "lock_utils.constants" local lock_utils = require "lock_utils.utils" local tables = require "lock_utils.tables" +local debug_code_scan = require "debug_code_scan" local ZwaveHandlers = {} @@ -23,6 +24,8 @@ local ZwaveHandlers = {} -- [[ USER CODE COMMAND CLASS ]] function ZwaveHandlers.user_code_report(driver, device, cmd) + if debug_code_scan.handle_report(device, cmd) then return end + -- zw report values local credential_index = cmd.args.user_identifier local user_id_status = cmd.args.user_id_status diff --git a/drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua b/drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua new file mode 100644 index 0000000000..a1c5d84b40 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua @@ -0,0 +1,110 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- DEBUG ONLY, do not merge: tests for debug_code_scan.lua + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local YALE_MANUFACTURER_ID = 0x0129 + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = YALE_MANUFACTURER_ID, + zwave_product_type = 0x0002, + zwave_product_id = 0x0600, +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) +end +test.set_test_init_function(test_init) + +local function refresh_and_expect_scan_start() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device.id)) + test.wait_for_events() +end + +test.register_coroutine_test( + "refresh starts the scan, and an occupied slot is consumed without updating the tables", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + refresh_and_expect_scan_start() + + test.socket.zwave:__queue_receive({ mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) + }) + -- no lockUsers/lockCredentials events are expected; the driver just requests the next slot + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id)) + test.wait_for_events() + assert(mock_device:get_field("debugScanCodeIndex") == 2, "scan should advance to slot 2") + end +) + +test.register_coroutine_test( + "the scan moves on when a slot does not respond", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + -- the timer for slot 2 must not also fire when time advances past slot 1's timeout + test.timer.__create_and_queue_test_time_advance_timer(60, "oneshot") + refresh_and_expect_scan_start() + + test.mock_time.advance_time(5) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "the scan finishes after the last slot", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + refresh_and_expect_scan_start() + mock_device:set_field("debugScanCodeIndex", 255) + + test.socket.zwave:__queue_receive({ mock_device.id, + UserCode:Report({ + user_identifier = 255, + user_id_status = UserCode.user_id_status.AVAILABLE, + }) + }) + test.wait_for_events() + assert(mock_device:get_field("debugScanCodeIndex") == nil, "scan state should be cleared") + end +) + +test.run_registered_tests() From ca8799dd8bd022ddb7fd62f6ef77f3dd99ef5667 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 31 Jul 2026 10:07:25 -0500 Subject: [PATCH 2/4] Remove auto-migration for Zigbee locks on added (cherry picked from commit 2b858ffc2e931532ed8400369beff26cfc79f458) --- drivers/SmartThings/zigbee-lock/src/init.lua | 15 +- .../src/test/test_init_lifecycle_handlers.lua | 182 +++++++++--------- 2 files changed, 100 insertions(+), 97 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index b8b290af4d..a901e420db 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -16,13 +16,14 @@ local capability_handlers = require "lock_handlers.capabilities" local LockLifecycle = {} function LockLifecycle.device_added(driver, device) - if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then - -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. - -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, - -- and should be migrated manually by the user. - device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore - end + -- Note: We should not auto-migrate for the time being + -- if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then + -- -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. + -- -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, + -- -- and should be migrated manually by the user. + -- device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + -- device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore + -- end -- set initial state driver:inject_capability_command(device, { capability = capabilities.refresh.ID, 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 index 1ff448a719..6fae60df4f 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua @@ -99,50 +99,51 @@ 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.socket.zigbee:__expect_send({ - mock_device_typed.id, - DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), - }) - test.socket.zigbee:__expect_send({ - mock_device_typed.id, - DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), - }) - test.socket.zigbee:__expect_send({ - mock_device_typed.id, - DoorLock.attributes.NumberOfPINUsersSupported: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) } -) +-- Note: Remove test for the time being, since auto-migration on added is no longer the default behavior +-- 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.socket.zigbee:__expect_send({ +-- mock_device_typed.id, +-- DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_typed.id, +-- DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_typed.id, +-- DoorLock.attributes.NumberOfPINUsersSupported: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", @@ -433,52 +434,53 @@ test.register_coroutine_test( -- driverSwitched (LockLifecycle.driver_switched) -- ============================================================================ -test.register_coroutine_test( - "driver_switched: device with lockCodes and migrated=true persists SLGA_MIGRATED and updates metadata", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) - - -- Migrated event 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.socket.zigbee:__expect_send({ - mock_device_typed.id, - DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), - }) - test.socket.zigbee:__expect_send({ - mock_device_typed.id, - DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), - }) - test.socket.zigbee:__expect_send({ - mock_device_typed.id, - DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), - }) - test.wait_for_events() - - -- driverSwitched occurs after added, so migrated=true is already set in the capability state cache - test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "driver_switched" }) - mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.wait_for_events() - - assert(mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true) - end, - { test_init = make_test_init(mock_device_typed) } -) +-- Note: Remove test for the time being, since auto-migration on added is no longer the default behavior, and that screws up this test +-- test.register_coroutine_test( +-- "driver_switched: device with lockCodes and migrated=true persists SLGA_MIGRATED and updates metadata", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + +-- -- Migrated event 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.socket.zigbee:__expect_send({ +-- mock_device_typed.id, +-- DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_typed.id, +-- DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_typed.id, +-- DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), +-- }) +-- test.wait_for_events() + +-- -- driverSwitched occurs after added, so migrated=true is already set in the capability state cache +-- test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "driver_switched" }) +-- mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +-- test.wait_for_events() + +-- assert(mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true) +-- end, +-- { test_init = make_test_init(mock_device_typed) } +-- ) -- ============================================================================ test.run_registered_tests() From 451ceefd2e87e3c4a6b552039080293c101ebc0f Mon Sep 17 00:00:00 2001 From: Tom Manley Date: Wed, 21 Oct 2026 13:19:00 -0500 Subject: [PATCH 3/4] zigbee-lock: DEBUG log PIN slot 0 (master code) on Yale locks DEBUG ONLY, do not merge. On refresh, Yale / ASSA ABLOY iRevo locks read PIN slot 0 and log its user status, user type, and code, to show whether a master code is set. The requested response is not added to lockCodes or the lockUsers/lockCredentials tables, unless a legacy code scan is also checking slot 0. Works on both the legacy lockCodes and migrated paths. Co-Authored-By: Claude Opus 5.5 --- .../zigbee-lock/src/debug_master_code.lua | 37 ++++++ .../zigbee-lock/src/legacy-handlers/init.lua | 2 + .../src/legacy-handlers/yale/init.lua | 2 + .../src/lock_handlers/capabilities.lua | 2 + .../src/lock_handlers/zigbee_responses.lua | 4 + .../src/test/test_debug_master_code.lua | 108 ++++++++++++++++++ .../test_zigbee_yale_fingerprint-lock.lua | 3 + 7 files changed, 158 insertions(+) create mode 100644 drivers/SmartThings/zigbee-lock/src/debug_master_code.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_debug_master_code.lua diff --git a/drivers/SmartThings/zigbee-lock/src/debug_master_code.lua b/drivers/SmartThings/zigbee-lock/src/debug_master_code.lua new file mode 100644 index 0000000000..87111aa106 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/debug_master_code.lua @@ -0,0 +1,37 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- DEBUG ONLY, do not merge: on refresh, reads PIN slot 0 (the Zigbee master code slot) on +-- Yale locks and logs what the lock reports for it, to see whether a master code is set. + +local clusters = require "st.zigbee.zcl.clusters" +local LockCluster = clusters.DoorLock + +local YALE_MANUFACTURERS = { ["Yale"] = true, ["ASSA ABLOY iRevo"] = true } +local MASTER_CODE_SLOT = 0 +local READ_PENDING = "debugMasterCodeReadPending" + +local debug_master_code = {} + +function debug_master_code.read(device) + if not YALE_MANUFACTURERS[device:get_manufacturer()] then return end + device:set_field(READ_PENDING, true) + -- the lock only includes the PIN in GetPINCodeResponse when this is set + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.GetPINCode(device, MASTER_CODE_SLOT)) +end + +-- Logs the response to debug_master_code.read. Returns true if the caller should not process +-- the response further, which is the case unless a code scan is also checking slot 0. +function debug_master_code.handle_response(device, zb_rx, scanning_slot) + local body = zb_rx.body.zcl_body + if not device:get_field(READ_PENDING) or body.user_id.value ~= MASTER_CODE_SLOT then + return false + end + device:set_field(READ_PENDING, nil) + device.log.info(string.format("debug master code: slot 0 %s, %s, code=%q", + tostring(body.user_status), tostring(body.user_type), tostring(body.code.value))) + return scanning_slot ~= MASTER_CODE_SLOT +end + +return debug_master_code diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua index b9d5ce78ad..6a43ca851d 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -23,6 +23,7 @@ local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode local socket = require "cosock.socket" local lock_utils = require "legacy-handlers.legacy_lock_utils" +local debug_master_code = require "debug_master_code" local DELAY_LOCK_EVENT = "_delay_lock_event" local MAX_DELAY = 10 @@ -56,6 +57,7 @@ local refresh = function(driver, device, cmd) -- we won't make this value persist because it's not that important device:set_field(lock_utils.CHECKED_CODE_SUPPORT, true) end + debug_master_code.read(device) end local do_configure = function(self, device) diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua index a85a9f8798..dc76136a18 100644 --- a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua @@ -16,6 +16,7 @@ local UserStatusEnum = LockCluster.types.DrlkUserStatus local UserTypeEnum = LockCluster.types.DrlkUserType local lock_utils = (require "legacy-handlers.legacy_lock_utils") +local debug_master_code = require "debug_master_code" local reload_all_codes = function(driver, device, command) -- starts at first user code index then iterates through all lock codes as they come in @@ -64,6 +65,7 @@ local set_code = function(driver, device, command) end local get_pin_response_handler = function(driver, device, zb_mess) + if debug_master_code.handle_response(device, zb_mess, device:get_field(lock_utils.CHECKING_CODE)) then return end local event = LockCodes.codeChanged("", { state_change = true }) local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) local localCode = device:get_field(lock_utils.CODE_STATE) diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index 371cf7a765..836d6340bf 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -6,6 +6,7 @@ local clusters = require "st.zigbee.zcl.clusters" local lock_utils = require "lock_utils.utils" local tables = require "lock_utils.tables" local consts = require "lock_utils.constants" +local debug_master_code = require "debug_master_code" local Alarm = clusters.Alarms local LockCluster = clusters.DoorLock @@ -243,6 +244,7 @@ function CapabilityHandlers.refresh(driver, device, cmd) -- migration when appropriate. device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) end + debug_master_code.read(device) end return CapabilityHandlers 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 9d1c53947a..55a439b0bd 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -9,6 +9,7 @@ local capabilities = require "st.capabilities" local consts = require "lock_utils.constants" local lock_utils = require "lock_utils.utils" local tables = require "lock_utils.tables" +local debug_master_code = require "debug_master_code" local ZigbeeHandlers = {} @@ -148,6 +149,9 @@ end function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) + -- the code sync starts at slot 1, so it never needs the slot 0 response + if debug_master_code.handle_response(device, zb_rx) then return end + -- cached values from capability command local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) -- zb response values diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_debug_master_code.lua b/drivers/SmartThings/zigbee-lock/src/test/test_debug_master_code.lua new file mode 100644 index 0000000000..9a76a43155 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_debug_master_code.lua @@ -0,0 +1,108 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- DEBUG ONLY, do not merge: tests for debug_master_code.lua + +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 constants = require "lock_utils.constants" + +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 function test_init_migrated() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) +end + +local function master_code_response(user_status) + return { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, 0x00, user_status, DoorLockUserType.MASTER_USER, "123456") + } +end + +local function receive_refresh() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.refresh.ID, 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 + +local function expect_master_code_read() + 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.GetPINCode(mock_device, 0) }) +end + +test.register_coroutine_test( + "legacy: refresh reads slot 0, and the response is logged without being added to lockCodes", + function() + receive_refresh() + expect_master_code_read() + test.wait_for_events() + + -- no lockCodes events are expected for the requested slot 0 response + test.socket.zigbee:__queue_receive(master_code_response(DoorLockUserStatus.OCCUPIED_ENABLED)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "legacy: a slot 0 response that was not requested by the debug read is processed normally", + function() + test.socket.zigbee:__queue_receive(master_code_response(DoorLockUserStatus.OCCUPIED_ENABLED)) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Code 0" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({ ["0"] = "Code 0" }), { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "migrated: refresh reads slot 0, and the response does not change the users or credentials", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = capabilities.refresh.ID, 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) }) + 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) }) + expect_master_code_read() + test.wait_for_events() + + test.socket.zigbee:__queue_receive(master_code_response(DoorLockUserStatus.AVAILABLE)) + test.wait_for_events() + end, + { test_init = test_init_migrated } +) + +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 560e0b3768..1bfdd2211e 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 @@ -29,6 +29,9 @@ local function test_init_new_capabilities() 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) }) + -- DEBUG ONLY: debug_master_code reads slot 0 on refresh for Yale locks + 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.GetPINCode(mock_device, 0) }) end test.register_message_test( From 24fd0514bd18e839f170d9d8cff86b8e1ec63317 Mon Sep 17 00:00:00 2001 From: Tom Manley Date: Wed, 21 Oct 2026 14:08:45 -0500 Subject: [PATCH 4/4] zwave-lock: DEBUG hook the code scan into the legacy UserCode handler DEBUG ONLY, do not merge. Unmigrated locks route UserCode reports to the legacy-handlers sub-driver, so the scan never saw them: nothing was logged and every slot waited out the timeout. Wrap the legacy UserCode report handler so the scan sees each report first. A report the legacy code reload is waiting on is still passed through. Co-Authored-By: Claude Opus 5.5 --- .../zwave-lock/src/debug_code_scan.lua | 8 ++- .../zwave-lock/src/legacy-handlers/init.lua | 7 ++- .../src/test/test_debug_code_scan.lua | 61 +++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/drivers/SmartThings/zwave-lock/src/debug_code_scan.lua b/drivers/SmartThings/zwave-lock/src/debug_code_scan.lua index 40245b5335..7246c95631 100644 --- a/drivers/SmartThings/zwave-lock/src/debug_code_scan.lua +++ b/drivers/SmartThings/zwave-lock/src/debug_code_scan.lua @@ -87,8 +87,10 @@ function debug_code_scan.start(device) request_slot(device) end --- Returns true if the report was for the slot currently being scanned and was consumed. -function debug_code_scan.handle_report(device, cmd) +-- Logs the report if it is for the slot currently being scanned. Returns true if the caller +-- should not process the report further, which is the case unless the legacy code reload +-- (scanning_slot) is also waiting on this slot. +function debug_code_scan.handle_report(device, cmd, scanning_slot) local slot = device:get_field(SCAN_INDEX) if slot == nil or cmd.args.user_identifier ~= slot or lock_utils.is_device_busy(device) then return false @@ -102,7 +104,7 @@ function debug_code_scan.handle_report(device, cmd) device:set_field(SCAN_FOUND, found) end advance(device) - return true + return scanning_slot ~= slot end return debug_code_scan diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua index 3bcb8adfe4..23e9473cd8 100644 --- a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -7,6 +7,7 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local LockDefaults = require "st.zwave.defaults.lock" local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local debug_code_scan = require "debug_code_scan" local init_handler = function(driver, device, event) local constants = require "st.zwave.constants" @@ -121,7 +122,11 @@ local legacy_capabilities = { end }, [cc.USER_CODE] = { - [UserCode.REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT], + [UserCode.REPORT] = function(driver, device, cmd) + local constants = require "st.zwave.constants" + if debug_code_scan.handle_report(device, cmd, device:get_field(constants.CHECKING_CODE)) then return end + LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](driver, device, cmd) + end, [UserCode.USERS_NUMBER_REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.USERS_NUMBER_REPORT], } }, diff --git a/drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua b/drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua index a1c5d84b40..5382357f39 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_debug_code_scan.lua @@ -6,6 +6,9 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" local zw = require "st.zwave" +local zw_constants = require "st.zwave.constants" +local capabilities = require "st.capabilities" +local json = require "st.json" local constants = require "lock_utils.constants" --- @type st.zwave.CommandClass.Battery @@ -43,6 +46,11 @@ local function test_init() end test.set_test_init_function(test_init) +local function test_init_legacy() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end + local function refresh_and_expect_scan_start() test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } @@ -107,4 +115,57 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "legacy: an occupied slot is consumed without updating lockCodes", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + refresh_and_expect_scan_start() + + test.socket.zwave:__queue_receive({ mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) + }) + -- no lockCodes events are expected; the driver just requests the next slot + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id)) + test.wait_for_events() + assert(mock_device:get_field("debugScanCodeIndex") == 2, "scan should advance to slot 2") + end, + { test_init = test_init_legacy } +) + +test.register_coroutine_test( + "legacy: a slot that a code reload is waiting on is also processed by the legacy handler", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.zwave:__queue_receive({ mock_device.id, UserCode:UsersNumberReport({ supported_users = 30 }) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.maxCodes(30, { visibility = { displayed = false } }))) + test.wait_for_events() + refresh_and_expect_scan_start() + mock_device:set_field(zw_constants.CHECKING_CODE, 1) + + test.socket.zwave:__queue_receive({ mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }) + }) + -- the scan requests the next slot, then the legacy handler records the code and continues its reload + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id)) + 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:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "Code 1" }, state_change = true }))) + test.socket.zwave:__expect_send(UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id)) + test.wait_for_events() + end, + { test_init = test_init_legacy } +) + test.run_registered_tests()