Skip to content
Open
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
59 changes: 2 additions & 57 deletions drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 migrate_codes = require "legacy-handlers.migrate"

local init_handler = function(driver, device, event)
local constants = require "st.zwave.constants"
Expand Down Expand Up @@ -49,64 +50,8 @@ local function update_codes(driver, device, cmd)
end
end

--- @param driver st.zwave.Driver
--- @param device st.zwave.Device
--- @param cmd table
local function migrate(driver, device, cmd)
local LockCodesDefaults = require "st.zwave.defaults.lockCodes"
local table_utils = require "lock_utils.tables"
local get_lock_codes = LockCodesDefaults.get_lock_codes
local lock_users = {}
local lock_credentials = {}
local lock_codes = get_lock_codes(device)
local ordered_codes = {}

-- Slots that are not valid credential indices (e.g. 0, which means "all users" in Z-Wave, or non-numeric)
-- are dropped rather than migrated.
local min_credential = table_utils.get_minimum(device, "credentials")
for code_slot in pairs(lock_codes) do
local credential_index = tonumber(code_slot)
if math.type(credential_index) == "integer" and credential_index >= min_credential then
table.insert(ordered_codes, {slot = code_slot, index = credential_index})
else
device.log.warn(string.format("Dropping lock code slot %q during migration: not a valid credential index", tostring(code_slot)))
end
end

table.sort(ordered_codes, function(a, b) return a.index < b.index end)
for _, code in ipairs(ordered_codes) do
local user_index = #lock_users + 1
table.insert(lock_users, {userIndex = user_index, userType = "guest", userName = lock_codes[code.slot]})
table.insert(lock_credentials, {userIndex = user_index, credentialIndex = code.index, credentialType = "pin"})
end
device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } }))

local consts = require "lock_utils.constants"

-- lockCodes tracks a single fixed code length, which only some locks (e.g. Schlage) report.
-- Without one, fall back to the Z-Wave spec default range so the bounds are always set.
local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME)
local min_pin_length = code_length or consts.DEFAULT_MIN_PIN_LENGTH
local max_pin_length = code_length or consts.DEFAULT_MAX_PIN_LENGTH
device:emit_event(capabilities.lockCredentials.minPinCodeLen(min_pin_length, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockCredentials.maxPinCodeLen(max_pin_length, { visibility = { displayed = false } }))

local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME)
if max_codes then
device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } }))
end

device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))
device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore

driver:inject_capability_command(device, {
capability = capabilities.refresh.ID,
command = capabilities.refresh.commands.refresh.NAME,
args = {}
})
migrate_codes(driver, device)
end

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

local capabilities = require "st.capabilities"
local LockCodesDefaults = require "st.zwave.defaults.lockCodes"

local consts = require "lock_utils.constants"
local table_utils = require "lock_utils.tables"

-- Migrate the lock's lockCodes state to lockUsers and lockCredentials. This is shared by the lockCodes.migrate
-- handlers of the legacy sub-drivers, which pass the programming code slot for a lock that has one.
--- @param driver st.zwave.Driver
--- @param device st.zwave.Device
--- @param programming_code_slot number|nil the credentialIndex of the lock's programming code slot, or nil if it has none
local function migrate_codes(driver, device, programming_code_slot)
local get_lock_codes = LockCodesDefaults.get_lock_codes
local lock_users = {}
local lock_credentials = {}
local lock_codes = get_lock_codes(device)
local ordered_codes = {}

-- Slots that are not valid credential indices (e.g. 0, which means "all users" in Z-Wave, or non-numeric)
-- are dropped rather than migrated.
local min_credential = table_utils.get_minimum(device, "credentials")
for code_slot in pairs(lock_codes) do
local credential_index = tonumber(code_slot)
if math.type(credential_index) == "integer" and credential_index >= min_credential then
table.insert(ordered_codes, {slot = code_slot, index = credential_index})
else
device.log.warn(string.format("Dropping lock code slot %q during migration: not a valid credential index", tostring(code_slot)))
end
end

table.sort(ordered_codes, function(a, b) return a.index < b.index end)
-- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the programming code's
-- user takes the programming code slot as its index.
local user_index = 0
for _, code in ipairs(ordered_codes) do
local is_programming = programming_code_slot ~= nil and code.index == programming_code_slot
if not is_programming then
user_index = user_index + 1
if programming_code_slot ~= nil and user_index == programming_code_slot then user_index = user_index + 1 end
end
local owner_index = is_programming and code.index or user_index
table.insert(lock_users, {
userIndex = owner_index,
userType = is_programming and consts.PROGRAMMING_USER.USER_TYPE or "guest",
userName = lock_codes[code.slot],
})
table.insert(lock_credentials, {userIndex = owner_index, credentialIndex = code.index, credentialType = "pin"})
end
device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } }))

-- lockCodes tracks a single fixed code length, which only some locks (e.g. Schlage) report.
-- Without one, fall back to the Z-Wave spec default range so the bounds are always set.
local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME)
local min_pin_length = code_length or consts.DEFAULT_MIN_PIN_LENGTH
local max_pin_length = code_length or consts.DEFAULT_MAX_PIN_LENGTH
device:emit_event(capabilities.lockCredentials.minPinCodeLen(min_pin_length, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockCredentials.maxPinCodeLen(max_pin_length, { visibility = { displayed = false } }))

local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME)
if max_codes then
device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } }))
end

device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))
device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))

device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore

driver:inject_capability_command(device, {
capability = capabilities.refresh.ID,
command = capabilities.refresh.commands.refresh.NAME,
args = {}
})
end

return migrate_codes
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ local sub_drivers = {
lazy_load_if_possible("legacy-handlers.schlage-lock"),
lazy_load_if_possible("legacy-handlers.samsung-lock"),
lazy_load_if_possible("legacy-handlers.keywe-lock"),
lazy_load_if_possible("legacy-handlers.yale-lock"),
}
return sub_drivers
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

return function(opts, driver, device, ...)
local yale_programming_code = require "yale-lock.programming_code"
if device.zwave_manufacturer_id == yale_programming_code.MANUFACTURER_ID then
return true, require("legacy-handlers.yale-lock")
end
return false
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Copyright 2026 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local capabilities = require "st.capabilities"
local LockCodesDefaults = require "st.zwave.defaults.lockCodes"

local migrate_codes = require "legacy-handlers.migrate"
local yale_programming_code = require "yale-lock.programming_code"

-- Migrate a Yale lock, mapping its programming code slot to the programming code user.
local function yale_migrate(driver, device, cmd)
migrate_codes(driver, device, yale_programming_code.SLOT)

-- lockCodes only holds the programming code if SmartThings set it and the lock confirmed it as a user code. A factory
-- programming code, or one a Yale ZW3 confirmed with the "programming code changed" notification, is read back instead.
-- This is because the legacy handlers never read the programming code explicitly. Furthermore, if the lock was
-- running the newer ZW3 module, the legacy driver did not have the corresponding handling for saving
-- a programming code after a "Programming Code Changed" response.
if LockCodesDefaults.get_lock_codes(device)[tostring(yale_programming_code.SLOT)] == nil then
yale_programming_code.read_programming_code(device)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how this is going to interact with the sync logic, which is also doing the same get, but expecting particular data. I suppose it is getting caught at the yale-subdriver, but I wonder if this won't end up causing us to skip an index. I guess it probably won't because the sync doesn't start for 15s, but worth a callout I think

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a little extra handling to prevent us from skipping an index as you pointed out - commit a5b7914 covers this case!

end

local legacy_yale_lock = {
capability_handlers = {
[capabilities.lockCodes.ID] = {
[capabilities.lockCodes.commands.migrate.NAME] = yale_migrate
},
},
NAME = "Legacy Yale Lock",
can_handle = require("legacy-handlers.yale-lock.can_handle"),
}

return legacy_yale_lock
85 changes: 56 additions & 29 deletions drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ function CapabilityHandlers.add_user(driver, device, command)
return
end

-- programming users cannot be created through the capability
if command.args.userType == consts.PROGRAMMING_USER.USER_TYPE then
lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND)
return
end

