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
7 changes: 7 additions & 0 deletions drivers/SmartThings/zwave-lock/src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ 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 })
end
if device:supports_capability(capabilities.tamperAlert) then
device:emit_event(capabilities.tamperAlert.tamper.clear())
end
Expand Down
21 changes: 17 additions & 4 deletions drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ end
local function migrate(driver, device, cmd)
local LockCodesDefaults = require "st.zwave.defaults.lockCodes"
local table_utils = require "lock_utils.tables"
local consts = require("lock_utils.constants")
local get_lock_codes = LockCodesDefaults.get_lock_codes
local lock_users = {}
local lock_credentials = {}
Expand All @@ -74,10 +75,23 @@ local function migrate(driver, device, cmd)
end

table.sort(ordered_codes, function(a, b) return a.index < b.index end)
local master_index = table_utils.master_credential_index(device)
-- userIndex is assigned sequentially from 1 so every user is valid and unique, except that the master code's
-- user takes the master code slot as its index.
local user_index = 0
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"})
local is_master = code.index == master_index
if not is_master then
user_index = user_index + 1
if user_index == master_index then user_index = user_index + 1 end
end
local owner_index = is_master and code.index or user_index
table.insert(lock_users, {
userIndex = owner_index,
userType = is_master and consts.ADMIN.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 } }))
Expand All @@ -99,7 +113,6 @@ local function migrate(driver, device, cmd)

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

device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore
end
Expand Down
76 changes: 55 additions & 21 deletions drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 })
local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 })


-- Seconds to wait for a delete to be confirmed before reading the slot back
local DELETE_CONFIRM_DELAY = 4.2

local CapabilityHandlers = {}


Expand Down Expand Up @@ -121,7 +124,15 @@ function CapabilityHandlers.add_credential(driver, device, command)
local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex
local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex

if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then
-- The master code is added with its own slot as the userIndex and the master code userType.
local is_master = lock_utils.is_master_credential(device, cred_index)
if is_master and command.args.userType ~= consts.ADMIN.USER_TYPE then
-- only a master code credential may be written to the master code slot
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.INVALID_COMMAND)
return
end

if not is_master and tables.is_full(device, "credentials") then
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED)
return
end
Expand All @@ -146,34 +157,56 @@ function CapabilityHandlers.update_credential(driver, device, command)
return
end

if not tables.find_entry(device, "credentials", command.args.credentialIndex) then
local credential_index = command.args.credentialIndex
if not tables.find_entry(device, "credentials", credential_index) then
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE)
return
elseif credential_index == 0 then
-- user identifier 0 addresses every user code on the lock, never a single credential
lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.INVALID_COMMAND)
return
end

lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, {
userIndex = command.args.userIndex,
credentialIndex = command.args.credentialIndex,
credentialIndex = credential_index,
credentialType = command.args.credentialType,
credentialName = command.args.credentialName,
})
device:send(UserCode:Set({
user_identifier = command.args.credentialIndex,
user_identifier = credential_index,
user_code = command.args.credentialData,
user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS
}))
end

-- Remove a credential from the lock. The busy state must already be set.
local function send_credential_delete(device, credential_index)
if credential_index == 0 then
-- user identifier 0 addresses every user code on the lock, so a stale entry is only removed locally
lock_utils.delete_credential_report_helper(device, credential_index)
else
device:send(UserCode:Set({
user_identifier = credential_index,
user_id_status = UserCode.user_id_status.AVAILABLE
}))
-- A lock that refuses the delete (e.g. a Yale master code) ignores the Set and sends nothing, so read the
-- slot back if the delete has not completed. The report tells us whether the code is still there.
device.thread:call_with_delay(DELETE_CONFIRM_DELAY, function()
if lock_utils.is_delete_in_progress(device, credential_index) then
device:send(UserCode:Get({ user_identifier = credential_index }))
end
end)
end
end

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

if cmd_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
}))
send_credential_delete(device, 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)
else
Expand All @@ -188,10 +221,7 @@ function CapabilityHandlers.delete_credential(driver, device, command)
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
}))
send_credential_delete(device, command.args.credentialIndex)
end
end

