diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua index 5c06ec3f0a..dc1d31d78a 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -133,19 +133,27 @@ function CapabilityHandlers.add_credential(driver, device, command) return end - -- A userIndex of 0 means "auto-assign the next available slot". Credential index 0 is reserved for the master code, - -- so auto-assigned credentials start from 1. - 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", 1) or command.args.userIndex + -- addCredential has no credentialIndex argument, so its userIndex argument selects the credential slot. A value + -- of 0 means "auto-assign the next available slot". The user is always new, and takes the next available user slot. + local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + local user_index = tables.next_index(device, "users") - if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + local max_credentials = tables.get_max_entries(device, "credentials") + if #(tables.get_state(device, "credentials") or {}) >= max_credentials then lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) return end + -- a requested slot must not already hold a credential + if tables.find_entry(device, "credentials", cred_index) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.OCCUPIED) + return + end + -- Set busy state and attempt to set the PIN on-device. lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { userIndex = user_index, + userType = command.args.userType, credentialIndex = cred_index, credentialType = command.args.credentialType, credentialName = command.args.credentialName, @@ -153,7 +161,7 @@ function CapabilityHandlers.add_credential(driver, device, command) device:send(LockCluster.server.commands.SetPINCode(device, cred_index, UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, + command.args.userType == consts.MASTER.USER_TYPE and UserTypeEnum.MASTER_USER or UserTypeEnum.UNRESTRICTED, command.args.credentialData) ) end diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua index d9156b6a72..726a079a63 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -133,14 +133,13 @@ end -- Return the lowest integer, starting at the table's minimum, not yet used as the -- match_key in the named table. Used to auto-assign the next available slot for a new entry. --- An optional `start` overrides the table's minimum, e.g. to skip a reserved slot. -function table_utils.next_index(device, table_name, start) +function table_utils.next_index(device, table_name) local def = resolve_table_def(device, table_name) assert(def, string.format("table_helpers: unknown table %q", tostring(table_name))) local t = table_utils.get_state(device, def) or {} local occupied = {} for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end - local idx = start or def.minimum + local idx = def.minimum while occupied[idx] do idx = idx + 1 end return idx end diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua index 015c1ceaf9..7553c7b1c2 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -21,12 +21,24 @@ local ResponseStatus = DoorLock.types.DrlkPassFailStatus local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), }) +local mock_device_yale = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Yale", + model = "YRD256", + server_clusters = { DoorLock.ID }, + }, + }, +}) zigbee_test_utils.prepare_zigbee_env_info() -- ── helpers ──────────────────────────────────────────────────────────────── local function test_init() test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_yale) end test.set_test_init_function(test_init) @@ -73,14 +85,14 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) -- Expect SetPINCode to be sent to the lock test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), }) test.wait_for_events() @@ -94,7 +106,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) @@ -109,7 +121,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( - { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 0 }, { state_change = true, visibility = { displayed = false } } )) ) @@ -118,19 +130,46 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "addCredential: auto-assign (userIndex 0) skips credential slot 0, which is reserved for the master code", + "addCredential: creates the user with the userType given in the command", function() test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 0, "guest", "pin", "1234" } }, + args = { 0, "adminMember", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), }) test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "adminMember" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 0 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() end ) @@ -140,13 +179,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), }) test.wait_for_events() @@ -172,13 +211,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 2, "guest", "pin", "5678" } }, + args = { 0, "guest", "pin", "5678" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), }) test.wait_for_events() @@ -204,13 +243,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), }) test.wait_for_events() @@ -237,12 +276,12 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), }) test.wait_for_events() @@ -250,7 +289,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 2, "guest", "pin", "5678" } }, + args = { 0, "guest", "pin", "5678" } }, }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", @@ -706,11 +745,85 @@ test.register_coroutine_test( ) -- ============================================================================ --- Master codes: known master codes keep their type on update +-- Master codes: addCredential accepts the master userType; known master codes keep their type -- ============================================================================ local MASTER_USER = { userIndex = 1, userName = "Master Code", userType = "programmingUser" } +test.register_coroutine_test( + "addCredential: the master userType is sent to the lock as MASTER_USER", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 0, "programmingUser", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.MASTER_USER, "1234"), + }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: rejects a requested credential slot that already holds a master code", + function() + seed_users({ MASTER_USER }) + seed_credentials({ { userIndex = 1, credentialIndex = 4, credentialType = "pin", credentialName = "Master Code" } }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 4, "guest", "pin", "1234" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "occupied" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: a requested credential slot beyond pinUsersSupported is still sent to the lock", + function() + local max = mock_device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME, 20) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { max, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + max, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: a requested free credential slot is used, with the user in the next free user slot", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 5, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 5, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + end +) + test.register_coroutine_test( "updateCredential: a known master code is sent as MASTER_USER, based on the credential's stored user", function() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua index 2a11122a4f..ec39dc3f0b 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua @@ -243,13 +243,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin03" } }, + args = { 0, "guest", "pin", "pin03" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), }) test.wait_for_events() @@ -264,7 +264,7 @@ test.register_coroutine_test( { { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, - { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, + { userIndex = 3, credentialIndex = 0, credentialType = "pin", credentialName = "Guest 3" }, }, { visibility = { displayed = false } } )) @@ -283,7 +283,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( - { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 3 }, + { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 0 }, { state_change = true, visibility = { displayed = false } } )) ) @@ -299,13 +299,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin01" } }, + args = { 0, "guest", "pin", "pin01" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin01"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin01"), }) test.wait_for_events() @@ -333,13 +333,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 3, "guest", "pin", "pin03" } }, + args = { 0, "guest", "pin", "pin03" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), }) test.wait_for_events() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua index 709bac1a11..eb31471691 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -84,7 +84,7 @@ test.register_coroutine_test( ) ) ) - -- Users table receives the new entry +-- Users table receives the new entry test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users( @@ -93,7 +93,7 @@ test.register_coroutine_test( ) ) ) - test.wait_for_events() + test.wait_for_events() end ) @@ -377,12 +377,12 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", - args = { 1, "guest", "pin", "1234" } }, + args = { 0, "guest", "pin", "1234" } }, }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, - 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + 0, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), }) test.wait_for_events() @@ -394,7 +394,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( - { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { { userIndex = 1, credentialIndex = 0, credentialType = "pin", credentialName = "Guest 1" } }, { visibility = { displayed = false } } )) ) @@ -408,7 +408,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( - { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 0 }, { state_change = true, visibility = { displayed = false } } )) ) @@ -417,7 +417,7 @@ test.register_coroutine_test( -- Late ProgrammingEventNotification PIN_CODE_ADDED for the same slot arrives after busy ends. -- The credential is already in the table → find_entry returns it → handler exits early. -- Both user and credential already exist, so no events are emitted. - test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 0)) test.wait_for_events() end ) 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 6b091e2b5f..6b8bd0a6a9 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 @@ -574,9 +574,9 @@ test.register_coroutine_test( ) test.wait_for_events() - -- Re-add a credential for the new user at slot 2. The old credential at - -- credentialIndex 2 was cleanly removed, so add_entry must succeed without - -- returning OCCUPIED or any stale-state error. + -- Re-add a credential at the reclaimed slot 2. The old credential at credentialIndex 2 was cleanly + -- removed, so add_entry must succeed without returning OCCUPIED or any stale-state error. + -- addCredential always creates a new user, which takes the next free user index, 4. test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCredentials.ID, command = "addCredential", @@ -593,15 +593,25 @@ test.register_coroutine_test( mock_device.id, DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), }) - -- Credential is freshly added at index 2 alongside the retained entries at 1 and 3. - -- The user already exists, so the users table is unchanged. test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials( { { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, - { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + { userIndex = 4, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 4" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + { userIndex = 4, userName = "Guest 4", userType = "guest" }, }, { visibility = { displayed = false } } )) @@ -609,7 +619,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.commandResult( - { commandName = "addCredential", statusCode = "success", userIndex = 2, credentialIndex = 2 }, + { commandName = "addCredential", statusCode = "success", userIndex = 4, credentialIndex = 2 }, { state_change = true, visibility = { displayed = false } } )) )