-- Find the smallest positive userIndex not already in the table
local next_available_index = tables.next_index(device, "users")
local status = tables.add_entry(device, "users", {
Expand All @@ -42,20 +48,20 @@ function CapabilityHandlers.update_user(driver, device, command)
return
end

local result_status
if not tables.find_entry(device, "users", command.args.userIndex) then
result_status = tables.add_entry(device, "users", {
userIndex = command.args.userIndex,
userName = command.args.userName,
userType = command.args.userType,
})
else
result_status = tables.update_entry(device, "users",
command.args.userIndex,
{ userName = command.args.userName, userType = command.args.userType }
)
-- a user cannot be made a programming user, and a programming user cannot be changed to another type
local found_user = tables.find_entry(device, "users", command.args.userIndex)
local is_programming_user = found_user ~= nil and found_user.userType == consts.PROGRAMMING_USER.USER_TYPE
if (command.args.userType == consts.PROGRAMMING_USER.USER_TYPE) ~= is_programming_user then
lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND)
return
end

-- a user that does not exist is not created here, so this fails for it
local result_status = tables.update_entry(device, "users",
command.args.userIndex,
{ userName = command.args.userName, userType = command.args.userType }
)

local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex }
lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, result_status, additional_info)
end
Expand Down Expand Up @@ -120,16 +126,19 @@ function CapabilityHandlers.add_credential(driver, device, command)
-- userIndex 0 means "create a new user for this credential" (its index is chosen when the lock confirms the
-- code). Any other userIndex must name an existing user that does not already have a credential (for now,
-- each user may have at most one credential).
-- A programming user cannot be created or given a credential through the capability.
local user_index = command.args.userIndex
if user_index ~= 0 and (not tables.find_entry(device, "users", user_index)
if command.args.userType == consts.PROGRAMMING_USER.USER_TYPE
or user_index ~= 0 and (not tables.find_entry(device, "users", user_index)
or lock_utils.is_programming_user(device, user_index)
or tables.find_entry_by(device, "credentials", "userIndex", user_index)) then
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND)
return
end

local credentials_full = #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials")
local credentials_full = tables.is_full(device, "credentials")
-- if we are being asked to create a new user (index 0), this also needs a free user index.
local users_full = (user_index == 0) and #(tables.get_state(device, "users") or {}) >= tables.get_max_entries(device, "users")
local users_full = (user_index == 0) and tables.is_full(device, "users")
if credentials_full or users_full then
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED)
return
Expand Down Expand Up @@ -189,59 +198,77 @@ function CapabilityHandlers.update_credential(driver, device, command)
}))
end

-- The programming code cannot be deleted. Fail the command without sending anything to the lock.
local function refuse_programming_credential_delete(device, injected_by_delete_user)
device.log.warn("ignoring deleteCredential for a programming code, it cannot be deleted")
if injected_by_delete_user then
lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.FAILURE)
lock_utils.clear_busy_state(device)
end
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE)
end

function CapabilityHandlers.delete_credential(driver, device, command)
-- Index 0 is the Z-Wave "all users" wildcard; clearing everything must go through deleteAllCredentials.
if command.args.credentialIndex == 0 then
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.INVALID_COMMAND)
return
end

local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS)

if cmd_in_progress == consts.LOCK_USERS.DELETE then
local credential_index
if device:get_field(consts.DRIVER_STATE.COMMAND_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(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {}
device:send(UserCode:Set({
user_identifier = credential_args.credentialIndex,
user_id_status = UserCode.user_id_status.AVAILABLE
}))
if lock_utils.is_programming_user(device, credential_args.userIndex) then
refuse_programming_credential_delete(device, true)
return
end
credential_index = credential_args.credentialIndex
elseif lock_utils.is_device_busy(device) then
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY)
return
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, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE)
return
end
if lock_utils.is_programming_user(device, found_cred.userIndex) then
refuse_programming_credential_delete(device, false)
return
end
lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, {
credentialIndex = command.args.credentialIndex,
credentialType = command.args.credentialType,
userIndex = found_cred.userIndex,
})
device:send(UserCode:Set({
user_identifier = command.args.credentialIndex,
user_id_status = UserCode.user_id_status.AVAILABLE
}))
credential_index = command.args.credentialIndex
end

device:send(UserCode:Set({
user_identifier = credential_index,
user_id_status = UserCode.user_id_status.AVAILABLE
}))
end

function CapabilityHandlers.delete_all_credentials(driver, device, command)
local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS)

local send_clear_commands = function()
-- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot
-- User Identifier 0 with status AVAILABLE tells the lock to clear every user code slot. A lock with a
-- programming code (Yale) keeps it, so the programming entries are kept in state below.
device:send(UserCode:Set({
user_identifier = 0,
user_id_status = UserCode.user_id_status.AVAILABLE
}))
-- Delayed cleanup: ensure all tables are cleared after the lock has processed the command
device.thread:call_with_delay(4, function()
if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then
tables.delete_all_entries(device, "users")
lock_utils.delete_all_non_programming_entries(device, "users")
lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS)
end
tables.delete_all_entries(device, "credentials")
lock_utils.delete_all_non_programming_entries(device, "credentials")
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS)
lock_utils.clear_busy_state(device)
end)
Expand Down
Loading
Loading