Expand All @@ -202,21 +232,25 @@ function CapabilityHandlers.delete_all_credentials(driver, device, command)
local credentials = tables.get_state(device, "credentials") or {}
local delay = 0
for _, credential in ipairs(credentials) do
device.thread:call_with_delay(delay, function()
device:send(UserCode:Set({
user_identifier = credential.credentialIndex,
user_id_status = UserCode.user_id_status.AVAILABLE
}))
end)
delay = delay + 2
-- Delete all does not affect the master code, and user identifier 0 would clear every user code.
if not lock_utils.is_master_credential(device, credential.credentialIndex) and
credential.credentialIndex ~= 0 then
device.thread:call_with_delay(delay, function()
device:send(UserCode:Set({
user_identifier = credential.credentialIndex,
user_id_status = UserCode.user_id_status.AVAILABLE
}))
end)
delay = delay + 2
end
end
-- Delayed cleanup: ensure all tables are cleared after individual responses complete
device.thread:call_with_delay(delay + 4, function()
if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then
tables.delete_all_entries(device, "users")
lock_utils.delete_all_non_master_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_master_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
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,23 @@ function ZwaveHandlers.user_code_report(driver, device, cmd)

if command_in_progress == consts.SYNC.CODES_FROM_LOCK then
if user_code_occupied then
-- If an entry already exists at this index, this will be a no-op.
tables.add_entry(device, "users", {
userIndex = credential_index,
userName = "Guest " .. credential_index,
userType = "guest",
})
tables.add_entry(device, "credentials", {
userIndex = credential_index,
credentialIndex = credential_index,
credentialType = consts.CRED_TYPE_PIN,
credentialName = "Guest " .. credential_index,
})
-- If a credential already exists at this index, this will be a no-op.
if not tables.find_entry(device, "credentials", credential_index) then
-- Prefer the matching userIndex, but never attach this credential to a different, existing user.
local user_index = tables.find_entry(device, "users", credential_index) and
tables.next_index(device, "users") or credential_index
tables.add_entry(device, "users", {
userIndex = user_index,
userName = "Guest " .. user_index,
userType = "guest",
})
tables.add_entry(device, "credentials", {
userIndex = user_index,
credentialIndex = credential_index,
credentialType = consts.CRED_TYPE_PIN,
credentialName = "Guest " .. user_index,
})
end
-- reset the consecutive unoccupied codes counter since we found an occupied code
device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0)
elseif user_code_available then
Expand All @@ -69,6 +74,9 @@ function ZwaveHandlers.user_code_report(driver, device, cmd)
device:send(UserCode:Get({user_identifier = synced_code_index}))
end
return
elseif user_code_occupied and lock_utils.is_delete_in_progress(device, credential_index) then
-- the code is still on the lock after a delete, so the lock refused it
lock_utils.emit_delete_failure(device)
elseif user_code_occupied then
-- Code slot is now occupied: add or update credential and associated user
lock_utils.set_credential_report_helper(device, credential_index)
Expand Down Expand Up @@ -108,8 +116,9 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd)
local access_control_event = Notification.event.access_control

if event == access_control_event.ALL_USER_CODES_DELETED then
tables.delete_all_entries(device, "credentials")
tables.delete_all_entries(device, "users")
-- Deleting all user codes does not affect the master code.
lock_utils.delete_all_non_master_entries(device, "credentials")
lock_utils.delete_all_non_master_entries(device, "users")

elseif event == access_control_event.SINGLE_USER_CODE_DELETED then
lock_utils.delete_credential_report_helper(device, credential_index)
Expand All @@ -118,8 +127,13 @@ function ZwaveHandlers.user_code_event_handler(driver, device, cmd)
lock_utils.set_credential_report_helper(device, credential_index)

elseif event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then
-- aka "master code" changed
if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then
-- aka "master code" changed. Locks that keep it in a user code slot (Yale) send this instead of
-- NEW_USER_CODE_ADDED when the slot is written, so it completes the command in progress.
local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {}
if command_in_progress == consts.LOCK_CREDENTIALS.ADD and
lock_utils.is_master_credential(device, credential_args.credentialIndex) then
lock_utils.set_credential_report_helper(device, credential_args.credentialIndex)
elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then
lock_utils.emit_command_result(device, capabilities.lockCredentials,
consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.SUCCESS)
lock_utils.clear_busy_state(device)
Expand Down
9 changes: 9 additions & 0 deletions drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ lock_constants.SYNC = {
CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes",
}

