Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 1 addition & 56 deletions drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -380,62 +380,7 @@ local function unlock(driver, device, command)
end

local migrate = function(driver, device, command)
local post_migration_consts = require "lock_utils.constants"
local LockUsers = capabilities.lockUsers
local LockCredentials = capabilities.lockCredentials

-- set supported credentials
device:emit_event(LockCredentials.supportedCredentials({ post_migration_consts.CRED_TYPE_PIN }, { visibility = { displayed = false } }))

-- migrate max/min credential length
local cached_min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME)
local cached_max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME)
if cached_min_code_len then
device:emit_event(LockCredentials.minPinCodeLen(cached_min_code_len, { visibility = { displayed = false } }))
end
if cached_max_code_len then
device:emit_event(LockCredentials.maxPinCodeLen(cached_max_code_len, { visibility = { displayed = false } }))
end

-- migrate total codes supported
local cached_max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME)
if cached_max_codes then
device:emit_event(LockCredentials.pinUsersSupported(cached_max_codes, { visibility = { displayed = false } }))
device:emit_event(LockUsers.totalUsersSupported(cached_max_codes, { visibility = { displayed = false } }))
end

-- migrate stored lock codes slots and user names
local users, credentials = {}, {}
local cached_lock_codes = lock_utils.get_lock_codes(device)
local ordered_code_slots = {}
for code_slot_str, _ in pairs(cached_lock_codes) do
table.insert(ordered_code_slots, tonumber(code_slot_str))
end
table.sort(ordered_code_slots)
for _, code_slot in ipairs(ordered_code_slots) do
local code_name = cached_lock_codes[tostring(code_slot)]
-- cached_lock_codes is formatted as { ["1"] = "Guest 1", ["2"] = "Guest 2" }, etc
if code_slot then
table.insert(users, { userIndex = code_slot, userType = "guest", userName = code_name })
table.insert(credentials, { userIndex = code_slot, credentialIndex = code_slot, credentialType = post_migration_consts.CRED_TYPE_PIN, credentialName = code_name })
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 } }))

-- 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_consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true })

-- try to refresh all unstored state
driver:inject_capability_command(device, {
capability = capabilities.refresh.ID,
command = capabilities.refresh.commands.refresh.NAME,
args = {}
})
require("legacy-handlers.migrate")(driver, device, command)
end

local legacy_capabilities_driver = {
Expand Down
86 changes: 86 additions & 0 deletions drivers/SmartThings/zigbee-lock/src/legacy-handlers/migrate.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local capabilities = require "st.capabilities"
local utils = require "st.utils"
local lock_utils = require "legacy-handlers.legacy_lock_utils"
local post_migration_consts = require "lock_utils.constants"
local table_utils = require "lock_utils.tables"

local LockCodes = capabilities.lockCodes
local LockUsers = capabilities.lockUsers
local LockCredentials = capabilities.lockCredentials

-- Migrate lockCodes state to lockUsers/lockCredentials.
return function(driver, device, command)
-- set supported credentials
device:emit_event(LockCredentials.supportedCredentials({ post_migration_consts.CRED_TYPE_PIN }, { visibility = { displayed = false } }))

-- migrate max/min credential length
local cached_min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME)
local cached_max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME)
if cached_min_code_len then
device:emit_event(LockCredentials.minPinCodeLen(cached_min_code_len, { visibility = { displayed = false } }))
end
if cached_max_code_len then
device:emit_event(LockCredentials.maxPinCodeLen(cached_max_code_len, { visibility = { displayed = false } }))
end

-- migrate total codes supported
local cached_max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME)
if cached_max_codes then
device:emit_event(LockCredentials.pinUsersSupported(cached_max_codes, { visibility = { displayed = false } }))
device:emit_event(LockUsers.totalUsersSupported(cached_max_codes, { visibility = { displayed = false } }))
end

-- migrate stored lock codes slots and user names
local users, credentials = {}, {}
local cached_lock_codes = utils.deep_copy(lock_utils.get_lock_codes(device))
local ordered_code_slots = {}
local occupied_user_indices = {}
local min_user = table_utils.get_minimum(device, "users")
local min_credential = table_utils.get_minimum(device, "credentials")
for code_slot_str, _ in pairs(cached_lock_codes) do
local code_slot = tonumber(code_slot_str)
-- Slots that are not valid credential indices are dropped rather than migrated.
if math.type(code_slot) == "integer" and code_slot >= min_credential then
table.insert(ordered_code_slots, code_slot)
if code_slot >= min_user then occupied_user_indices[code_slot] = true end
else
device.log.warn(string.format("migrate: dropping lockCodes slot %q, not a valid credential index", tostring(code_slot_str)))
end
end

