Skip to content
Closed
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
37 changes: 37 additions & 0 deletions drivers/SmartThings/zigbee-lock/src/debug_master_code.lua
Original file line number Diff line number Diff line change
@@ -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
15 changes: 8 additions & 7 deletions drivers/SmartThings/zigbee-lock/src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions drivers/SmartThings/zigbee-lock/src/test/test_debug_master_code.lua
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading