From 2b858ffc2e931532ed8400369beff26cfc79f458 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 31 Jul 2026 10:07:25 -0500 Subject: [PATCH 1/2] Remove auto-migration for Zigbee locks on added --- 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 a3ee1e2d5cb129b6b209c2a8c58899e701e025b2 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Fri, 31 Jul 2026 12:53:37 -0500 Subject: [PATCH 2/2] Un-migrate Zigbee locks --- Jenkinsfile | 1 + drivers/SmartThings/zigbee-lock/src/init.lua | 25 +++- .../src/test/test_init_lifecycle_handlers.lua | 35 ++--- .../src/test/test_lock_users_commands.lua | 132 ++++++++++++++++++ 4 files changed, 173 insertions(+), 20 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 752d3d358c..ce90c838ab 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -4,6 +4,7 @@ def getEnvName() { if (branch == "origin/main") {return "ALPHA"} else if (branch == "origin/beta") {return "BETA"} else if (branch == "origin/production") {return "PROD"} + else if (branch == "origin/un-migrate-zigbee-locks") {return "TEST"} } def getChangedDrivers() { diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index a901e420db..46e7fecf06 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -32,6 +32,27 @@ function LockLifecycle.device_added(driver, device) }) end +local function revert_migration(driver, device) + local legacy_lock_utils = require "legacy-handlers.legacy_lock_utils" + local json = require "st.json" + + local latest_users = table_utils.get_state(device, "users") or {} + local lock_codes = {} + for _, user in ipairs(latest_users) do + lock_codes[string.format("%s", user.userIndex)] = user.userName or ("Code " .. user.userIndex) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_codes), { visibility = { displayed = false } })) + device:set_field(legacy_lock_utils.LOCK_CODES, lock_codes, { persist = true }) + + local max_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) + if max_code_length then + device:emit_event(capabilities.lockCodes.codeLength(max_code_length, { visibility = { displayed = false } })) + end + + device:emit_event(capabilities.lockCodes.migrated(false, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, nil, { persist = true }) -- persist the un-migrated state to the datastore +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. @@ -39,9 +60,7 @@ 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(consts.DRIVER_STATE.SLGA_MIGRATED) == true then - -- ensure lockCodes capability state is reflected correctly for already migrated devices - device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + revert_migration(driver, device) 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. 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 6fae60df4f..5dc345531f 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 @@ -382,24 +382,25 @@ end -- init (LockLifecycle.init) -- ============================================================================ -test.register_coroutine_test( - "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", - function() - mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) +-- Note: Remove test since un-migration on init is the default behavior here, and that screws up this test +-- test.register_coroutine_test( +-- "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", +-- function() +-- mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) - test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) - test.socket.capability:__expect_send( - mock_device_base:generate_test_message("main", - capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) - ) - test.socket.capability:__expect_send( - mock_device_base:generate_test_message("main", - capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) - ) - test.wait_for_events() - end, - { test_init = make_test_init(mock_device_base) } -) +-- test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) +-- test.socket.capability:__expect_send( +-- mock_device_base:generate_test_message("main", +-- capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) +-- ) +-- test.socket.capability:__expect_send( +-- mock_device_base:generate_test_message("main", +-- capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) +-- ) +-- test.wait_for_events() +-- end, +-- { test_init = make_test_init(mock_device_base) } +-- ) test.register_coroutine_test( "init: device with lockCodes but SLGA_MIGRATED not set does nothing", 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 2f68f0cfb6..8d2bde8116 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 @@ -651,4 +651,136 @@ test.register_coroutine_test( end ) +-- ============================================================================ +-- revert Migration on init +-- ============================================================================ + +local st_utils = require "st.utils" +local json = require "st.json" + +-- Lightweight mock state so that table_utils functions work on mock_device +-- before the driver has lazily initialised the wrapped device. After the +-- driver processes its first message (wrapped_init), MockDevice.__index +-- delegates every field access to the real driver device, so the overrides +-- below are only ever active during the pre-init seeding phase. +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) + + -- get_state guards with device:supports_capability; always return true here. + rawset(mock_device, "supports_capability", function() return true end) + + -- get_state / get_max_entries use device:get_latest_state to read + -- capability attribute values from the state cache. + rawset(mock_device, "get_latest_state", + function(_, component_id, capability_id, attribute_name, default_value) + local key = mock_state_key(component_id, capability_id, attribute_name) + local value = mock_latest_state[key] + if value == nil then return default_value end + return value + end + ) + + -- add_entry / delete_entry / update_entry all call device:emit_event. + -- Forward to the capability socket so __expect_send checks pass, and + -- keep mock_latest_state in sync so that successive get_state calls + -- within the same seeding loop see the growing list. + rawset(mock_device, "emit_event", function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end) +end + +test.register_coroutine_test( + "Revert Migration on init, after added", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 5, userName = "Charlie", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 5, credentialIndex = 5, credentialType = "pin" }, + }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.capability:__set_channel_ordering("relaxed") + + 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 = 5, userName = "Charlie", 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 = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 5, credentialIndex = 5, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + + -- Reversion of Migration should be handled for the device on initialization + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({ + ["1"] = "Alice", ["2"] = "Bob", ["5"] = "Charlie" + }), { visibility = { displayed = false } }) + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.migrated(false, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == nil, "Device should not be marked as migrated") + local stored_codes = st_utils.deep_copy(mock_device:get_field("lockCodes")) + assert(stored_codes["1"] == "Alice") + assert(stored_codes["2"] == "Bob") + assert(stored_codes["5"] == "Charlie") + + -- ensure codeChanged now triggers correctly after altering a code + 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", ["2"] = "Bob", ["5"] = "Charlie"}), { visibility = { displayed = false } }))) + end, + { + test_init = function() + test.disable_startup_messages() + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.mock_device.add_test_device(mock_device) + end, + min_api_version = 17 + } +) + test.run_registered_tests()