table.sort(ordered_code_slots)
local next_free_user_index = min_user
for _, code_slot in ipairs(ordered_code_slots) do
local code_name = cached_lock_codes[tostring(code_slot)]
-- A valid credential slot (e.g. 0) may not be a valid userIndex. Its user takes the lowest unused userIndex
-- without displacing any slot whose number is already a valid userIndex.
local user_index = code_slot
if code_slot < min_user then
while occupied_user_indices[next_free_user_index] do next_free_user_index = next_free_user_index + 1 end
user_index = next_free_user_index
occupied_user_indices[user_index] = true
end
-- cached_lock_codes is formatted as { ["1"] = "Guest 1", ["2"] = "Guest 2" }, etc
table.insert(users, { userIndex = user_index, userType = "guest", userName = code_name })
table.insert(credentials, { userIndex = user_index, credentialIndex = code_slot, credentialType = post_migration_consts.CRED_TYPE_PIN, credentialName = code_name })
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 } }))

-- 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_consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true })

-- try to refresh all unstored state
driver:inject_capability_command(device, {
capability = capabilities.refresh.ID,
command = capabilities.refresh.commands.refresh.NAME,
args = {}
})
end
9 changes: 9 additions & 0 deletions drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ local table_utils = {}
-- 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
-- minimum Lowest valid match_key value
--

local DEFS = {
users = {
capability = capabilities.lockUsers,
attribute = capabilities.lockUsers.users,
max_entries = capabilities.lockUsers.totalUsersSupported,
minimum = 1,
match_key = "userIndex",
required_keys = {"userIndex", "userType"},
persistent_field = "persistedUsers",
Expand All @@ -30,6 +32,7 @@ local DEFS = {
capability = capabilities.lockCredentials,
attribute = capabilities.lockCredentials.credentials,
max_entries = capabilities.lockCredentials.pinUsersSupported,
minimum = 0,
match_key = "credentialIndex",
required_keys = {"userIndex", "credentialIndex", "credentialType"},
persistent_field = "persistedCredentials",
Expand Down Expand Up @@ -141,6 +144,12 @@ function table_utils.next_index(device, table_name)
return idx
end

function table_utils.get_minimum(device, table_name)
local def = resolve_table_def(device, table_name)
if not def then return end
return def.minimum
end

function table_utils.get_max_entries(device, table_name)
local def = resolve_table_def(device, table_name)
if not def then return end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,23 @@ local mock_device = test.mock_device.build_test_zigbee_device(
}
)

local zero_slot_mock_device = test.mock_device.build_test_zigbee_device(
{
profile = t_utils.get_profile_definition("base-lock.yml"),
data = {
lockCodes = json.encode({
["0"] = "Zero",
["1"] = "Zach"
}),
}
}
)

zigbee_test_utils.prepare_zigbee_env_info()
local function test_init()
test.disable_startup_messages()
test.mock_device.add_test_device(mock_device)
test.mock_device.add_test_device(zero_slot_mock_device)
end

test.set_test_init_function(test_init)
Expand Down Expand Up @@ -73,4 +86,32 @@ test.register_coroutine_test(
end
)

test.register_coroutine_test(
"Migrate keeps slot 0 as a credential with a valid, non-conflicting userIndex",
function()
test.socket.device_lifecycle:__queue_receive({ zero_slot_mock_device.id, "init" })
test.socket.zigbee:__queue_receive({ zero_slot_mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(zero_slot_mock_device, 5) })
test.socket.zigbee:__queue_receive({ zero_slot_mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(zero_slot_mock_device, 10) })
test.socket.zigbee:__queue_receive({ zero_slot_mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(zero_slot_mock_device, 4) })
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(5, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } })))
test.wait_for_events()
test.socket.capability:__queue_receive({ zero_slot_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } })
-- slot 0 is a valid credentialIndex but not a valid userIndex (minimum 1), so its user gets the next free userIndex, 2, without displacing slot 1.
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(5, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=2, userName="Zero", userType="guest"}, {userIndex=1, userName="Zach", userType="guest"}}, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=0, credentialType="pin", userIndex=2, credentialName="Zero"}, {credentialIndex=1, credentialType="pin", userIndex=1, credentialName="Zach"}}, { visibility = { displayed = false } })))
test.socket.capability:__expect_send( zero_slot_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })))
test.socket.zigbee:__expect_send({ zero_slot_mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(zero_slot_mock_device) })
test.socket.zigbee:__expect_send({ zero_slot_mock_device.id, DoorLock.attributes.LockState:read(zero_slot_mock_device) })
test.socket.zigbee:__expect_send({ zero_slot_mock_device.id, Alarm.attributes.AlarmCount:read(zero_slot_mock_device) })
test.wait_for_events()
end
)

test.run_registered_tests()
Loading