lock_constants.ADMIN = {
-- Yale locks keep their master code in this user code slot.
YALE_SLOT = 251,
YALE_MANUFACTURER_ID = 0x0129,
-- Stopgap userType for the master code until the lockUsers capability has one for it.
USER_TYPE = "controlOnlyMember",
NAME = "Master Code",
}

lock_constants.COMMAND_RESULT = {
SUCCESS = "success",
FAILURE = "failure",
Expand Down
52 changes: 44 additions & 8 deletions drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

local capabilities = require "st.capabilities"
local st_utils = require "st.utils"
local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT
local consts = require "lock_utils.constants"
local COMMAND_RESULT = consts.COMMAND_RESULT

local table_utils = {}

Expand Down Expand Up @@ -41,6 +42,14 @@ local DEFS = {
}
}

-- Returns the credentialIndex of the device's master code slot, or nil if the device does not have one.
-- Only Yale locks have one.
function table_utils.master_credential_index(device)
if device.zwave_manufacturer_id == consts.ADMIN.YALE_MANUFACTURER_ID then
return consts.ADMIN.YALE_SLOT
end
end

-- Resolve a table name to its definition. Logs an error and returns nil if unknown.
local function resolve_table_def(device, table_name)
local def = DEFS[table_name]
Expand Down Expand Up @@ -156,19 +165,39 @@ function table_utils.get_max_entries(device, table_name)
return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing
end

-- Return true if the named table has reached its max_entries limit. The master code credential, and the
-- user that owns it, are not one of the lock's user slots, so they do not count toward the limit.
function table_utils.is_full(device, table_name)
local def = resolve_table_def(device, table_name)
if not def then return true end
local master_index = table_utils.master_credential_index(device)
local master_cred = master_index and table_utils.find_entry(device, "credentials", master_index)
local count = 0
for _, entry in ipairs(table_utils.get_state(device, def) or {}) do
local is_master
if table_name == "credentials" then
is_master = master_index ~= nil and entry.credentialIndex == master_index
else
is_master = master_cred ~= nil and entry.userIndex == master_cred.userIndex
end
if not is_master then count = count + 1 end
end
return count >= table_utils.get_max_entries(device, table_name)
end

-- Add an entry to a named table. The entry must satisfy all required_keys for
-- that table. An entry whose match_key value already exists in the
-- table is skipped to prevent duplicates. If the table has a max_entries limit,
-- entries that exceed the limit are not added.
function table_utils.add_entry(device, table_name, entry)
-- entries that exceed the limit are not added, unless exempt_from_limit is true (used for the master code).
function table_utils.add_entry(device, table_name, entry, exempt_from_limit)
device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name)
local def = resolve_table_def(device, table_name)
if not def then return COMMAND_RESULT.FAILURE end
if not validate_entry(device, entry, def.required_keys) then return COMMAND_RESULT.FAILURE end
local t = table_utils.get_state(device, def)
if not t then return COMMAND_RESULT.FAILURE end

if #t >= table_utils.get_max_entries(device, table_name) then
if not exempt_from_limit and table_utils.is_full(device, table_name) then
device.log.warn(string.format(
"table_helpers: cannot add entry to %q, max entries reached", table_name
))
Expand Down Expand Up @@ -247,13 +276,20 @@ function table_utils.delete_entry(device, table_name, matcher)
return COMMAND_RESULT.FAILURE
end

-- Delete all entries from a table.
function table_utils.delete_all_entries(device, table_name)
-- Delete all entries from a table. If keep is given, entries for which keep(entry)
-- returns true are retained.
function table_utils.delete_all_entries(device, table_name, keep)
device.log.debug("table_helpers: attempting to delete all entries from " .. table_name)
local def = resolve_table_def(device, table_name)
if not def then return COMMAND_RESULT.FAILURE end
device:emit_event(def.attribute({}, {visibility = {displayed = false}}))
persist_table(device, def, {})
local kept = {}
if keep then
for _, entry in ipairs(table_utils.get_state(device, def) or {}) do
if keep(entry) then table.insert(kept, entry) end
end
end
device:emit_event(def.attribute(kept, {visibility = {displayed = false}}))
persist_table(device, def, kept)
return COMMAND_RESULT.SUCCESS
end

Expand Down
Loading
Loading