diff --git a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml b/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml index 159e6939f3..c9c98898c6 100644 --- a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index ce6894b868..06732d3480 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -28,6 +28,8 @@ local capabilities = require "st.capabilities" local Battery = capabilities.battery local Lock = capabilities.lock local LockCodes = capabilities.lockCodes +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers -- Enums local UserStatusEnum = LockCluster.types.DrlkUserStatus @@ -40,21 +42,16 @@ local lock_utils = require "lock_utils" local DELAY_LOCK_EVENT = "_delay_lock_event" local MAX_DELAY = 10 -local reload_all_codes = function(driver, device, command) - -- starts at first user code index then iterates through all lock codes as they come in - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MinPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) +local function lazy_load_if_possible(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + + -- version 9 will include the lazy loading functions + if version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) end - if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end - device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) - device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) end local refresh = function(driver, device, cmd) @@ -71,30 +68,6 @@ local refresh = function(driver, device, cmd) end end -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - - device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) - device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - - device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) - device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - -- Don't send a reload all codes if this is a part of migration - if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then - device.thread:call_with_delay(2, function(d) - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end) - else - device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) - end -end - local alarm_handler = function(driver, device, zb_mess) local ALARM_REPORT = { [0] = Lock.lock.unknown(), @@ -106,191 +79,6 @@ local alarm_handler = function(driver, device, zb_mess) end end -local get_pin_response_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {codeName = lock_utils.get_code_name(device, code_slot)} - if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then - -- Code slot is occupied - event.value = code_slot .. lock_utils.get_change_type(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - else - -- Code slot is unoccupied - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - -- Code has been deleted - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - else - -- Code is unset - event.value = code_slot .. " unset" - device:emit_event(event) - end - end - - code_slot = tonumber(code_slot) - if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then - -- the code we're checking has arrived - local last_slot = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - 1 - if (code_slot >= last_slot) then - device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) - device:set_field(lock_utils.CHECKING_CODE, nil) - else - local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 - device:set_field(lock_utils.CHECKING_CODE, checkingCode) - device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) - end - end -end - -local programming_event_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {} - if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - -- Master code changed - event.value = "0 set" - event.data = {codeName = "Master Code"} - device:emit_event(event) - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (zb_mess.body.zcl_body.user_id.value == 0xFF) then - -- All codes deleted - for cs, _ in pairs(lock_utils.get_lock_codes(device)) do - lock_utils.code_deleted(device, cs) - end - lock_utils.lock_codes_event(device, {}) - else - -- One code deleted - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - end - end - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then - -- Code added or changed - local change_type = lock_utils.get_change_type(device, code_slot) - local code_name = lock_utils.get_code_name(device, code_slot) - event.value = code_slot .. change_type - event.data = {codeName = code_name} - device:emit_event(event) - if (change_type == " set") then - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = code_name - lock_utils.lock_codes_event(device, lock_codes) - end - end -end - -local handle_max_codes = function(driver, device, value) - if value.value ~= 0 then - -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, - -- but it gave us a non-zero number of pin users, so we want to switch the profile - if not device:supports_capability_by_id(LockCodes.ID) then - device:try_update_metadata({profile = "base-lock"}) -- switch to a lock with codes - lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes - if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes - -- so we'll load them manually - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end - end - device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) - end -end - -local handle_max_code_length = function(driver, device, value) - device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) -end - -local handle_min_code_length = function(driver, device, value) - device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) -end - -local update_codes = function(driver, device, command) - local delay = 0 - -- args.codes is json - for name, code in pairs(command.args.codes) do - -- these seem to come in the format "code[slot#]: code" - local code_slot = tonumber(string.gsub(name, "code", ""), 10) - if (code_slot ~= nil) then - if (code ~= nil and (code ~= "0" and code ~= "")) then - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.SetPINCode(device, - code_slot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - code)) - end) - delay = delay + 2 - else - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) - end) - delay = delay + 2 - end - device.thread:call_with_delay(delay, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) - end) - delay = delay + 2 - end - end -end - -local delete_code = function(driver, device, command) - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) - device.thread:call_with_delay(2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) -end - -local request_code = function(driver, device, command) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) -end - -local set_code = function(driver, device, command) - if (command.args.codePIN == "") then - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {command.args.codeSlot, command.args.codeName} - }) - else - device:send(LockCluster.server.commands.SetPINCode(device, - command.args.codeSlot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - command.args.codePIN) - ) - if (command.args.codeName ~= nil) then - -- wait for confirmation from the lock to commit this to memory - -- Groovy driver has a lot more info passed here as a description string, may need to be investigated - local codeState = device:get_field(lock_utils.CODE_STATE) or {} - codeState["setName"..command.args.codeSlot] = command.args.codeName - device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) - end - - device.thread:call_with_delay(4, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) - end -end - -local name_slot = function(driver, device, command) - local code_slot = tostring(command.args.codeSlot) - local lock_codes = lock_utils.get_lock_codes(device) - if (lock_codes[code_slot] ~= nil) then - lock_codes[code_slot] = command.args.codeName - device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) - lock_utils.lock_codes_event(device, lock_codes) - end -end - local function device_added(driver, device) lock_utils.populate_state_from_data(device) @@ -331,69 +119,6 @@ local lock_state_handler = function(driver, device, value, zb_rx) end end -local lock_operation_event_handler = function(driver, device, zb_rx) - local event_code = zb_rx.body.zcl_body.operation_event_code.value - local source = zb_rx.body.zcl_body.operation_event_source.value - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" - } - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() - } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" - else - event.data.method = METHOD[source] - end - if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad - local code_id = zb_rx.body.zcl_body.user_id.value - local code_name = "Code "..code_id - local lock_codes = device:get_field("lockCodes") - if (lock_codes ~= nil and - lock_codes[code_id] ~= nil) then - code_name = lock_codes[code_id] - end - event.data = {method = METHOD[0], codeId = code_id .. "", codeName = code_name} - end - - -- if this is an event corresponding to a recently-received attribute report, we - -- want to set our delay timer for future lock attribute report events - if device:get_latest_state( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) - end - end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) - end -end - local function lock(driver, device, command) device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) end @@ -413,30 +138,14 @@ local zigbee_lock_driver = { [Alarm.ID] = { [Alarm.client.commands.Alarm.ID] = alarm_handler }, - [LockCluster.ID] = { - [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, - [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, - [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler - } }, attr = { [LockCluster.ID] = { [LockCluster.attributes.LockState.ID] = lock_state_handler, - [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, - [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, - [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes } } }, capability_handlers = { - [LockCodes.ID] = { - [LockCodes.commands.updateCodes.NAME] = update_codes, - [LockCodes.commands.deleteCode.NAME] = delete_code, - [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [LockCodes.commands.requestCode.NAME] = request_code, - [LockCodes.commands.setCode.NAME] = set_code, - [LockCodes.commands.nameSlot.NAME] = name_slot, - }, [Lock.ID] = { [Lock.commands.lock.NAME] = lock, [Lock.commands.unlock.NAME] = unlock, @@ -446,13 +155,10 @@ local zigbee_lock_driver = { } }, sub_drivers = { - require("samsungsds"), - require("yale"), - require("yale-fingerprint-lock"), - require("lock-without-codes") + lazy_load_if_possible("using-old-capabilities"), + lazy_load_if_possible("using-new-capabilities"), }, lifecycle_handlers = { - doConfigure = do_configure, added = device_added, init = init, }, diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils.lua index 0a36a9685e..901c29f030 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils.lua @@ -24,7 +24,7 @@ local lock_utils = { CODE_STATE = "codeState", MIGRATION_COMPLETE = "migrationComplete", MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped", - CHECKED_CODE_SUPPORT = "checkedCodeSupport" + CHECKED_CODE_SUPPORT = "checkedCodeSupport", } lock_utils.get_lock_codes = function(device) diff --git a/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua new file mode 100644 index 0000000000..e02487be90 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/new_lock_utils.lua @@ -0,0 +1,216 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +local utils = require "st.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers +local INITIAL_INDEX = 1 + +local new_lock_utils = { + -- Constants + ADD_CREDENTIAL = "addCredential", + ADD_USER = "addUser", + COMMAND_NAME = "commandName", + CREDENTIAL_TYPE = "pin", + CHECKING_CODE = "checkingCode", + DELETE_ALL_CREDENTIALS = "deleteAllCredentials", + DELETE_ALL_USERS = "deleteAllUsers", + DELETE_CREDENTIAL = "deleteCredential", + DELETE_USER = "deleteUser", + LOCK_CREDENTIALS = "lockCredentials", + LOCK_USERS = "lockUsers", + PENDING_CREDENTIAL = "pendingCredential", + STATUS_BUSY = "busy", + STATUS_DUPLICATE = "duplicate", + STATUS_FAILURE = "failure", + STATUS_INVALID_COMMAND = "invalidCommand", + STATUS_OCCUPIED = "occupied", + STATUS_RESOURCE_EXHAUSTED = "resourceExhausted", + STATUS_SUCCESS = "success", + UPDATE_CREDENTIAL = "updateCredential", + UPDATE_USER = "updateUser", + USER_INDEX = "userIndex", + USER_NAME = "userName", + USER_TYPE = "userType" +} + +new_lock_utils.get_users = function(device) + local users = device:get_field(new_lock_utils.LOCK_USERS) + return users ~= nil and users or {} +end + +new_lock_utils.get_user = function(device, user_index) + for _, user in ipairs(new_lock_utils.get_users(device)) do + if user.userIndex == user_index then + return user + end + end + + return nil +end + +new_lock_utils.get_available_user_index = function(current_data, max) + if current_data == nil and max ~= 0 then + return INITIAL_INDEX + elseif current_data ~= nil then + for index = 1, max do + if current_data["user" .. index] == nil then + return index + end + end + end + + return nil +end + +new_lock_utils.get_credentials = function(device) + local credentials = device:get_field(new_lock_utils.LOCK_CREDENTIALS) + return credentials ~= nil and credentials or {} +end + +new_lock_utils.get_credential = function(device, credential_index) + for _, credential in ipairs(new_lock_utils.get_credentials(device)) do + if credential.credentialIndex == credential_index then + return credential + end + end + return nil +end + +new_lock_utils.get_available_credential_index = function(current_data, max) + local available_index = nil + local used_index = {} + + for i, _ in ipairs(current_data) do + used_index[i] = true + end + + if current_data ~= {} then + for index = 1, max do + if used_index[index] == nil then + available_index = index + break + end + end + else + available_index = INITIAL_INDEX + end + + return available_index +end + +new_lock_utils.create_user = function(device, user_name, user_type, user_index) + local status_code = new_lock_utils.STATUS_SUCCESS + local max_users = device:get_latest_state("main", capabilities.lockUsers.ID, + capabilities.lockUsers.totalUsersSupported.NAME, 0) + local current_users = new_lock_utils.get_users(device) + local available_index = new_lock_utils.get_available_user_index(current_users, max_users) + + if max_users == 0 or available_index == nil then + -- Can't add any users - update commandResult statusCode + status_code = new_lock_utils.STATUS_RESOURCE_EXHAUSTED + else + -- use the passed in index if it's set + if user_index ~= nil then + available_index = user_index + end + current_users["user"..available_index] = { userIndex = available_index, userType = user_type, userName = user_name } + device:set_field(new_lock_utils.LOCK_USERS, current_users, { persist = true }) + end + + return status_code +end + +new_lock_utils.delete_user = function(device, user_index, deleted_by_credential_deletion) + local current_users = new_lock_utils.get_users(device) + local status_code = new_lock_utils.STATUS_FAILURE + + for index, user in pairs(current_users) do + if user.userIndex == user_index then + -- also delete associated credential if this isn't being call by a credential deletion. + if not deleted_by_credential_deletion then + -- find associated credential. + for _, credential in ipairs(new_lock_utils.get_credentials(device)) do + if credential.userIndex == user_index then + new_lock_utils.delete_credential(device, credential.credentialIndex, true) + break + end + end + end + -- table.remove(current_users, index) + current_users[index] = nil + device:set_field(new_lock_utils.LOCK_USERS, current_users) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + + return status_code +end + +new_lock_utils.add_credential = function(device, user_index, user_type, credential_type, credential_index) + -- need to also create a user if one does not exist at the user index. + if new_lock_utils.get_user(device, user_index) == nil then + local user_name = "USER_" .. user_index + local status = new_lock_utils.create_user(device, user_name, user_type, user_index) + if status ~= new_lock_utils.STATUS_SUCCESS then + return status + end + end + + local credentials = new_lock_utils.get_credentials(device) + table.insert(credentials, + { userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type }) + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + return new_lock_utils.STATUS_SUCCESS +end + +new_lock_utils.delete_credential = function(device, credential_index, deleted_by_user_deletion) + local credentials = new_lock_utils.get_credentials(device) + local status_code = new_lock_utils.STATUS_FAILURE + + for index, credential in pairs(credentials) do + if credential.credentialIndex == credential_index then + -- also delete associated user if this isn't being called by a user deletion. + if not deleted_by_user_deletion then + new_lock_utils.delete_user(device, credential.userIndex, true) + end + table.remove(credentials, index) + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + + return status_code +end + +new_lock_utils.update_credential = function(device, credential_index, user_index, credential_type) + local credentials = new_lock_utils.get_credentials(device) + local status_code = new_lock_utils.STATUS_FAILURE + + for _, credential in ipairs(credentials) do + if credential.credentialIndex == credential_index then + credential.credentialType = credential_type + credential.userIndex = user_index + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + return status_code +end + +return new_lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua new file mode 100644 index 0000000000..d5d970c010 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_slga_migration.lua @@ -0,0 +1,83 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local PowerConfiguration = clusters.PowerConfiguration +local DoorLock = clusters.DoorLock +local Alarm = clusters.Alarms +local capabilities = require "st.capabilities" + +local json = require "st.json" + +local mock_datastore = require "integration_test.mock_env_datastore" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + data = { + lockCodes = json.encode({ + ["1"] = "Zach", + ["5"] = "Steven" + }), + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init()end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + 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.wait_for_events() + -- Validate lockCodes field + mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["5"] = "Steven" }) + -- Validate migration complete flag + mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) + + -- Set min/max code length attributes + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 5) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 10) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate `migrate` command functionality. + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua new file mode 100644 index 0000000000..f01e497565 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_new_capabilities.lua @@ -0,0 +1,575 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local capabilities = require "st.capabilities" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType + +local test_credential_index = 1 +local test_credentials = {} +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init_new_capabilities() + test_credential_index = 1 + test_credentials = {} + test.mock_device.add_test_device(mock_device) +end + +local function init_migration() + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report( + mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report( + mock_device, 8) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.maxCodeLength(8, { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported + :build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.maxPinCodeLen(8, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() +end + +local function add_default_users() + local user_list = {} + for i = 1, 4 do + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser" .. i, "guest" } + }, + }) + -- add to the user list that is now expected + user_list["user"..i] = {userIndex = i, userType = "guest", userName = "TestUser" .. i } + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + user_list, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +end + +local function add_credential(user_index, credential_data) +test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { user_index, "guest", "pin", credential_data } + }, + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + test_credential_index, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + credential_data + ) + } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, test_credential_index) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + test_credential_index, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + credential_data + ) + } + ) + table.insert(test_credentials, { userIndex = tonumber(user_index), credentialIndex = test_credential_index, credentialType = "pin" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials(test_credentials, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test_credential_index = test_credential_index + 1 +end + +test.set_test_init_function(test_init_new_capabilities) + +test.register_coroutine_test( + "Add User command received and commandResult is success until totalUsersSupported reached", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial max users + add_default_users() + + -- 5th addUser call - totalUsersSupported is passsed and now commandResult should be resourceExhausted + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Update User command reports a commandResult of success unless user index doesn't exist", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial users + add_default_users() + + -- success + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = { "2", "ChangeUserName", "guest" } + }, + }) + + local users = {} + users["user" .. 1] = { userIndex = 1, userName = "TestUser1", userType = "guest" } + users["user" .. 2] = { userIndex = 2, userName = "ChangeUserName", userType = "guest" } + users["user" .. 3] = { userIndex = 3, userName = "TestUser3", userType = "guest" } + users["user" .. 4] = { userIndex = 4, userName = "TestUser4", userType = "guest" } + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(users, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + + -- failure - try updating non existent userIndex + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = { "6", "ChangeUserName", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Delete User command reports a commandResult of success unless user index doesn't exist", + function() + -- make sure we have migrated and are using the new capabilities + init_migration() + -- create initial users + add_default_users() + + -- success + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { "3" } + }, + }) + + local users = {} + users["user" .. 1] = { userIndex = 1, userName = "TestUser1", userType = "guest" } + users["user" .. 2] = { userIndex = 2, userName = "TestUser2", userType = "guest" } + users["user" .. 4] = { userIndex = 4, userName = "TestUser4", userType = "guest" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users(users, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + + -- failure - try updating non existent userIndex + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { "3" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + + +test.register_coroutine_test( + "addCredential command received and commandResult is success", + function() + init_migration() + add_credential("2", "abc123") + end +) + +test.register_coroutine_test( + "updateCredential command received and commandResult is success", + function() + init_migration() + add_credential("2", "abc123") + + -- try to update the wrong credentialIndex first and expect a failure + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "2", "4", "pin", "abc123" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- try to update the right credential + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { "2", "1", "pin", "changedPin123" } + }, + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "changedPin123" + ) + } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "abc123" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { + { userIndex = 2, credentialIndex = 1, credentialType = "pin" } + }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential command received and commandResult is success", + function() + init_migration() + add_credential("2", "abc123") + add_credential("1", "test123") + add_credential("3", "321test") + + -- try to delete credential with wrong index and expect a failure + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { "4", "pin" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- try to delete credential with correct index + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { "1", "pin"} + }, + }) + 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.ClearPINCode(mock_device, 1) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + "" + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" } + }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials command received and commandResult is success", + function() + init_migration() + add_credential("2", "abc123") + add_credential("1", "test123") + add_credential("3", "321test") + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {} + }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) + }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id,DoorLock.server.commands.GetPINCode(mock_device, 1) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + "" + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" } + }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua index 2255c063a3..ddd89ff598 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua @@ -27,7 +27,18 @@ 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_new_capabilities() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(8, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(0, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) +end test.register_message_test( "Max user code number report should be handled", @@ -43,7 +54,26 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(30)) } - } + }, + {test_init = test_init } +) + +test.register_message_test( + "Max user code number report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(30)) + } + }, + {test_init = test_init_new_capabilities } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua new file mode 100644 index 0000000000..17eeab86ee --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/init.lua @@ -0,0 +1,494 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Zigbee Driver utilities +local defaults = require "st.zigbee.defaults" +local device_management = require "st.zigbee.device_management" +local ZigbeeDriver = require "st.zigbee" + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +-- Capabilities +local capabilities = require "st.capabilities" +local Battery = capabilities.battery +local Lock = capabilities.lock +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode + +local socket = require "cosock.socket" +local lock_utils = require "new_lock_utils" + +local DELAY_LOCK_EVENT = "_delay_lock_event" +local MAX_DELAY = 10 + +local reload_all_codes = function(device) + -- starts at first user code index then iterates through all lock codes as they come in + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + end + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then + device:set_field(lock_utils.CHECKING_CODE, 1) + end + + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) +end + +local init = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + -- device.thread:call_with_delay(2, function(d) + -- reload_all_codes(device) + -- end) +end + +local add_user_handler = function(driver, device, command) + local user_name = command.args.userName + local user_type = command.args.userType + local status = lock_utils.create_user(device, user_name, user_type, nil) + local command_result_info = { + commandName = lock_utils.ADD_USER, + statusCode = status + } + + if status == lock_utils.STATUS_SUCCESS then + local current_users = lock_utils.get_users(device) + device:emit_event(capabilities.lockUsers.users(current_users, { visibility = { displayed = false } })) + end + + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) +end + +local update_user_handler = function(driver, device, command) + local user_name = command.args.userName + local user_type = command.args.userType + local user_index = tonumber(command.args.userIndex) + local current_users = lock_utils.get_users(device) + + local command_result_info = { + commandName = lock_utils.UPDATE_USER, + statusCode = lock_utils.STATUS_FAILURE + } + + for _, user in pairs(current_users) do + if user.userIndex == user_index then + -- update user info and the commandResult + user.userName = user_name + user.userType = user_type + device:set_field(lock_utils.LOCK_USERS, current_users, { persist = true }) + device:emit_event(capabilities.lockUsers.users(current_users, { visibility = { displayed = false } })) + command_result_info.statusCode = lock_utils.STATUS_SUCCESS + break + end + end + + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) +end + +local delete_user_handler = function(driver, device, command) + local user_index = tonumber(command.args.userIndex) + local status_code = lock_utils.delete_user(device, user_index, false) + local command_result_info = { + commandName = lock_utils.DELETE_USER, + statusCode = status_code + } + + if status_code == lock_utils.STATUS_SUCCESS then + local current_users = lock_utils.get_users(device) + device:emit_event(capabilities.lockUsers.users(current_users, { visibility = { displayed = false } })) + end + + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) +end + +local delete_all_users_handler = function(driver, device, command) + local current_users = lock_utils.get_users(device) + local command_result_info = { + commandName = lock_utils.DELETE_ALL_USERS, + statusCode = lock_utils.STATUS_SUCCESS + } + current_users = {} -- clear the table + device:set_field(lock_utils.LOCK_USERS, current_users, { persist = true }) + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) +end + +local add_credential_handler = function(driver, device, command) + device:set_field(lock_utils.COMMAND_NAME, lock_utils.ADD_CREDENTIAL) + local user_index = tonumber(command.args.userIndex) + local user_type = command.args.userType + local credential_type = command.args.credentialType + local credential_data = command.args.credentialData + local credential_index = nil + local status_code = lock_utils.STATUS_SUCCESS + local max_credentials = device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME, 0) + + credential_index = lock_utils.get_available_credential_index(lock_utils.get_credentials(device), max_credentials) + if credential_index ~= nil then + device:set_field(lock_utils.PENDING_CREDENTIAL, + { userIndex = user_index, userType = user_type, credentialType = credential_type }) + else + status_code = lock_utils.STATUS_RESOURCE_EXHAUSTED + end + if status_code == lock_utils.STATUS_SUCCESS then + -- set the pin code and then validate it was successful when the GetPINCode response is received. + -- the credential creation and events will also be handled in that response. + device:send(LockCluster.server.commands.SetPINCode(device, + credential_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + credential_data) + ) + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + else + local command_result_info = { + commandName = lock_utils.ADD_CREDENTIAL, + statusCode = status_code + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) + device:set_field(lock_utils.COMMAND_NAME, nil) + end +end + +local update_credential_handler = function(driver, device, command) + device:set_field(lock_utils.COMMAND_NAME, lock_utils.UPDATE_CREDENTIAL) + local credential_index = tonumber(command.args.credentialIndex) + local credential_data = command.args.credentialData + + if lock_utils.get_credential(device, credential_index) ~= nil then + device:send(LockCluster.server.commands.SetPINCode(device, + credential_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + credential_data) + ) + device.thread:call_with_delay(4, function() + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + else + local command_result_info = { + commandName = lock_utils.UPDATE_CREDENTIAL, + statusCode = lock_utils.STATUS_FAILURE + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) + end +end + +local delete_credential_handler = function(driver, device, command) + local credential_index = tonumber(command.args.credentialIndex) + if lock_utils.get_credential(device, credential_index) ~= nil then + device:set_field(lock_utils.COMMAND_NAME, lock_utils.DELETE_CREDENTIAL) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + else + local command_result_info = { + commandName = lock_utils.DELETE_CREDENTIAL, + statusCode = lock_utils.STATUS_FAILURE + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) + device:set_field(lock_utils.COMMAND_NAME, nil) + end +end + +local delete_all_credentials_handler = function(driver, device, command) + device:set_field(lock_utils.COMMAND_NAME, lock_utils.DELETE_ALL_CREDENTIALS) + local credentials = lock_utils.get_credentials(device) + local delay = 0 + for _, credential in ipairs(credentials) do + local credential_index = tonumber(credential.credentialIndex) + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.ClearPINCode(device, credential_index)) + end) + device.thread:call_with_delay(delay + 2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, credential_index)) + end) + delay = delay + 2 + end +end + +local max_code_length_handler = function(driver, device, value) + device:emit_event(LockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +local min_code_length_handler = function(driver, device, value) + device:emit_event(LockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +local max_codes_handler = function(driver, device, value) + device:emit_event(LockCredentials.pinUsersSupported(value.value, { visibility = { displayed = false } })) +end + +local get_pin_response_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + local pending_credential = device:get_field(lock_utils.PENDING_CREDENTIAL) + local command = device:get_field(lock_utils.COMMAND_NAME) + local command_result_info = { + commandName = command, + statusCode = lock_utils.STATUS_SUCCESS + } + + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then + if command == lock_utils.ADD_CREDENTIAL then + -- create credential + lock_utils.add_credential(device, pending_credential.userIndex, + pending_credential.userType, + pending_credential.credentialType, + credential_index) + elseif command == lock_utils.UPDATE_CREDENTIAL then + -- update credential + local credential = lock_utils.get_credential(device, credential_index) + if credential ~= nil then + lock_utils.update_credential(device, credential.credentialIndex, credential.userIndex, credential.credentialType) + end + else + -- something + end + + local credentials = lock_utils.get_credentials(device) + device:emit_event(capabilities.lockCredentials.credentials(credentials, { visibility = { displayed = false } })) + else + if lock_utils.get_credential(device, credential_index) ~= nil then + -- Credential has been deleted. + lock_utils.delete_credential(device, credential_index, false) + device:emit_event(capabilities.lockCredentials.credentials(lock_utils.get_credentials(device), + { visibility = { displayed = false } })) + else + -- unset + end + end + + if (credential_index == device:get_field(lock_utils.CHECKING_CODE)) then + -- the credential we're checking has arrived + local last_slot = device:get_latest_state("main", capabilities.lockCredentials.ID, + capabilities.lockCredentials.pinUsersSupported.NAME) + if (credential_index >= last_slot) then + device:set_field(lock_utils.CHECKING_CODE, nil) + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end + + if command ~= nil then + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, { state_change = true, visibility = { displayed = false } } + )) + device:set_field(lock_utils.COMMAND_NAME, nil) + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local credential_index = tonumber(zb_mess.body.zcl_body.user_id.value) + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code changed + -- todo? + -- event updateCredential + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (zb_mess.body.zcl_body.user_id.value == 0xFFFF) then + -- All credentials deleted + for _, credential in pairs(lock_utils.get_credentials(device)) do + lock_utils.delete_credential(device, credential.credentialIndex, false) + end + device:emit_event(capabilities.lockCredentials.credentials(lock_utils.get_credentials(device), + { visibility = { displayed = false } })) + else + -- One credential deleted + if (lock_utils.get_credential(device, credential_index) ~= nil) then + lock_utils.delete_credential(device, credential_index, false) + device:emit_event(capabilities.lockCredentials.credentials(lock_utils.get_credentials(device), + { visibility = { displayed = false } })) + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then + if lock_utils.get_credential(device, credential_index) == nil then + local max_users = device:get_latest_state("main", capabilities.lockUsers.ID, + capabilities.lockUsers.totalUsersSupported.NAME, 0) + + local user_index = lock_utils.get_available_user_index(lock_utils.get_users(device), max_users) + lock_utils.add_credential(device, user_index, + "guest", + lock_utils.CREDENTIAL_TYPE, + credential_index) + device:emit_event(capabilities.lockCredentials.credentials(lock_utils.get_credentials(device), + { visibility = { displayed = false } })) + end + end +end + +local lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockUsers.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local user = lock_utils.get_user(device, code_id) + if user ~= nil then + code_name = user.userName + end + + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + if device:get_latest_state( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + +local new_capabilities_driver = { + NAME = "Lock Driver Using New Capabilities", + supported_capabilities = { + Lock, + LockCredentials, + LockUsers, + Battery, + }, + zigbee_handlers = { + cluster = { + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler, + } + }, + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.MaxPINCodeLength.ID] = max_code_length_handler, + [LockCluster.attributes.MinPINCodeLength.ID] = min_code_length_handler, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = max_codes_handler, + } + } + }, + capability_handlers = { + [LockUsers.ID] = { + [LockUsers.commands.addUser.NAME] = add_user_handler, + [LockUsers.commands.updateUser.NAME] = update_user_handler, + [LockUsers.commands.deleteUser.NAME] = delete_user_handler, + [LockUsers.commands.deleteAllUsers.NAME] = delete_all_users_handler, + }, + [LockCredentials.ID] = { + [LockCredentials.commands.addCredential.NAME] = add_credential_handler, + [LockCredentials.commands.updateCredential.NAME] = update_credential_handler, + [LockCredentials.commands.deleteCredential.NAME] = delete_credential_handler, + [LockCredentials.commands.deleteAllCredentials.NAME] = delete_all_credentials_handler, + }, + }, + sub_drivers = { + require("using-new-capabilities.samsungsds"), + require("using-new-capabilities.yale-fingerprint-lock"), + -- require("using-new-capabilities.yale"), + require("using-new-capabilities.lock-without-codes") + }, + health_check = false, + lifecycle_handlers = { + init = init + }, + can_handle = function(opts, driver, device, ...) + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if lock_codes_migrated then + local subdriver = require("using-new-capabilities") + return true, subdriver + end + return false + end +} + +return new_capabilities_driver diff --git a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-new-capabilities/lock-without-codes/init.lua diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua new file mode 100644 index 0000000000..b42f3b0f14 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/samsungsds/init.lua @@ -0,0 +1,117 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local device_management = require "st.zigbee.device_management" +local clusters = require "st.zigbee.zcl.clusters" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local PowerConfiguration = clusters.PowerConfiguration +local DoorLock = clusters.DoorLock +local Lock = capabilities.lock + +local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F +local SAMSUNG_SDS_MFR_CODE = 0x0003 + +local function handle_lock_state(driver, device, value, zb_rx) + if value.value == DoorLock.attributes.LockState.LOCKED then + device:emit_event(Lock.lock.locked()) + elseif value.value == DoorLock.attributes.LockState.UNLOCKED then + device:emit_event(Lock.lock.unlocked()) + end +end + +local function mfg_lock_door_handler(driver, device, zb_rx) + local cmd = zb_rx.body.zcl_body.body_bytes:byte(1) + if cmd == 0x00 then + device:emit_event(Lock.lock.unlocked()) + end +end + +local function unlock_cmd_handler(driver, device, command) + device:send(cluster_base.build_manufacturer_specific_command( + device, + DoorLock.ID, + SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND, + SAMSUNG_SDS_MFR_CODE, + "\x10\x04\x31\x32\x33\x35")) +end + +local function lock_cmd_handler(driver, device, command) + -- do nothing in lock command handler +end + +local refresh = function(driver, device, cmd) + -- do nothing in refresh capability handler +end + +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +local device_added = function(self, device) + emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) + device:emit_event(capabilities.battery.battery(100)) +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, DoorLock.ID, self.environment_info.hub_zigbee_eui)) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0)) +end + +local battery_init = battery_defaults.build_linear_voltage_init(4.0, 6.0) + +local device_init = function(driver, device, event) + battery_init(driver, device, event) + device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) + device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) +end + +local samsung_sds_driver = { + NAME = "SAMSUNG SDS Lock Driver", + zigbee_handlers = { + cluster = { + [DoorLock.ID] = { + [SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND] = mfg_lock_door_handler + } + }, + attr = { + [DoorLock.ID] = { + [DoorLock.attributes.LockState.ID] = handle_lock_state + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh + }, + [capabilities.lock.ID] = { + [capabilities.lock.commands.unlock.NAME] = unlock_cmd_handler, + [capabilities.lock.commands.lock.NAME] = lock_cmd_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure, + added = device_added, + init = device_init + }, + can_handle = function(opts, driver, device, ...) + return device:get_manufacturer() == "SAMSUNG SDS" + end +} + +return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua new file mode 100644 index 0000000000..b336cb1bc5 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale-fingerprint-lock/init.lua @@ -0,0 +1,54 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local LockCluster = clusters.DoorLock +local LockCredentials = capabilities.lockCredentials + +local YALE_FINGERPRINT_MAX_CODES = 0x1E + +local YALE_FINGERPRINT_LOCK = { + { mfr = "ASSA ABLOY iRevo", model = "iZBModule01" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "0700000001" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +local yale_fingerprint_lock_models = function(opts, driver, device) + for _, fingerprint in ipairs(YALE_FINGERPRINT_LOCK) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local handle_max_codes = function(driver, device, value) + device:emit_event(LockCredentials.maxPinCodeLen(YALE_FINGERPRINT_MAX_CODES)) +end + +local yale_fingerprint_lock_driver = { + NAME = "YALE Fingerprint Lock", + zigbee_handlers = { + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes + } + } + }, + can_handle = yale_fingerprint_lock_models +} + +return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua new file mode 100644 index 0000000000..d00f9ed73f --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/init.lua @@ -0,0 +1,109 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local LockCluster = clusters.DoorLock + +-- Capabilities +local capabilities = require "st.capabilities" +local LockCredentials = capabilities.lockCredentials + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType + +local lock_utils = (require "lock_utils") + +local get_pin_response_handler = function(driver, device, zb_mess) + local event = LockCredentials.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) + if localCode ~= nil then localCode = localCode["setCode"..code_slot] end + local code_name = lock_utils.get_code_name(device, code_slot) + local lock_codes = lock_utils.get_lock_codes(device) + event.data = {codeName = code_name} + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then + -- Code slot is occupied + if (localCode ~= nil) then + local serverCode = zb_mess.body.zcl_body.code.value + if (localCode == serverCode) then + event.value = code_slot .. lock_utils.get_change_type(device, code_slot) + device:emit_event(event) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + else + event.value = code_slot .. " failed" + event.data = nil + device:emit_event(event) + end + lock_utils.reset_code_state(device, code_slot) + else + event.value = code_slot .. lock_utils.get_change_type(device, code_slot) + device:emit_event(event) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + end + else + -- Code slot is unoccupied + if (localCode ~= nil) then + -- Code slot found empty during creation of a user code + event.value = code_slot .. " failed" + event.data = nil + device:emit_event(event) + event.value = code_slot .. " is not set" + device:emit_event(event) + lock_utils.reset_code_state(device, code_slot) + elseif (lock_codes[code_slot] ~= nil) then + -- Code has been deleted + lock_utils.lock_codes_event(device, (lock_utils.code_deleted(device, code_slot))) + else + -- Code is unset + event.value = code_slot .. " unset" + device:emit_event(event) + end + end + + code_slot = tonumber(code_slot) + if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then + -- the code we're checking has arrived + local defaultMaxCodes = 8 + if (code_slot >= defaultMaxCodes) then + device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) + device:set_field(lock_utils.CHECKING_CODE, nil) + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end +end + +local yale_door_lock_driver = { + NAME = "Yale Door Lock", + zigbee_handlers = { + cluster = { + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + } + } + }, + + sub_drivers = { require("using-new-capabilities.yale.yale-bad-battery-reporter") }, + can_handle = function(opts, driver, device, ...) + return device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" + end +} + +return yale_door_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-new-capabilities/yale/yale-bad-battery-reporter/init.lua diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua new file mode 100644 index 0000000000..a235b469d7 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/init.lua @@ -0,0 +1,426 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Zigbee Driver utilities +local defaults = require "st.zigbee.defaults" +local device_management = require "st.zigbee.device_management" +local ZigbeeDriver = require "st.zigbee" + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +-- Capabilities +local capabilities = require "st.capabilities" +local Battery = capabilities.battery +local Lock = capabilities.lock +local LockCodes = capabilities.lockCodes +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode + +local socket = require "cosock.socket" +local lock_utils = require "lock_utils" + +local DELAY_LOCK_EVENT = "_delay_lock_event" +local MAX_DELAY = 10 + +local reload_all_codes = function(driver, device, command) + -- starts at first user code index then iterates through all lock codes as they come in + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + end + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end + device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) +end + +local get_pin_response_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = { codeName = lock_utils.get_code_name(device, code_slot) } + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then + -- Code slot is occupied + event.value = code_slot .. lock_utils.get_change_type(device, code_slot) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + else + -- Code slot is unoccupied + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + -- Code has been deleted + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + else + -- Code is unset + event.value = code_slot .. " unset" + device:emit_event(event) + end + end + + code_slot = tonumber(code_slot) + if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then + -- the code we're checking has arrived + local last_slot = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - 1 + if (code_slot >= last_slot) then + device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) + device:set_field(lock_utils.CHECKING_CODE, nil) + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = {} + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code changed + event.value = "0 set" + event.data = { codeName = "Master Code" } + device:emit_event(event) + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (zb_mess.body.zcl_body.user_id.value == 0xFF) then + -- All codes deleted + for cs, _ in pairs(lock_utils.get_lock_codes(device)) do + lock_utils.code_deleted(device, cs) + end + lock_utils.lock_codes_event(device, {}) + else + -- One code deleted + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then + -- Code added or changed + local change_type = lock_utils.get_change_type(device, code_slot) + local code_name = lock_utils.get_code_name(device, code_slot) + event.value = code_slot .. change_type + event.data = { codeName = code_name } + device:emit_event(event) + if (change_type == " set") then + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + end + end +end + +local handle_max_codes = function(driver, device, value) + if value.value ~= 0 then + -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, + -- but it gave us a non-zero number of pin users, so we want to switch the profile + if not device:supports_capability_by_id(LockCodes.ID) then + device:try_update_metadata({ profile = "base-lock" }) -- switch to a lock with codes + lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes + if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes + -- so we'll load them manually + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end + end + device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) + end +end + +local handle_max_code_length = function(driver, device, value) + device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) +end + +local handle_min_code_length = function(driver, device, value) + device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) +end + +local lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local lock_codes = device:get_field("lockCodes") + if (lock_codes ~= nil and + lock_codes[code_id] ~= nil) then + code_name = lock_codes[code_id] + end + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + if device:get_latest_state( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + +local update_codes = function(driver, device, command) + local delay = 0 + -- args.codes is json + for name, code in pairs(command.args.codes) do + -- these seem to come in the format "code[slot#]: code" + local code_slot = tonumber(string.gsub(name, "code", ""), 10) + if (code_slot ~= nil) then + if (code ~= nil and (code ~= "0" and code ~= "")) then + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.SetPINCode(device, + code_slot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + code)) + end) + delay = delay + 2 + else + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) + end) + delay = delay + 2 + end + device.thread:call_with_delay(delay, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) + end) + delay = delay + 2 + end + end +end + +local delete_code = function(driver, device, command) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) +end + +local request_code = function(driver, device, command) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) +end + +local set_code = function(driver, device, command) + if (command.args.codePIN == "") then + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = { command.args.codeSlot, command.args.codeName } + }) + else + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.codeSlot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.codePIN) + ) + if (command.args.codeName ~= nil) then + -- wait for confirmation from the lock to commit this to memory + -- Groovy driver has a lot more info passed here as a description string, may need to be investigated + local codeState = device:get_field(lock_utils.CODE_STATE) or {} + codeState["setName" .. command.args.codeSlot] = command.args.codeName + device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) + end + + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) + end +end + +local name_slot = function(driver, device, command) + local code_slot = tostring(command.args.codeSlot) + local lock_codes = lock_utils.get_lock_codes(device) + if (lock_codes[code_slot] ~= nil) then + lock_codes[code_slot] = command.args.codeName + device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) + lock_utils.lock_codes_event(device, lock_codes) + end +end + +local migrate = function(driver, device, command) + local lock_users = {} + local lock_credentials = {} + local lock_codes = lock_utils.get_lock_codes(device) + local ordered_codes = {} + + for code in pairs(lock_codes) do + table.insert(ordered_codes, code) + end + + table.sort(ordered_codes) + for index, code_slot in ipairs(ordered_codes) do + table.insert(lock_users, { userIndex = index, userType = "guest", userName = lock_codes[code_slot] }) + table.insert(lock_credentials, { userIndex = index, credentialIndex = tonumber(code_slot), credentialType = "pin" }) + end + + local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.minCodeLength.NAME, 4) + local max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.maxCodeLength.NAME, 8) + local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME, 0) + if (code_length ~= nil) then + max_code_len = code_length + min_code_len = code_length + end + + device:emit_event(LockCredentials.minPinCodeLen(min_code_len, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.maxPinCodeLen(max_code_len, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + device:emit_event(LockUsers.users(lock_users, { visibility = { displayed = false } })) + device:emit_event(LockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + -- Don't send a reload all codes if this is a part of migration + if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then + device.thread:call_with_delay(2, function(d) + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end) + else + device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) + end +end + +local old_capabilities_driver = { + NAME = "Lock Driver Using Old Capabilities", + supported_capabilities = { + Lock, + LockCodes, + Battery, + }, + zigbee_handlers = { + cluster = { + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + } + }, + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, + [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes, + } + } + }, + capability_handlers = { + [LockCodes.ID] = { + [LockCodes.commands.updateCodes.NAME] = update_codes, + [LockCodes.commands.deleteCode.NAME] = delete_code, + [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [LockCodes.commands.requestCode.NAME] = request_code, + [LockCodes.commands.setCode.NAME] = set_code, + [LockCodes.commands.nameSlot.NAME] = name_slot, + [LockCodes.commands.migrate.NAME] = migrate, + }, + }, + sub_drivers = { + require("using-old-capabilities.samsungsds"), + require("using-old-capabilities.yale"), + require("using-old-capabilities.yale-fingerprint-lock"), + require("using-old-capabilities.lock-without-codes") + }, + health_check = false, + lifecycle_handlers = { + doConfigure = do_configure + }, + can_handle = function(opts, driver, device, ...) + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if not lock_codes_migrated then + local subdriver = require("using-old-capabilities") + return true, subdriver + end + return false + end +} + +return old_capabilities_driver diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua new file mode 100644 index 0000000000..7272991459 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/lock-without-codes/init.lua @@ -0,0 +1,101 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local configurationMap = require "configurations" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local DoorLock = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +local LOCK_WITHOUT_CODES_FINGERPRINTS = { + { model = "E261-KR0B0Z0-HA" }, + { mfr = "Danalock", model = "V3-BTZB" } +} + +local function can_handle_lock_without_codes(opts, driver, device) + for _, fingerprint in ipairs(LOCK_WITHOUT_CODES_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function device_init(driver, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end + end +end + +local function handle_lock(driver, device, cmd) + device:send(DoorLock.commands.LockDoor(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) +end + +local function handle_unlock(driver, device, cmd) + device:send(DoorLock.commands.UnlockDoor(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) +end + +local function do_refresh(driver, device) + device:refresh() +end + +local function do_configure(driver, device) + device:configure() +end + +local function handle_lock_door(driver, device, zb_rx) + local function query_device() + device:send(DoorLock.attributes.LockState:read(device)) + end + device.thread:call_with_delay(5, query_device) +end + +local lock_without_codes = { + NAME = "Zigbee Lock Without Codes", + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure + }, + capability_handlers = { + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = handle_lock, + [capabilities.lock.commands.unlock.NAME] = handle_unlock + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zigbee_handlers = { + cluster = { + [DoorLock.ID] = { + [DoorLock.commands.LockDoorResponse.ID] = handle_lock_door, + [DoorLock.commands.UnlockDoorResponse.ID] = handle_lock_door, + } + }, + attr = { + [DoorLock.ID] = { + [DoorLock.attributes.NumberOfPINUsersSupported.ID] = function() end -- just to make sure we don't switch profiles + } + } + }, + can_handle = can_handle_lock_without_codes +} + +return lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/samsungsds/init.lua diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua similarity index 100% rename from drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale-fingerprint-lock/init.lua diff --git a/drivers/SmartThings/zigbee-lock/src/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua similarity index 98% rename from drivers/SmartThings/zigbee-lock/src/yale/init.lua rename to drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua index 73e984036e..a38e6be361 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/init.lua @@ -152,7 +152,7 @@ local yale_door_lock_driver = { } }, - sub_drivers = { require("yale.yale-bad-battery-reporter") }, + sub_drivers = { require("using-old-capabilities.yale.yale-bad-battery-reporter") }, can_handle = function(opts, driver, device, ...) return device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" end diff --git a/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua new file mode 100644 index 0000000000..59fdbf228b --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/using-old-capabilities/yale/yale-bad-battery-reporter/init.lua @@ -0,0 +1,52 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local BAD_YALE_LOCK_FINGERPRINTS = { + { mfr = "Yale", model = "YRD220/240 TSDB" }, + { mfr = "Yale", model = "YRL220 TS LL" }, + { mfr = "Yale", model = "YRD210 PB DB" }, + { mfr = "Yale", model = "YRL210 PB LL" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +local is_bad_yale_lock_models = function(opts, driver, device) + for _, fingerprint in ipairs(BAD_YALE_LOCK_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local battery_report_handler = function(driver, device, value) + device:emit_event(capabilities.battery.battery(value.value)) +end + +local bad_yale_driver = { + NAME = "YALE BAD Lock Driver", + zigbee_handlers = { + attr = { + [clusters.PowerConfiguration.ID] = { + [clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_report_handler + } + } + }, + can_handle = is_bad_yale_lock_models +} + +return bad_yale_driver diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml index 5fbfb13f3d..e3a25ab57a 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: tamperAlert diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml index f4957f9ad0..efb97c8b27 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: refresh diff --git a/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index b83b196256..653f99e3f3 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -19,134 +19,28 @@ local cc = require "st.zwave.CommandClass" local ZwaveDriver = require "st.zwave.driver" --- @type st.zwave.defaults local defaults = require "st.zwave.defaults" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) ---- @type st.zwave.CommandClass.UserCode -local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) ---- @type st.zwave.CommandClass.Battery -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.CommandClass.Time -local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) -local constants = require "st.zwave.constants" -local utils = require "st.utils" -local json = require "st.json" -local SCAN_CODES_CHECK_INTERVAL = 30 -local MIGRATION_COMPLETE = "migrationComplete" -local MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped" - -local function periodic_codes_state_verification(driver, device) - local scan_codes_state = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME) - if scan_codes_state == "Scanning" then - driver:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - } - ) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(driver, device) - end - ) - end -end - -local function populate_state_from_data(device) - if device.data.lockCodes ~= nil and device:get_field(MIGRATION_COMPLETE) ~= true then - -- build the lockCodes table - local lockCodes = {} - local lc_data = json.decode(device.data.lockCodes) - for k, v in pairs(lc_data) do - lockCodes[k] = v - end - -- Populate the devices `lockCodes` field - device:set_field(constants.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) - -- Populate the devices state history cache - device.state_cache["main"] = device.state_cache["main"] or {} - device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} - device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} - - device:set_field(MIGRATION_COMPLETE, true, { persist = true }) - end -end - ---- Builds up initial state for the device ---- ---- @param self st.zwave.Driver ---- @param device st.zwave.Device -local function added_handler(self, device) - populate_state_from_data(device) - if device.data.lockCodes == nil or device:get_field(MIGRATION_RELOAD_SKIPPED) == true then - if (device:supports_capability(capabilities.lockCodes)) then - self:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} }) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(self, device) - end - ) - end +local lazy_load_if_possible = function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) else - device:set_field(MIGRATION_RELOAD_SKIPPED, true, { persist = true }) + return require(sub_driver_name) end - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) - if (device:supports_capability(capabilities.tamperAlert)) then - device:emit_event(capabilities.tamperAlert.tamper.clear()) - end -end - -local init_handler = function(driver, device, event) - populate_state_from_data(device) - -- temp fix before this can be changed from being persisted in memory - device:set_field(constants.CODE_STATE, nil, { persist = true }) end local do_refresh = function(self, device) + local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) + local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) device:send(DoorLock:OperationGet({})) device:send(Battery:Get({})) end ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @param cmd table -local function update_codes(driver, device, cmd) - local delay = 0 - -- args.codes is json - for name, code in pairs(cmd.args.codes) do - -- these seem to come in the format "code[slot#]: code" - local code_slot = tonumber(string.gsub(name, "code", ""), 10) - if (code_slot ~= nil) then - if (code ~= nil and (code ~= "0" and code ~= "")) then - -- code changed - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({ - user_identifier = code_slot, - user_code = code, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) - end) - delay = delay + 2.2 - else - -- code deleted - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) - end) - delay = delay + 2.2 - device.thread:call_with_delay(delay, function () - device:send(UserCode:Get({user_identifier = code_slot})) - end) - delay = delay + 2.2 - end - end - end -end - local function time_get_handler(driver, device, cmd) + local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) local time = os.date("*t") device:send_to_component( Time:Report({ @@ -162,32 +56,24 @@ local driver_template = { supported_capabilities = { capabilities.lock, capabilities.lockCodes, + capabilities.lockUsers, + capabilities.lockCredentials, capabilities.battery, capabilities.tamperAlert }, - lifecycle_handlers = { - added = added_handler, - init = init_handler, - }, capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes - }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, zwave_handlers = { [cc.TIME] = { - [Time.GET] = time_get_handler -- used by DanaLock + [0x01] = time_get_handler -- used by DanaLock } }, sub_drivers = { - require("zwave-alarm-v1-lock"), - require("schlage-lock"), - require("samsung-lock"), - require("keywe-lock"), - require("apiv6_bugfix"), + require("using-new-capabilities"), + require("using-old-capabilities"), } } diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua index 52144295b3..3ee38c115b 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua @@ -43,10 +43,10 @@ local zwave_lock_endpoints = { } local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints - } + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } ) local function test_init() @@ -459,7 +459,7 @@ test.register_coroutine_test( Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, - event_parameter = "" + event_parameter = "\x01" }) } ) diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua deleted file mode 100644 index e4a9b50758..0000000000 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua +++ /dev/null @@ -1,246 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Mock out globals -local test = require "integration_test" -local capabilities = require "st.capabilities" -local zw = require "st.zwave" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) ---- @type st.zwave.CommandClass.Battery -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.CommandClass.UserCode -local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) -local t_utils = require "integration_test.utils" -local zw_test_utils = require "integration_test.zwave_test_utils" -local utils = require "st.utils" - -local mock_datastore = require "integration_test.mock_env_datastore" - -local json = require "dkjson" - -local zwave_lock_endpoints = { - { - command_classes = { - { value = zw.BATTERY }, - { value = zw.DOOR_LOCK }, - { value = zw.USER_CODE }, - { value = zw.NOTIFICATION } - } - } -} - -local lockCodes = { - ["1"] = "Zach", - ["2"] = "Steven" -} - -local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints, - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } -) - -local mock_device_no_data = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - data = {} - } -) - -local expect_reload_all_codes_messages = function(dev, lc) - test.socket.capability:__expect_send(dev:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode(lc), { visibility = { displayed = false } }) - )) - test.socket.zwave:__expect_send( UserCode:UsersNumberGet({}):build_test_tx(dev.id) ) - test.socket.capability:__expect_send(dev:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(dev.id) ) -end - -test.register_coroutine_test( - "Device added data lock codes population", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device added without data should function", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - expect_reload_all_codes_messages(mock_device_no_data,{}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - end -) - -test.register_coroutine_test( - "Device init after added shouldn't change the datastores", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device init after added with no data should update the datastores", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - -- This should happen as the data is empty at this point - expect_reload_all_codes_messages(mock_device_no_data, {}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - test.socket.device_lifecycle():__queue_receive(mock_device_no_data:generate_info_changed( - { - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } - )) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", true) - end -) - - -test.register_coroutine_test( - "Device added data lock codes population, should not reload all codes", - function() - test.timer.__create_and_queue_test_time_advance_timer(31, "oneshot") - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.wait_for_events() - test.mock_time.advance_time(35) - -- Nothing should happen - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_slga_migration.lua new file mode 100644 index 0000000000..d446289fc0 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_slga_migration.lua @@ -0,0 +1,143 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) +local t_utils = require "integration_test.utils" +local zw_test_utils = require "integration_test.zwave_test_utils" +local utils = require "st.utils" +local json = require "dkjson" +--- @type st.zwave.constants +local constants = require "st.zwave.constants" + +local SCHLAGE_MANUFACTURER_ID = 0x003B +local SCHLAGE_PRODUCT_TYPE = 0x0002 +local SCHLAGE_PRODUCT_ID = 0x0469 + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION } + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints, + } +) + +local schlage_mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = SCHLAGE_MANUFACTURER_ID, + zwave_product_type = SCHLAGE_PRODUCT_TYPE, + zwave_product_id = SCHLAGE_PRODUCT_ID, + } +) + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local function init_code_slot(slot_number, name, device) + local lock_codes = device.persistent_store[constants.LOCK_CODES] + if lock_codes == nil then + lock_codes = {} + device.persistent_store[constants.LOCK_CODES] = lock_codes + end + lock_codes[tostring(slot_number)] = name +end + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(schlage_mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + init_code_slot(1, "Zach", mock_device) + init_code_slot(5, "Steven", mock_device) + -- setup codes + test.socket.zwave:__queue_receive({mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + end +) + +test.register_coroutine_test( + "Migrate new device", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(8, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + end +) + +test.register_coroutine_test( + "Schlage-Lock device called 'migrate' command, validate codeLength is being properly set", + function() + init_code_slot(1, "Zach", schlage_mock_device) + init_code_slot(5, "Steven", schlage_mock_device) + -- setup codes + test.socket.zwave:__queue_receive({schlage_mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.zwave:__queue_receive({schlage_mock_device.id, Configuration:Report({ parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, configuration_value = 6 })}) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ schlage_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_new_capabilities.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_new_capabilities.lua new file mode 100644 index 0000000000..6edacbb690 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_new_capabilities.lua @@ -0,0 +1,982 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local json = require "dkjson" +--- @type st.zwave.constants +local constants = require "st.zwave.constants" +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +--- @type st.zwave.CommandClass.Alarm +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +local t_utils = require "integration_test.utils" +local zw_test_utils = require "integration_test.zwave_test_utils" + +-- supported comand classes +local zwave_lock_endpoints = { + { + command_classes = { + {value = zw.BATTERY}, + {value = zw.DOOR_LOCK}, + {value = zw.USER_CODE}, + {value = zw.NOTIFICATION} + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } +) + +-- start with a migrated blank device +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(8, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(8, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) +end + +test.set_test_init_function(test_init) + + +test.register_coroutine_test( + "Add user should succeed", + function () + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 1", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 2", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }, {userIndex = 2, userType = "guest", userName = "TestUser 2" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Add credential should succeed", + function() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 1, "guest", "pin", "1234" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "Code 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 2, "guest", "pin", "3456" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + {userIndex = 1, userType = "guest", userName = "Code 1" }, + {userIndex = 2, userType = "guest", userName = "Code 2" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 2, + user_code = "3456", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" } + }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Add credential for existing user should succeed", + function () + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 1", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 1, "guest", "pin", "1234" } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Update user should succeed", + function () + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 1", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = { "TestUser 2", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "TestUser 1" }, {userIndex = 2, userType = "guest", userName = "TestUser 2" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = {1, "new name", "guest" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "new name" }, {userIndex = 2, userType = "guest", userName = "TestUser 2" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Delete user should succeed", + function() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 1, "guest", "pin", "1234" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "Code 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = { 1 } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + test.timer.__create_and_queue_test_time_advance_timer(4.2, "oneshot") + test.wait_for_events() + + test.mock_time.advance_time(4.2) + test.socket.zwave:__expect_send(UserCode:Get( {user_identifier = 1}):build_test_tx(mock_device.id)) + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Update credential should succeed", + function() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 1, "guest", "pin", "1234" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "Code 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = { 1, 1, "pin", "3456" } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "3456", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Delete credential should succeed", + function() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 1, "guest", "pin", "1234" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "Code 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = { 1, "pin" } + }, + }) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Delete all users should succeed", + function() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 1, "guest", "pin", "1234" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "Code 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 2, "guest", "pin", "3456" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + {userIndex = 1, userType = "guest", userName = "Code 1" }, + {userIndex = 2, userType = "guest", userName = "Code 2" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 2, + user_code = "3456", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" } + }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteAllUsers", + args = {} + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(0.5, "oneshot") + test.mock_time.advance_time(0) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.mock_time.advance_time(0.5) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 2, + user_id_status = UserCode.user_id_status.AVAILABLE + }):build_test_tx(mock_device.id) + ) + end +) + +test.register_coroutine_test( + "The lock reporting unlock via code should include the code number", + function() + test.socket.capability:__queue_receive({mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = { 1, "guest", "pin", "1234" } + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest", userName = "Code 1" }}, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = 1, + user_code = "1234", + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive({mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({{ userIndex = 1, credentialIndex = 1, credentialType = "pin" }}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + test.socket.zwave:__queue_receive( + { + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, + event_parameter = "\x01" + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", userIndex = 1 } }) + ) + ) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/using-new-capabilities/init.lua b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/init.lua new file mode 100644 index 0000000000..fc171b493a --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/init.lua @@ -0,0 +1,532 @@ +local capabilities = require "st.capabilities" +local LockUsers = capabilities.lockUsers +local LockCredentials = capabilities.lockCredentials +local lock_utils = require "using-new-capabilities.new_lock_utils" +local utils = require "st.utils" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control + +-- Helper methods + +local add_or_update = function(device, method, credential_index, user_index) + -- if so, add the credential to the list + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + credentials[credential_index] = { userIndex = user_index, credentialIndex = credential_index, credentialType = "pin"} + -- emit credential event + device:emit_event(LockCredentials.credentials(credentials, { visibility = { displayed = false } })) + -- emit command success + device:emit_event(LockCredentials.commandResult( + { commandName = method, statusCode = lock_utils.STATUS_SUCCESS, credentialIndex = credential_index}, { state_change = true, visibility = { displayed = false } } + )) + -- set the ongoing operation field to nil + device:set_field(method..credential_index, nil) +end + +-- returns the index of the lowest unset index less than the max +local next_empty_index = function(table, max) + local index = 1 + for i = 1, max + 1 do + if table[i] == nil then + index = i + break + end + end + return index +end + +-- Lifecycle handlers +local added_handler = function(driver, device) + -- read user/credential metadata + -- reload all codes +end + +-- Lock Users commands + +local add_user_handler = function(driver, device, cmd) + local user_name = cmd.args.userName + local user_type = cmd.args.userType + -- get the table of current users + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + -- check that we can add a new user + local max_users = device:get_latest_state("main", LockUsers.ID, LockUsers.totalUsersSupported.NAME, 8) + if utils.table_size(users) == max_users then + -- we cannot create a new user (unlikely!) + end + -- find the index to add the user at + local index = next_empty_index(users, max_users) + -- insert the user into the table + users[index] = {userIndex = index, userName = user_name, userType = user_type} + -- emit the users table event + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- emit the command result event + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.ADD_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = index }, { state_change = true, visibility = { displayed = false } } + )) +end + +local update_user_handler = function(driver, device, cmd) + local index = cmd.args.userIndex + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + -- does the user index already exist? + -- if not, update the user (offset user index by 1) + if users[index] ~= nil then + -- insert the user into the table + users[index] = {userIndex = index, userName = cmd.args.userName, userType = cmd.args.userType} + -- emit the users table event + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- emit the command result event + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.UPDATE_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = index }, { state_change = true, visibility = { displayed = false } } + )) + end +end + +local delete_user_handler = function(driver, device, cmd) + local index = cmd.args.userIndex + -- make sure the user exists + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + if users[index] ~= nil then + -- see if the user is associated with a lock code + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + for _, credential in pairs(credentials) do + if credential.userIndex == index then + -- if so, delete that code + device:send(UserCode:Set({user_identifier = credential.credentialIndex, user_id_status = UserCode.user_id_status.AVAILABLE})) + -- save state for receipt of delete + device:set_field("_delete_credential"..credential.credentialIndex, index) + -- make sure delete went through + device.thread:call_with_delay(4.2, function(d) device:send(UserCode:Get({user_identifier = credential.credentialIndex})) end) + return -- if the user has a credential, we need confirmation that the code was deleted before proceeding + end + end + -- delete user from the list + users[index] = nil + -- emit users event + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- emit user delete success + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.DELETE_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = index }, { state_change = true, visibility = { displayed = false } } + )) + end +end + +local delete_all_users_handler = function(driver, device, cmd) + -- TODO: Z-Wave User Code v2 includes mass sets/gets that could be leveraged to make this simpler + -- delete every user + -- send users event + device:emit_event(LockUsers.users({}, { visibility = { displayed = false}})) + -- send success event + device:emit_event(LockUsers.commandResult({ commandName = lock_utils.DELETE_ALL_USERS, statusCode = lock_utils.STATUS_SUCCESS }, { state_change = true, visibility = { displayed = false}})) + + + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + -- delete every credential + local delay = 0 + for _, credential in pairs(credentials) do + device.thread:call_with_delay(delay, function(d) + device:send(UserCode:Set({user_identifier = credential.credentialIndex, user_id_status = UserCode.user_id_status.AVAILABLE})) + end) + -- include a delay between deletes + delay = delay + .5 + end + -- send credentials event + device:emit_event(LockCredentials.credentials({}, { visibility = { displayed = false}})) + -- send success event (this would be tedious to check for every code, so assume they all went through) + device:emit_event(LockCredentials.commandResult({ commandName = lock_utils.DELETE_ALL_CREDENTIALS, statusCode = lock_utils.STATUS_SUCCESS }, { state_change = true, visibility = { displayed = false}})) +end + +--- Lock Credentials Commands + +local add_credential_handler = function(driver, device, cmd) + local index = cmd.args.userIndex + local user_type = cmd.args.userType + local credential_type = cmd.args.credentialType -- if this is not "pin", send an error + local data = cmd.args.credentialData + -- get the table of current credentials + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + -- does the user index already exist? + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + -- if not, create a new user (offset user index by 1) + if users[index] == nil then + -- insert the user into the table + users[index] = {userIndex = index, userName = "Code "..index, userType = user_type} + -- emit the users table event + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- emit the command result event + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.ADD_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = index }, { state_change = true, visibility = { displayed = false } } + )) + end + -- find the index to add the credential at + local max_credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.pinUsersSupported.NAME, 8) + local credential_index = next_empty_index(credentials, max_credentials) + -- save some state so we can complete the transaction on message receipt + device:set_field(lock_utils.ADD_CREDENTIAL..credential_index, index) + -- send the credential creation message + device:send(UserCode:Set({ + user_identifier = credential_index, + user_code = data, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) +end + +local update_credential_handler = function(driver, device, cmd) + -- validate args + local user_index = cmd.args.userIndex + local credential_index = cmd.args.credentialIndex + local credential_type = cmd.args.credentialType + local data = cmd.args.credentialData + -- make sure credential already exists + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + if credentials[credential_index] ~= nil and users[user_index] ~= nil then + -- store state to track update + device:set_field(lock_utils.UPDATE_CREDENTIAL..credential_index, user_index) + -- send command to update code + device:send(UserCode:Set({ + user_identifier = credential_index, + user_code = data, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) + else + -- failure + end +end + +local delete_credential_handler = function(driver, device, cmd) + -- find the user associated with this credential + local user_index = cmd.args.credentialIndex + -- run delete user with that credential + driver:inject_capability_command(device, { + capability = capabilities.lockUsers.ID, + command = capabilities.lockUsers.commands.deleteUser.NAME, + args = { user_index } + }) +end + +local delete_all_credentials_handler = function(driver, device, cmd) + -- check to see if we have users that do not have a code associated + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + local user_index_marked_for_individual_deletion = {} + + for _, credential in pairs(credentials) do + users[credential.userIndex] = nil + user_index_marked_for_individual_deletion[credential.userIndex] = true + end + -- if we don't, this is equivalent to delete_all_users + if utils.table_size(users) == 0 then + driver:inject_capability_command(device, { + capability = capabilities.lockUsers.ID, + command = capabilities.lockUsers.commands.deleteAllUsers.NAME, + args = { } + }) + return + end + -- if we do, delete all users other than those + for i, _ in pairs(user_index_marked_for_individual_deletion) do + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.DELETE_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = i }, { state_change = true, visibility = { displayed = false } } + )) + end + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- and then delete all codes + local delay = 0 + for _, credential in pairs(credentials) do + device.thread.call_with_delay(delay, function(d) + device:send(UserCode:Set({user_identifier = credential.credentialIndex, user_id_status = UserCode.user_id_status.AVAILABLE})) + end) + -- include a delay between deletes + delay = delay + .5 + end + -- send credentials event (these will be sent before all the actual deletes have been sent) + device:emit_event(LockCredentials.credentials({}, { visibility = { displayed = false}})) + -- send success event (this would be tedious to check for every code, so assume they all went through) + device:emit_event(LockCredentials.commandResult({ commandName = lock_utils.DELETE_ALL_CREDENTIALS, statusCode = lock_utils.STATUS_SUCCESS }, { visibility = { displayed = false}})) +end + + + +-- Z-Wave Message Handlers + +local user_code_report_handler = function(driver, device, cmd) + local code_id = cmd.args.user_identifier + local user_id_status = cmd.args.user_id_status + + -- is this a report about an occupied credential index? + if (user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or + (user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code)) then + -- are we in the middle of a user code set for this index? + local user_index_add = device:get_field(lock_utils.ADD_CREDENTIAL..code_id) + local user_index_update = device:get_field(lock_utils.UPDATE_CREDENTIAL..code_id) + if user_index_add ~= nil then + add_or_update(device, lock_utils.ADD_CREDENTIAL, code_id, user_index_add) + elseif user_index_update ~= nil then + add_or_update(device, lock_utils.UPDATE_CREDENTIAL, code_id, user_index_update) + end + elseif user_id_status == UserCode.user_id_status.AVAILABLE then + -- are we in the middle of a user code delete? + local user_index = device:get_field("_delete_credential"..code_id) + if user_index ~= nil then + -- if so, delete the credential + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + credentials[code_id] = nil + -- emit credential event + device:emit_event(LockCredentials.credentials(credentials, { visibility = { displayed = false } })) + -- emit command success + device:emit_event(LockCredentials.commandResult( + { commandName = lock_utils.DELETE_CREDENTIAL, statusCode = lock_utils.STATUS_SUCCESS, credentialIndex = code_id }, { state_change = true, visibility = { displayed = false } } + )) + -- delete the user + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + users[user_index] = nil + -- emit users event + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- emit command success + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.DELETE_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = user_index }, { state_change = true, visibility = { displayed = false } } + )) + -- clear state + device:set_field("_delete_credential"..code_id, nil) + end + end +end + +local notification_report_handler = function(driver, device, cmd) + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event = cmd.args.event + local credentials = device:get_latest_state("main", LockCredentials.ID, LockCredentials.credentials.NAME, {}) + local users = device:get_latest_state("main", LockUsers.ID, LockUsers.users.NAME, {}) + if (event == access_control_event.ALL_USER_CODES_DELETED) then + -- this is unexpected, but we got this out of band, so... + -- check to see if we have users that do not have a code associated + local user_index_marked_for_individual_deletion = {} + for _, credential in pairs(credentials) do + users[credential.userIndex] = nil + user_index_marked_for_individual_deletion[credential.userIndex] = true + end + -- if we don't, this is equivalent to delete_all_users + if utils.table_size(users) == 0 then + driver:inject_capability_command(device, { + capability = capabilities.lockUsers.ID, + command = capabilities.lockUsers.commands.deleteAllUsers.NAME, + args = { } + }) + return + end + -- if we do, delete all users other than those + for i, _ in pairs(user_index_marked_for_individual_deletion) do + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.DELETE_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = i }, { state_change = true, visibility = { displayed = false } } + )) + end + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- emit empty credentials + device:emit_event(LockCredentials.credentials({}, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.commandResult({ commandName = lock_utils.DELETE_ALL_CREDENTIALS, statusCode = lock_utils.STATUS_SUCCESS }, { visibility = { displayed = false}})) + elseif (event == access_control_event.SINGLE_USER_CODE_DELETED) then + local credential_index = lock_utils.get_code_id_from_notification_event(cmd.args.event_parameter, cmd.args.v1_alarm_level) + -- find the user index assigned to this code to delete it as well + local credential = credentials[credential_index] + if credential ~= nil then + -- we may want to check if these match + local stored_user_index = device:get_field("_delete_credential"..credential_index) + local user_index = credential.userIndex + local user = users[user_index] + if user ~= nil then + users[user_index] = nil + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.DELETE_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = user_index }, { state_change = true, visibility = { displayed = false } } + )) + device:set_field("_delete_credential"..credential_index, nil) + end + else + -- something bad happened + end + elseif (event == access_control_event.NEW_USER_CODE_ADDED) then + local credential_index = lock_utils.get_code_id_from_notification_event(cmd.args.event_parameter, cmd.args.v1_alarm_level) + -- determine if this is due to a command or an out-of-band update + local user_index_add = device:get_field(lock_utils.ADD_CREDENTIAL..credential_index) + local user_index_update = device:get_field(lock_utils.UPDATE_CREDENTIAL..credential_index) + if user_index_add ~= nil then + add_or_update(device, lock_utils.ADD_CREDENTIAL, credential_index, user_index_add) + elseif user_index_update ~= nil then + add_or_update(device, lock_utils.UPDATE_CREDENTIAL, credential_index, user_index_update) + else + -- out-of-band update + -- create a user for this code index + local max_users = device:get_latest_state("main", LockUsers.ID, LockUsers.totalUsersSupported.NAME, 8) + -- find the index to add the user at + local index = next_empty_index(users, max_users) + -- insert the user into the table + users[index] = {userIndex = index, userName = "Code "..index, userType = "guest"} + -- emit the users table event + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + -- emit the command result event + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.ADD_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = index }, { state_change = true, visibility = { displayed = false } } + )) + -- add the credential + add_or_update(device, lock_utils.ADD_CREDENTIAL, credential_index, index) + end + elseif (event == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE) then + local credential_index = lock_utils.get_code_id_from_notification_event(cmd.args.event_parameter, cmd.args.v1_alarm_level) + -- this is a create code failure + -- double check we have a stored add command + local user_index_add = device:get_field(lock_utils.ADD_CREDENTIAL..credential_index) + -- clear that state + device:set_field(lock_utils.ADD_CREDENTIAL..credential_index, nil) + -- emit a credential add failure + device:emit_event(LockCredentials.commandResult( + { commandName = lock_utils.ADD_CREDENTIAL, statusCode = "duplicate", credentialIndex = credential_index }, { state_change = true, visibility = { displayed = false } } + )) + -- if we have a stored add command, we should delete the associated user, I think + if users[user_index_add] ~= nil then + users[user_index_add] = nil + device:emit_event(LockUsers.commandResult( + { commandName = lock_utils.DELETE_USER, statusCode = lock_utils.STATUS_SUCCESS, userIndex = user_index_add }, { state_change = true, visibility = { displayed = false } } + )) + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + end + elseif (event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION) then + -- master code changed + -- we might want to fire a credential updated success here? + + + -- these are all the lock operation events + elseif (event >= access_control_event.MANUAL_LOCK_OPERATION and event <= access_control_event.LOCK_JAMMED) then + local event_to_send + + local METHOD = { + KEYPAD = "keypad", + MANUAL = "manual", + COMMAND = "command", + AUTO = "auto" + } + + local DELAY_LOCK_EVENT = "_delay_lock_event" + local DELAY_LOCK_EVENT_TIMER = "_delay_lock_event_timer" + local MAX_DELAY = 10 + + if ((event >= access_control_event.MANUAL_LOCK_OPERATION and + event <= access_control_event.KEYPAD_UNLOCK_OPERATION) or + event == access_control_event.AUTO_LOCK_LOCKED_OPERATION) then + -- even event codes are unlocks, odd event codes are locks + local events = {[0] = capabilities.lock.lock.unlocked(), [1] = capabilities.lock.lock.locked()} + event_to_send = events[event & 1] + elseif (event >= access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION and + event <= access_control_event.LOCK_JAMMED) then + event_to_send = capabilities.lock.lock.unknown() + end + + if (event_to_send ~= nil) then + local method_map = { + [access_control_event.MANUAL_UNLOCK_OPERATION] = METHOD.MANUAL, + [access_control_event.MANUAL_LOCK_OPERATION] = METHOD.MANUAL, + [access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION] = METHOD.MANUAL, + [access_control_event.RF_LOCK_OPERATION] = METHOD.COMMAND, + [access_control_event.RF_UNLOCK_OPERATION] = METHOD.COMMAND, + [access_control_event.RF_NOT_FULLY_LOCKED_OPERATION] = METHOD.COMMAND, + [access_control_event.KEYPAD_LOCK_OPERATION] = METHOD.KEYPAD, + [access_control_event.KEYPAD_UNLOCK_OPERATION] = METHOD.KEYPAD, + [access_control_event.AUTO_LOCK_LOCKED_OPERATION] = METHOD.AUTO, + [access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION] = METHOD.AUTO + } + + event_to_send["data"] = {method = method_map[event]} + + -- SPECIAL CASES: + if (event == access_control_event.MANUAL_UNLOCK_OPERATION and cmd.args.event_parameter == 2) then + -- functionality from DTH, some locks can distinguish being manually locked via keypad + event_to_send.data.method = METHOD.KEYPAD + elseif (event == access_control_event.KEYPAD_LOCK_OPERATION or event == access_control_event.KEYPAD_UNLOCK_OPERATION) then + local code_id = cmd.args.v1_alarm_level + if cmd.args.event_parameter ~= nil and string.len(cmd.args.event_parameter) ~= 0 then + local event_params = { cmd.args.event_parameter:byte(1, -1) } + code_id = (#event_params == 1) and event_params[1] or event_params[3] + end + local user_id + if (credentials ~= nil and + credentials[code_id] ~= nil) then + user_id = credentials[code_id].userIndex + end + if user_id ~= nil then event_to_send["data"] = { userIndex = user_id, method = event_to_send["data"].method } end + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + if device:get_latest_state( + "main", + capabilities.lock.ID, + capabilities.lock.lock.ID) == event_to_send.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local socket = require "socket" + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + local timer = device:get_field(DELAY_LOCK_EVENT_TIMER) + if timer ~= nil then + device.thread:cancel_timer(timer) + device:set_field(DELAY_LOCK_EVENT_TIMER, nil) + end + + device:emit_event(event_to_send) + end + end + end +end + +local users_number_report_handler = function(driver, device, cmd) + -- these are the same for Z-Wave + device:emit_event(LockUsers.totalUsersSupported(cmd.args.supported_users, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.pinUsersSupported(cmd.args.supported_users, { visibility = { displayed = false } })) +end + +local zwave_lock = { + lifecycle_handlers = { + added = added_handler, + }, + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report_handler, + [UserCode.USERS_NUMBER_REPORT] = users_number_report_handler, + } + }, + capability_handlers = { + [LockUsers.ID] = { + [LockUsers.commands.addUser.NAME] = add_user_handler, + [LockUsers.commands.updateUser.NAME] = update_user_handler, + [LockUsers.commands.deleteUser.NAME] = delete_user_handler, + [LockUsers.commands.deleteAllUsers.NAME] = delete_all_users_handler, + }, + [LockCredentials.ID] = { + [LockCredentials.commands.addCredential.NAME] = add_credential_handler, + [LockCredentials.commands.updateCredential.NAME] = update_credential_handler, + [LockCredentials.commands.deleteCredential.NAME] = delete_credential_handler, + [LockCredentials.commands.deleteAllCredentials.NAME] = delete_all_credentials_handler, + } + }, + NAME = "Using new capabilities", + can_handle = function(opts, driver, device, ...) + if not device:supports_capability_by_id(LockUsers.ID) then return false end + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if lock_codes_migrated then + local subdriver = require("using-new-capabilities") + return true, subdriver + end + return false + end +} + +return zwave_lock \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/using-new-capabilities/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/keywe-lock/init.lua new file mode 100644 index 0000000000..d39aa45d1c --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/keywe-lock/init.lua @@ -0,0 +1,86 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Association = (require "st.zwave.CommandClass.Association")({version=2}) +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local KEYWE_MFR = 0x037B +local TAMPER_CLEAR_DELAY = 10 + +local function can_handle_keywe_lock(opts, self, device, cmd, ...) + return device.zwave_manufacturer_id == KEYWE_MFR +end + +local function clear_tamper_if_needed(device) + local current_tamper_state = device:get_latest_state("main", capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME) + if current_tamper_state == "detected" then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_OPEN then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_CLOSED then + event = capabilities.lock.lock.locked() + end + if event ~= nil then + event["data"] = {method = "manual"} + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + device.thread:call_with_delay( + TAMPER_CLEAR_DELAY, + function(d) + clear_tamper_if_needed(device) + end + ) + end +end + +local function do_configure(self, device) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local keywe_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Keywe Lock", + can_handle = can_handle_keywe_lock, +} + +return keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/using-new-capabilities/new_lock_utils.lua b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/new_lock_utils.lua new file mode 100644 index 0000000000..43a22cc713 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/new_lock_utils.lua @@ -0,0 +1,227 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +local utils = require "st.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers +local INITIAL_INDEX = 1 + +local new_lock_utils = { + -- Constants + ADD_CREDENTIAL = "addCredential", + ADD_USER = "addUser", + COMMAND_NAME = "commandName", + CREDENTIAL_TYPE = "pin", + CHECKING_CODE = "checkingCode", + DELETE_ALL_CREDENTIALS = "deleteAllCredentials", + DELETE_ALL_USERS = "deleteAllUsers", + DELETE_CREDENTIAL = "deleteCredential", + DELETE_USER = "deleteUser", + LOCK_CREDENTIALS = "lockCredentials", + LOCK_USERS = "lockUsers", + PENDING_CREDENTIAL = "pendingCredential", + STATUS_BUSY = "busy", + STATUS_DUPLICATE = "duplicate", + STATUS_FAILURE = "failure", + STATUS_INVALID_COMMAND = "invalidCommand", + STATUS_OCCUPIED = "occupied", + STATUS_RESOURCE_EXHAUSTED = "resourceExhausted", + STATUS_SUCCESS = "success", + UPDATE_CREDENTIAL = "updateCredential", + UPDATE_USER = "updateUser", + USER_INDEX = "userIndex", + USER_NAME = "userName", + USER_TYPE = "userType" +} + +new_lock_utils.get_users = function(device) + local users = device:get_field(new_lock_utils.LOCK_USERS) + return users ~= nil and users or {} +end + +new_lock_utils.get_user = function(device, user_index) + for _, user in ipairs(new_lock_utils.get_users(device)) do + if user.userIndex == user_index then + return user + end + end + + return nil +end + +new_lock_utils.get_available_user_index = function(current_data, max) + if current_data == nil and max ~= 0 then + return INITIAL_INDEX + elseif current_data ~= nil then + for index = 1, max do + if current_data["user" .. index] == nil then + return index + end + end + end + + return nil +end + +new_lock_utils.get_credentials = function(device) + local credentials = device:get_field(new_lock_utils.LOCK_CREDENTIALS) + return credentials ~= nil and credentials or {} +end + +new_lock_utils.get_credential = function(device, credential_index) + for _, credential in ipairs(new_lock_utils.get_credentials(device)) do + if credential.credentialIndex == credential_index then + return credential + end + end + return nil +end + +new_lock_utils.get_available_credential_index = function(current_data, max) + local available_index = nil + local used_index = {} + + for i, _ in ipairs(current_data) do + used_index[i] = true + end + + if current_data ~= {} then + for index = 1, max do + if used_index[index] == nil then + available_index = index + break + end + end + else + available_index = INITIAL_INDEX + end + + return available_index +end + +new_lock_utils.create_user = function(device, user_name, user_type, user_index) + local status_code = new_lock_utils.STATUS_SUCCESS + local max_users = device:get_latest_state("main", capabilities.lockUsers.ID, + capabilities.lockUsers.totalUsersSupported.NAME, 0) + local current_users = new_lock_utils.get_users(device) + local available_index = new_lock_utils.get_available_user_index(current_users, max_users) + + if max_users == 0 or available_index == nil then + -- Can't add any users - update commandResult statusCode + status_code = new_lock_utils.STATUS_RESOURCE_EXHAUSTED + else + -- use the passed in index if it's set + if user_index ~= nil then + available_index = user_index + end + current_users["user"..available_index] = { userIndex = available_index, userType = user_type, userName = user_name } + device:set_field(new_lock_utils.LOCK_USERS, current_users, { persist = true }) + end + + return status_code +end + +new_lock_utils.delete_user = function(device, user_index, deleted_by_credential_deletion) + local current_users = new_lock_utils.get_users(device) + local status_code = new_lock_utils.STATUS_FAILURE + + for index, user in pairs(current_users) do + if user.userIndex == user_index then + -- also delete associated credential if this isn't being call by a credential deletion. + if not deleted_by_credential_deletion then + -- find associated credential. + for _, credential in ipairs(new_lock_utils.get_credentials(device)) do + if credential.userIndex == user_index then + new_lock_utils.delete_credential(device, credential.credentialIndex, true) + break + end + end + end + -- table.remove(current_users, index) + current_users[index] = nil + device:set_field(new_lock_utils.LOCK_USERS, current_users) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + + return status_code +end + +new_lock_utils.add_credential = function(device, user_index, user_type, credential_type, credential_index) + -- need to also create a user if one does not exist at the user index. + if new_lock_utils.get_user(device, user_index) == nil then + local user_name = "USER_" .. user_index + local status = new_lock_utils.create_user(device, user_name, user_type, user_index) + if status ~= new_lock_utils.STATUS_SUCCESS then + return status + end + end + + local credentials = new_lock_utils.get_credentials(device) + table.insert(credentials, + { userIndex = user_index, credentialIndex = credential_index, credentialType = credential_type }) + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + return new_lock_utils.STATUS_SUCCESS +end + +new_lock_utils.delete_credential = function(device, credential_index, deleted_by_user_deletion) + local credentials = new_lock_utils.get_credentials(device) + local status_code = new_lock_utils.STATUS_FAILURE + + for index, credential in pairs(credentials) do + if credential.credentialIndex == credential_index then + -- also delete associated user if this isn't being called by a user deletion. + if not deleted_by_user_deletion then + new_lock_utils.delete_user(device, credential.userIndex, true) + end + table.remove(credentials, index) + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + + return status_code +end + +new_lock_utils.update_credential = function(device, credential_index, user_index, credential_type) + local credentials = new_lock_utils.get_credentials(device) + local status_code = new_lock_utils.STATUS_FAILURE + + for _, credential in ipairs(credentials) do + if credential.credentialIndex == credential_index then + credential.credentialType = credential_type + credential.userIndex = user_index + device:set_field(new_lock_utils.LOCK_CREDENTIALS, credentials) + status_code = new_lock_utils.STATUS_SUCCESS + break + end + end + return status_code +end + +new_lock_utils.get_code_id_from_notification_event = function(event_params, v1_alarm_level) + -- some locks do not properly include the code ID in the event params, but do encode it + -- in the v1 alarm level + local code_id = v1_alarm_level + if event_params ~= nil and event_params ~= "" then + event_params = {event_params:byte(1,-1)} + code_id = (#event_params == 1) and event_params[1] or event_params[3] + end + return tostring(code_id) +end + +return new_lock_utils diff --git a/drivers/SmartThings/zwave-lock/src/using-new-capabilities/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/samsung-lock/init.lua new file mode 100644 index 0000000000..813c6217b4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/samsung-lock/init.lua @@ -0,0 +1,111 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local access_control_event = Notification.event.access_control + +local json = require "dkjson" +local constants = require "st.zwave.constants" + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local get_lock_codes = LockCodesDefaults.get_lock_codes +local clear_code_state = LockCodesDefaults.clear_code_state +local code_deleted = LockCodesDefaults.code_deleted + +local SAMSUNG_MFR = 0x022E + +local function can_handle_samsung_lock(opts, self, device, cmd, ...) + return device.zwave_manufacturer_id == SAMSUNG_MFR +end + +local function get_ongoing_code_set(device) + local code_id + local code_state = device:get_field(constants.CODE_STATE) + if code_state ~= nil then + for key, state in pairs(code_state) do + if state ~= nil then + code_id = key:match("setName(%d)") + end + end + end + return code_id +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.NEW_USER_CODE_ADDED then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + device:send(UserCode:Get({user_identifier = code_id})) + return + end + elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) + clear_code_state(device, code_id) + end + elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- Update Master Code in the same way as in defaults... + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + -- ...and delete rest of them, as lock does + local lock_codes = get_lock_codes(device) + for code_id, _ in pairs(lock_codes) do + if code_id ~= "0" then + code_deleted(device, code_id) + end + end + event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + end +end + +-- Used doConfigure instead of added to not overwrite parent driver's added_handler +local function do_configure(self, device) + -- taken directly from DTH + -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked + device:emit_event(capabilities.lock.lock.unlocked()) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) +end + +local samsung_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Samsung Lock", + can_handle = can_handle_samsung_lock, +} + +return samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/using-new-capabilities/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/schlage-lock/init.lua new file mode 100644 index 0000000000..67e649d869 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/schlage-lock/init.lua @@ -0,0 +1,193 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local constants = require "st.zwave.constants" +local json = require "dkjson" + +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local user_id_status = UserCode.user_id_status +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) + +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local SCHLAGE_MFR = 0x003B +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds + +local function can_handle_schlage_lock(opts, self, device, cmd, ...) + return device.zwave_manufacturer_id == SCHLAGE_MFR +end + +local function set_code_length(self, device, cmd) + local length = cmd.args.length + if length >= 4 and length <= 8 then + device:send(Configuration:Set({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = length, + size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size + })) + end +end + +local function reload_all_codes(self, device, cmd) + LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + end +end + +local function set_code(self, device, cmd) + if (cmd.args.codePIN == "") then + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = {cmd.args.codeSlot, cmd.args.codeName}, + }) + else + -- copied from defaults with additional check for Schlage's configuration + if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then + if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end + local code_state = device:get_field(constants.CODE_STATE) + code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName + device:set_field(constants.CODE_STATE, code_state, { persist = true }) + end + local send_set_user_code = function () + device:send(UserCode:Set({ + user_identifier = cmd.args.codeSlot, + user_code = cmd.args.codePIN, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + end + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) + else + send_set_user_code() + end + end +end + +local function do_configure(self, device) + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function basic_set_handler(self, device, cmd) + device:emit_event(cmd.args.value == 0 and capabilities.lock.lock.unlocked() or capabilities.lock.lock.locked()) + device:send(Association:Remove({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function configuration_report(self, device, cmd) + local parameter_number = cmd.args.parameter_number + if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then + local reported_code_length = cmd.args.configuration_value + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length ~= nil and current_code_length ~= reported_code_length then + local all_codes_deleted_mocked_command = Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED + }) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + end + device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + end +end + +local function is_user_code_report_mfr_specific(device, cmd) + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local code_id = cmd.args.user_identifier + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_state = device:get_field(constants.CODE_STATE) + return user_code == "**********" or user_code == nil or (code_state ~= nil and code_state["setName"..cmd.args.user_identifier] ~= nil) + else + return (code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE) or + reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE + end +end + +local function user_code_report_handler(self, device, cmd) + local code_id = cmd.args.user_identifier + if is_user_code_report_mfr_specific(device, cmd) then + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local event + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_name = LockCodesDefaults.get_code_name(device, code_id) + local change_type = LockCodesDefaults.get_change_type(device, code_id) + event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) + event.data = {codeName = code_name} + if code_id ~= 0 then -- ~= MASTER_CODE + LockCodesDefaults.code_set_event(device, code_id, code_name) + end + elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then + local lock_codes = LockCodesDefaults.get_lock_codes(device) + for _code_id, _ in pairs(lock_codes) do + LockCodesDefaults.code_deleted(device, _code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) + else -- user_id_status.STATUS_NOT_AVAILABLE + event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + end + + if event ~= nil then + device:emit_event(event) + end + LockCodesDefaults.clear_code_state(device, code_id) + LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) + else + LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + end +end + +local schlage_lock = { + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, + [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [capabilities.lockCodes.commands.setCode.NAME] = set_code + } + }, + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report_handler + }, + [cc.CONFIGURATION] = { + [Configuration.REPORT] = configuration_report + }, + [cc.BASIC] = { + [Basic.SET] = basic_set_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure, + }, + NAME = "Schlage Lock", + can_handle = can_handle_schlage_lock, +} + +return schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/using-new-capabilities/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/zwave-alarm-v1-lock/init.lua new file mode 100644 index 0000000000..44d978999b --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-new-capabilities/zwave-alarm-v1-lock/init.lua @@ -0,0 +1,165 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Alarm +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.defaults.lockCodes +local lock_code_defaults = require "st.zwave.defaults.lockCodes" +local json = require "dkjson" + +local METHOD = { + KEYPAD = "keypad", + MANUAL = "manual", + COMMAND = "command", + AUTO = "auto" +} + +--- Determine whether the passed command is a V1 alarm command +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @return boolean true if the device is smoke co alarm +local function can_handle_v1_alarm(opts, driver, device, cmd, ...) + return opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 +end + +--- Default handler for alarm command class reports, these were largely OEM-defined +--- +--- This converts alarm V1 reports to correct lock events +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Alarm.Report +local function alarm_report_handler(driver, device, cmd) + local alarm_type = cmd.args.alarm_type + local event = nil + local lock_codes = lock_code_defaults.get_lock_codes(device) + local code_id = nil + if (cmd.args.alarm_level ~= nil) then + code_id = tostring(cmd.args.alarm_level) + end + if (alarm_type == 9 or alarm_type == 17) then + event = capabilities.lock.lock.unknown() + elseif (alarm_type == 16 or alarm_type == 19) then + event = capabilities.lock.lock.unlocked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 18) then + event = capabilities.lock.lock.locked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 21) then + event = capabilities.lock.lock.locked() + if (cmd.args.alarm_level == 2) then + event["data"] = {method = METHOD.MANUAL} + else + event["data"] = {method = METHOD.KEYPAD} + end + elseif (alarm_type == 22) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.MANUAL} + elseif (alarm_type == 23) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 24) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 25) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 26) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 27) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 32) then + -- all user codes deleted + for code_id, _ in pairs(lock_codes) do + lock_code_defaults.code_deleted(device, code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + elseif (alarm_type == 33) then + -- user code deleted + if (code_id ~= nil) then + lock_code_defaults.clear_code_state(device, code_id) + if (lock_codes[code_id] ~= nil) then + lock_code_defaults.code_deleted(device, code_id) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + end + end + elseif (alarm_type == 13 or alarm_type == 112) then + -- user code changed/set + if (code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + local change_type = lock_code_defaults.get_change_type(device, code_id) + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) + code_changed_event["data"] = { codeName = code_name} + lock_code_defaults.code_set_event(device, code_id, code_name) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 34 or alarm_type == 113) then + -- duplicate lock code + if (code_id ~= nil) then + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 130) then + -- batteries replaced + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 161) then + -- tamper alarm + event = capabilities.tamperAlert.tamper.detected() + elseif (alarm_type == 167) then + -- low battery + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 168) then + -- critical battery + event = capabilities.battery.battery(1) + elseif (alarm_type == 169) then + -- battery too low to operate + event = capabilities.battery.battery(0) + end + + if (event ~= nil) then + device:emit_event(event) + end +end + +local zwave_lock = { + zwave_handlers = { + [cc.ALARM] = { + [Alarm.REPORT] = alarm_report_handler + } + }, + NAME = "Z-Wave lock alarm V1", + can_handle = can_handle_v1_alarm, +} + +return zwave_lock diff --git a/drivers/SmartThings/zwave-lock/src/using-old-capabilities/can_handle.lua b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/can_handle.lua new file mode 100644 index 0000000000..8151871ac3 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/can_handle.lua @@ -0,0 +1,10 @@ +return function(opts, driver, device, ...) + local capabilities = require "st.capabilities" + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if not lock_codes_migrated then + local subdriver = require("using-old-capabilities") + return true, subdriver + end + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/using-old-capabilities/init.lua b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/init.lua new file mode 100644 index 0000000000..08cd518841 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/init.lua @@ -0,0 +1,175 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" + +local SCAN_CODES_CHECK_INTERVAL = 30 + +local function periodic_codes_state_verification(driver, device) + local scan_codes_state = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME) + if scan_codes_state == "Scanning" then + driver:inject_capability_command(device, + { capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + } + ) + device.thread:call_with_delay( + SCAN_CODES_CHECK_INTERVAL, + function(d) + periodic_codes_state_verification(driver, device) + end + ) + end +end + +local init_handler = function(driver, device, event) + local constants = require "st.zwave.constants" + -- temp fix before this can be changed from being persisted in memory + device:set_field(constants.CODE_STATE, nil, { persist = true }) +end + +--- Builds up initial state for the device +--- +--- @param self st.zwave.Driver +--- @param device st.zwave.Device +local function added_handler(self, device) + self:inject_capability_command(device, + { capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} }) + device.thread:call_with_delay( + SCAN_CODES_CHECK_INTERVAL, + function(d) + periodic_codes_state_verification(self, device) + end + ) + local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) + local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) + device:send(DoorLock:OperationGet({})) + device:send(Battery:Get({})) + if (device:supports_capability(capabilities.tamperAlert)) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd table +local function update_codes(driver, device, cmd) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + local delay = 0 + -- args.codes is json + for name, code in pairs(cmd.args.codes) do + -- these seem to come in the format "code[slot#]: code" + local code_slot = tonumber(string.gsub(name, "code", ""), 10) + if (code_slot ~= nil) then + if (code ~= nil and (code ~= "0" and code ~= "")) then + -- code changed + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({ + user_identifier = code_slot, + user_code = code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) + end) + delay = delay + 2.2 + else + -- code deleted + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) + end) + delay = delay + 2.2 + device.thread:call_with_delay(delay, function () + device:send(UserCode:Get({user_identifier = code_slot})) + end) + delay = delay + 2.2 + end + end + 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 get_lock_codes = LockCodesDefaults.get_lock_codes + local lock_users = {} + local lock_credentials = {} + local lock_codes = get_lock_codes(device) + local ordered_codes = {} + + for code in pairs(lock_codes) do + table.insert(ordered_codes, code) + end + + table.sort(ordered_codes) + for index = 1, #ordered_codes do + local code_slot, code_name = ordered_codes[index], lock_codes[ ordered_codes[index] ] + table.insert(lock_users, {userIndex = index, userType = "guest", userName = code_name}) + table.insert(lock_credentials, {userIndex = index, credentialIndex = tonumber(code_slot), credentialType = "pin"}) + end + + local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME, 4) + local max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME, 10) + local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME, 8) + if (code_length ~= nil) then + max_code_len = code_length + min_code_len = code_length + end + + device:emit_event(capabilities.lockCredentials.minPinCodeLen(min_code_len, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(max_code_len, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) +end + +local using_old_capabilities = { + lifecycle_handlers = { + init = init_handler, + added = added_handler, + }, + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes + }, + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.migrate.NAME] = migrate + }, + }, + sub_drivers = { + require("using-old-capabilities.zwave-alarm-v1-lock"), + require("using-old-capabilities.schlage-lock"), + require("using-old-capabilities.samsung-lock"), + require("using-old-capabilities.keywe-lock"), + }, + can_handle = function(opts, driver, device, ...) + if not device:supports_capability_by_id(capabilities.lockCodes.ID) then return false end + local lock_codes_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, + capabilities.lockCodes.migrated.NAME, false) + if not lock_codes_migrated then + local subdriver = require("using-old-capabilities") + return true, subdriver + end + return false + end, + NAME = "Using old capabilities" +} + +return using_old_capabilities diff --git a/drivers/SmartThings/zwave-lock/src/using-old-capabilities/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/keywe-lock/init.lua new file mode 100644 index 0000000000..d39aa45d1c --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/keywe-lock/init.lua @@ -0,0 +1,86 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Association = (require "st.zwave.CommandClass.Association")({version=2}) +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local KEYWE_MFR = 0x037B +local TAMPER_CLEAR_DELAY = 10 + +local function can_handle_keywe_lock(opts, self, device, cmd, ...) + return device.zwave_manufacturer_id == KEYWE_MFR +end + +local function clear_tamper_if_needed(device) + local current_tamper_state = device:get_latest_state("main", capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME) + if current_tamper_state == "detected" then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_OPEN then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_CLOSED then + event = capabilities.lock.lock.locked() + end + if event ~= nil then + event["data"] = {method = "manual"} + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + device.thread:call_with_delay( + TAMPER_CLEAR_DELAY, + function(d) + clear_tamper_if_needed(device) + end + ) + end +end + +local function do_configure(self, device) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local keywe_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Keywe Lock", + can_handle = can_handle_keywe_lock, +} + +return keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/using-old-capabilities/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/samsung-lock/init.lua new file mode 100644 index 0000000000..813c6217b4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/samsung-lock/init.lua @@ -0,0 +1,111 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local access_control_event = Notification.event.access_control + +local json = require "dkjson" +local constants = require "st.zwave.constants" + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local get_lock_codes = LockCodesDefaults.get_lock_codes +local clear_code_state = LockCodesDefaults.clear_code_state +local code_deleted = LockCodesDefaults.code_deleted + +local SAMSUNG_MFR = 0x022E + +local function can_handle_samsung_lock(opts, self, device, cmd, ...) + return device.zwave_manufacturer_id == SAMSUNG_MFR +end + +local function get_ongoing_code_set(device) + local code_id + local code_state = device:get_field(constants.CODE_STATE) + if code_state ~= nil then + for key, state in pairs(code_state) do + if state ~= nil then + code_id = key:match("setName(%d)") + end + end + end + return code_id +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.NEW_USER_CODE_ADDED then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + device:send(UserCode:Get({user_identifier = code_id})) + return + end + elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) + clear_code_state(device, code_id) + end + elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- Update Master Code in the same way as in defaults... + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + -- ...and delete rest of them, as lock does + local lock_codes = get_lock_codes(device) + for code_id, _ in pairs(lock_codes) do + if code_id ~= "0" then + code_deleted(device, code_id) + end + end + event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + end +end + +-- Used doConfigure instead of added to not overwrite parent driver's added_handler +local function do_configure(self, device) + -- taken directly from DTH + -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked + device:emit_event(capabilities.lock.lock.unlocked()) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) +end + +local samsung_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Samsung Lock", + can_handle = can_handle_samsung_lock, +} + +return samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/using-old-capabilities/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/schlage-lock/init.lua new file mode 100644 index 0000000000..67e649d869 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/schlage-lock/init.lua @@ -0,0 +1,193 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local constants = require "st.zwave.constants" +local json = require "dkjson" + +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local user_id_status = UserCode.user_id_status +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) + +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local SCHLAGE_MFR = 0x003B +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds + +local function can_handle_schlage_lock(opts, self, device, cmd, ...) + return device.zwave_manufacturer_id == SCHLAGE_MFR +end + +local function set_code_length(self, device, cmd) + local length = cmd.args.length + if length >= 4 and length <= 8 then + device:send(Configuration:Set({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = length, + size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size + })) + end +end + +local function reload_all_codes(self, device, cmd) + LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + end +end + +local function set_code(self, device, cmd) + if (cmd.args.codePIN == "") then + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = {cmd.args.codeSlot, cmd.args.codeName}, + }) + else + -- copied from defaults with additional check for Schlage's configuration + if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then + if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end + local code_state = device:get_field(constants.CODE_STATE) + code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName + device:set_field(constants.CODE_STATE, code_state, { persist = true }) + end + local send_set_user_code = function () + device:send(UserCode:Set({ + user_identifier = cmd.args.codeSlot, + user_code = cmd.args.codePIN, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + end + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) + else + send_set_user_code() + end + end +end + +local function do_configure(self, device) + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function basic_set_handler(self, device, cmd) + device:emit_event(cmd.args.value == 0 and capabilities.lock.lock.unlocked() or capabilities.lock.lock.locked()) + device:send(Association:Remove({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function configuration_report(self, device, cmd) + local parameter_number = cmd.args.parameter_number + if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then + local reported_code_length = cmd.args.configuration_value + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length ~= nil and current_code_length ~= reported_code_length then + local all_codes_deleted_mocked_command = Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED + }) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + end + device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + end +end + +local function is_user_code_report_mfr_specific(device, cmd) + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local code_id = cmd.args.user_identifier + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_state = device:get_field(constants.CODE_STATE) + return user_code == "**********" or user_code == nil or (code_state ~= nil and code_state["setName"..cmd.args.user_identifier] ~= nil) + else + return (code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE) or + reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE + end +end + +local function user_code_report_handler(self, device, cmd) + local code_id = cmd.args.user_identifier + if is_user_code_report_mfr_specific(device, cmd) then + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local event + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_name = LockCodesDefaults.get_code_name(device, code_id) + local change_type = LockCodesDefaults.get_change_type(device, code_id) + event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) + event.data = {codeName = code_name} + if code_id ~= 0 then -- ~= MASTER_CODE + LockCodesDefaults.code_set_event(device, code_id, code_name) + end + elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then + local lock_codes = LockCodesDefaults.get_lock_codes(device) + for _code_id, _ in pairs(lock_codes) do + LockCodesDefaults.code_deleted(device, _code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) + else -- user_id_status.STATUS_NOT_AVAILABLE + event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + end + + if event ~= nil then + device:emit_event(event) + end + LockCodesDefaults.clear_code_state(device, code_id) + LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) + else + LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + end +end + +local schlage_lock = { + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, + [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [capabilities.lockCodes.commands.setCode.NAME] = set_code + } + }, + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report_handler + }, + [cc.CONFIGURATION] = { + [Configuration.REPORT] = configuration_report + }, + [cc.BASIC] = { + [Basic.SET] = basic_set_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure, + }, + NAME = "Schlage Lock", + can_handle = can_handle_schlage_lock, +} + +return schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/using-old-capabilities/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/zwave-alarm-v1-lock/init.lua new file mode 100644 index 0000000000..44d978999b --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/using-old-capabilities/zwave-alarm-v1-lock/init.lua @@ -0,0 +1,165 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Alarm +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.defaults.lockCodes +local lock_code_defaults = require "st.zwave.defaults.lockCodes" +local json = require "dkjson" + +local METHOD = { + KEYPAD = "keypad", + MANUAL = "manual", + COMMAND = "command", + AUTO = "auto" +} + +--- Determine whether the passed command is a V1 alarm command +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @return boolean true if the device is smoke co alarm +local function can_handle_v1_alarm(opts, driver, device, cmd, ...) + return opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 +end + +--- Default handler for alarm command class reports, these were largely OEM-defined +--- +--- This converts alarm V1 reports to correct lock events +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Alarm.Report +local function alarm_report_handler(driver, device, cmd) + local alarm_type = cmd.args.alarm_type + local event = nil + local lock_codes = lock_code_defaults.get_lock_codes(device) + local code_id = nil + if (cmd.args.alarm_level ~= nil) then + code_id = tostring(cmd.args.alarm_level) + end + if (alarm_type == 9 or alarm_type == 17) then + event = capabilities.lock.lock.unknown() + elseif (alarm_type == 16 or alarm_type == 19) then + event = capabilities.lock.lock.unlocked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 18) then + event = capabilities.lock.lock.locked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 21) then + event = capabilities.lock.lock.locked() + if (cmd.args.alarm_level == 2) then + event["data"] = {method = METHOD.MANUAL} + else + event["data"] = {method = METHOD.KEYPAD} + end + elseif (alarm_type == 22) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.MANUAL} + elseif (alarm_type == 23) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 24) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 25) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 26) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 27) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 32) then + -- all user codes deleted + for code_id, _ in pairs(lock_codes) do + lock_code_defaults.code_deleted(device, code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + elseif (alarm_type == 33) then + -- user code deleted + if (code_id ~= nil) then + lock_code_defaults.clear_code_state(device, code_id) + if (lock_codes[code_id] ~= nil) then + lock_code_defaults.code_deleted(device, code_id) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + end + end + elseif (alarm_type == 13 or alarm_type == 112) then + -- user code changed/set + if (code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + local change_type = lock_code_defaults.get_change_type(device, code_id) + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) + code_changed_event["data"] = { codeName = code_name} + lock_code_defaults.code_set_event(device, code_id, code_name) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 34 or alarm_type == 113) then + -- duplicate lock code + if (code_id ~= nil) then + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 130) then + -- batteries replaced + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 161) then + -- tamper alarm + event = capabilities.tamperAlert.tamper.detected() + elseif (alarm_type == 167) then + -- low battery + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 168) then + -- critical battery + event = capabilities.battery.battery(1) + elseif (alarm_type == 169) then + -- battery too low to operate + event = capabilities.battery.battery(0) + end + + if (event ~= nil) then + device:emit_event(event) + end +end + +local zwave_lock = { + zwave_handlers = { + [cc.ALARM] = { + [Alarm.REPORT] = alarm_report_handler + } + }, + NAME = "Z-Wave lock alarm V1", + can_handle = can_handle_v1_alarm, +} + +return zwave_lock