From e08e51d76cc8b6211c05c3eaf2ebe27a6df32604 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 17 Feb 2026 14:58:11 +0100 Subject: [PATCH 01/15] keypad initial commit --- drivers/SmartThings/zigbee-keypad/config.yml | 6 + .../zigbee-keypad/fingerprints.yml | 6 + .../frient-keypad-security-system.yml | 14 ++ .../src/frient-keypad/can_handle.lua | 11 + .../zigbee-keypad/src/frient-keypad/init.lua | 212 ++++++++++++++++++ .../SmartThings/zigbee-keypad/src/init.lua | 21 ++ .../zigbee-keypad/src/lazy_load_subdriver.lua | 15 ++ .../zigbee-keypad/src/sub_drivers.lua | 8 + 8 files changed, 293 insertions(+) create mode 100644 drivers/SmartThings/zigbee-keypad/config.yml create mode 100644 drivers/SmartThings/zigbee-keypad/fingerprints.yml create mode 100644 drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml create mode 100644 drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua create mode 100644 drivers/SmartThings/zigbee-keypad/src/init.lua create mode 100644 drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua diff --git a/drivers/SmartThings/zigbee-keypad/config.yml b/drivers/SmartThings/zigbee-keypad/config.yml new file mode 100644 index 0000000000..fbb84bda1f --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/config.yml @@ -0,0 +1,6 @@ +name: 'Zigbee Keypad' +packageKey: 'zigbee-keypad' +permissions: + zigbee: {} +description: "SmartThings driver for Zigbee keypad devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/zigbee-keypad/fingerprints.yml b/drivers/SmartThings/zigbee-keypad/fingerprints.yml new file mode 100644 index 0000000000..6048ae596a --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/fingerprints.yml @@ -0,0 +1,6 @@ +zigbeeManufacturer: + - id: "frient A/S/KEPZB-110" + deviceLabel: "frient Intelligent Keypad" + manufacturer: "frient A/S" + model: KEPZB-110 + deviceProfileName: frient-keypad-security-system diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml new file mode 100644 index 0000000000..8428c169b0 --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -0,0 +1,14 @@ +name: frient-keypad-security-system +components: +- id: main + capabilities: + - id: securitySystem + version: 1 + - id: battery + version: 1 + - id: tamperAlert + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua new file mode 100644 index 0000000000..d9bbbccafb --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_keypad_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and device:get_model() == "KEPZB-110" then + return true, require("frient-keypad") + end + return false +end + +return frient_keypad_can_handle diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua new file mode 100644 index 0000000000..5454c4b268 --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -0,0 +1,212 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local device_management = require "st.zigbee.device_management" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local log = require "log" + +local PowerConfiguration = clusters.PowerConfiguration +local IASACE = clusters.IASACE +local SecuritySystem = capabilities.securitySystem +local IASZone = clusters.IASZone +local tamperAlert = capabilities.tamperAlert + +local ArmMode = IASACE.types.ArmMode +local ArmNotification = IASACE.types.ArmNotification +local PanelStatus = IASACE.types.IasacePanelStatus +local AudibleNotification = IASACE.types.IasaceAudibleNotification +local AlarmStatus = IASACE.types.IasaceAlarmStatus + +local BATTERY_INIT = battery_defaults.build_linear_voltage_init(4.0, 6.0) + +local SECURITY_STATUS_EVENTS = { + armedAway = SecuritySystem.securitySystemStatus.armedAway, + armedStay = SecuritySystem.securitySystemStatus.armedStay, + disarmed = SecuritySystem.securitySystemStatus.disarmed, +} + +local ARM_MODE_TO_STATUS = { + [ArmMode.DISARM] = "disarmed", + [ArmMode.ARM_DAY_HOME_ZONES_ONLY] = "armedStay", + [ArmMode.ARM_NIGHT_SLEEP_ZONES_ONLY] = "armedStay", + [ArmMode.ARM_ALL_ZONES] = "armedAway", +} + +local ARM_MODE_TO_NOTIFICATION = { + [ArmMode.DISARM] = ArmNotification.ALL_ZONES_DISARMED, + [ArmMode.ARM_DAY_HOME_ZONES_ONLY] = ArmNotification.ONLY_DAY_HOME_ZONES_ARMED, + [ArmMode.ARM_NIGHT_SLEEP_ZONES_ONLY] = ArmNotification.ONLY_NIGHT_SLEEP_ZONES_ARMED, + [ArmMode.ARM_ALL_ZONES] = ArmNotification.ALL_ZONES_ARMED, +} + +local STATUS_TO_PANEL = { + armedAway = PanelStatus.ARMED_AWAY, + armedStay = PanelStatus.ARMED_STAY, + disarmed = PanelStatus.PANEL_DISARMED_READY_TO_ARM, +} + +local function emit_supported(device) + device:emit_event(SecuritySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } })) + device:emit_event(SecuritySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } })) +end + +local function emit_status_event(device, status, extra_data) + local event_factory = SECURITY_STATUS_EVENTS[status] or SecuritySystem.securitySystemStatus.disarmed + local event = event_factory({ state_change = true }) + if extra_data ~= nil then + device.log.info(string.format("securitySystemStatus extra data ignored (keys=%s)", table.concat((function() + local keys = {} + for k, _ in pairs(extra_data) do + keys[#keys + 1] = tostring(k) + end + return keys + end)(), ","))) + end + device.log.info(string.format("Emitting securitySystemStatus=%s", status)) + device:emit_event(event) +end + +local function get_current_status(device) + return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" +end + +local function send_panel_status(device, status) + local panel_status = STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM + device:send(IASACE.client.commands.PanelStatusChanged( + device, + panel_status, + 0x00, + AudibleNotification.MUTE, + AlarmStatus.NO_ALARM + )) +end + +local function handle_arm_command(driver, device, zb_rx) + local cmd = zb_rx.body.zcl_body + local pin = cmd.arm_disarm_code.value + local pin_len = pin ~= nil and string.len(pin) or 0 + log.info(string.format("IAS ACE Arm received (mode=%s, pin_len=%d)", tostring(cmd.arm_mode.value), pin_len)) + + local status = ARM_MODE_TO_STATUS[cmd.arm_mode.value] + if status == nil then + log.warn("IAS ACE Arm received with unsupported arm mode") + return + end + + local data = { source = "keypad" } + if pin ~= nil and pin ~= "" then + data.pin = pin + end + + emit_status_event(device, status, data) + device:send(IASACE.client.commands.ArmResponse( + device, + ARM_MODE_TO_NOTIFICATION[cmd.arm_mode.value] or ArmNotification.ALL_ZONES_DISARMED + )) +end + +local function handle_get_panel_status(driver, device, zb_rx) + local status = get_current_status(device) + device:send(IASACE.client.commands.GetPanelStatusResponse( + device, + STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 0x00, + AudibleNotification.MUTE, + AlarmStatus.NO_ALARM + )) +end + +local function handle_arm_away(driver, device, command) + emit_status_event(device, "armedAway", { source = "app" }) + send_panel_status(device, "armedAway") +end + +local function handle_arm_stay(driver, device, command) + emit_status_event(device, "armedStay", { source = "app" }) + send_panel_status(device, "armedStay") +end + +local function handle_disarm(driver, device, command) + emit_status_event(device, "disarmed", { source = "app" }) + send_panel_status(device, "disarmed") +end + +local function refresh(driver, device, command) + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + send_panel_status(device, get_current_status(device)) +end + +local function device_added(driver, device) + emit_supported(device) + if device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) == nil then + emit_status_event(device, "disarmed", { source = "driver" }) + end +end + +local function do_configure(self, device) + device:send(device_management.build_bind_request(device, IASACE.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(PowerConfiguration.attributes.BatteryVoltage:configure_reporting(device, 30, 21600, 1)) +end + +local function device_init(driver, device) + BATTERY_INIT(driver, device) + emit_supported(device) +end + +local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) + if zone_status:is_tamper_set() then + device:emit_event(tamperAlert.tamper.detected()) + else + device:emit_event(tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local frient_keypad = { + NAME = "frient Keypad", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure, + init = device_init, + }, + zigbee_handlers = { + cluster = { + [IASACE.ID] = { + [IASACE.server.commands.Arm.ID] = handle_arm_command, + [IASACE.server.commands.GetPanelStatus.ID] = handle_get_panel_status, + }, + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, + } + }, + capability_handlers = { + [SecuritySystem.ID] = { + [SecuritySystem.commands.armAway.NAME] = handle_arm_away, + [SecuritySystem.commands.armStay.NAME] = handle_arm_stay, + [SecuritySystem.commands.disarm.NAME] = handle_disarm, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh, + }, + }, + can_handle = require("frient-keypad.can_handle"), +} + +return frient_keypad diff --git a/drivers/SmartThings/zigbee-keypad/src/init.lua b/drivers/SmartThings/zigbee-keypad/src/init.lua new file mode 100644 index 0000000000..3f28bf5e7c --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/init.lua @@ -0,0 +1,21 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" + +local zigbee_keypad_driver = { + supported_capabilities = { + capabilities.securitySystem, + capabilities.battery, + capabilities.refresh, + capabilities.tamperAlert, + }, + sub_drivers = require("sub_drivers"), + health_check = false, +} + +defaults.register_for_default_handlers(zigbee_keypad_driver, zigbee_keypad_driver.supported_capabilities) +local keypad = ZigbeeDriver("zigbee-keypad", zigbee_keypad_driver) +keypad:run() diff --git a/drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..4a7cc64b45 --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua b/drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua new file mode 100644 index 0000000000..83cf18614b --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient-keypad"), +} +return sub_drivers \ No newline at end of file From a6b309a9b7d3f996b170eb35ec9728ddd61f1fae Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Fri, 20 Feb 2026 07:18:42 +0100 Subject: [PATCH 02/15] add exitDelay --- .../frient-keypad-security-system.yml | 85 ++++ .../zigbee-keypad/src/frient-keypad/init.lua | 469 +++++++++++++++++- .../SmartThings/zigbee-keypad/src/init.lua | 1 + 3 files changed, 546 insertions(+), 9 deletions(-) diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index 8428c169b0..dcc2fdd5a4 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -4,6 +4,8 @@ components: capabilities: - id: securitySystem version: 1 + - id: lockCodes + version: 1 - id: battery version: 1 - id: tamperAlert @@ -12,3 +14,86 @@ components: version: 1 categories: - name: SmartLock +preferences: + - name: pinMap + title: PIN Map + description: "Format: 1234:Alice,4321:Bob. Entries are added/updated; existing entries are kept." + required: false + preferenceType: string + definition: + stringType: text + default: "" + - name: rfidMap + title: RFID Map + description: "Format: ABCD1234:Alice,EFGH5678:Bob. Entries are added/updated; existing entries are kept." + required: false + preferenceType: string + definition: + stringType: text + default: "" + - name: deletePinMap + title: Delete PINs + description: "Comma-separated PINs to delete. Example: 1234,4321" + required: false + preferenceType: string + definition: + stringType: text + default: "" + - name: deleteRfidMap + title: Delete RFIDs + description: "Comma-separated RFIDs to delete. Example: ABCD1234,EFGH5678" + required: false + preferenceType: string + definition: + stringType: text + default: "" + - name: showPinSnapshot + title: Show PIN Snapshot + description: "Display the current PIN/RFID list in the device UI (sensitive)." + required: false + preferenceType: boolean + definition: + default: true + - name: minCodeLength + title: Minimum PIN Length + description: "Minimum PIN length reported to the app." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 32 + default: 4 + - name: maxCodeLength + title: Maximum PIN Length + description: "Maximum PIN length reported to the app." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 32 + default: 8 + - name: maxCodes + title: Maximum Codes + description: "Maximum number of PIN codes reported to the app." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 500 + default: 30 + - name: length + title: Length + description: "length" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 30 + default: 5 + - name: exitDelay + title: Exit Delay + description: "Turn on exit delay when arming. Duration in seconds. Default is 60 seconds. Set to 0 to disable exit delay." + required: false + preferenceType: boolean + definition: + default: false \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index 5454c4b268..5e9361660f 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -5,11 +5,14 @@ local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local utils = require "st.utils" +local json = require "st.json" local log = require "log" local PowerConfiguration = clusters.PowerConfiguration local IASACE = clusters.IASACE local SecuritySystem = capabilities.securitySystem +local LockCodes = capabilities.lockCodes local IASZone = clusters.IASZone local tamperAlert = capabilities.tamperAlert @@ -21,6 +24,20 @@ local AlarmStatus = IASACE.types.IasaceAlarmStatus local BATTERY_INIT = battery_defaults.build_linear_voltage_init(4.0, 6.0) +local LOCK_CODES_FIELD = "lockCodes" +local LOCK_CODE_PINS_FIELD = "lockCodePins" +local LOCK_CODE_LENGTH_FIELD = "lockCodeLength" + +-- Update these tables to match your local user map. +local LOCAL_USER_MAP = { + pins = { + ["1234"] = { name = "User 1", index = 1 }, + }, + rfids = { + ["ABCD1234"] = { name = "User 2", index = 2 }, + }, +} + local SECURITY_STATUS_EVENTS = { armedAway = SecuritySystem.securitySystemStatus.armedAway, armedStay = SecuritySystem.securitySystemStatus.armedStay, @@ -45,6 +62,14 @@ local STATUS_TO_PANEL = { armedAway = PanelStatus.ARMED_AWAY, armedStay = PanelStatus.ARMED_STAY, disarmed = PanelStatus.PANEL_DISARMED_READY_TO_ARM, + exitDelay = PanelStatus.EXIT_DELAY, +} + +local STATUS_TO_ACTIVITY = { + armedAway = "armed away", + armedStay = "armed stay", + disarmed = "disarmed", + exitDelay = "exit delay", } local function emit_supported(device) @@ -56,7 +81,8 @@ local function emit_status_event(device, status, extra_data) local event_factory = SECURITY_STATUS_EVENTS[status] or SecuritySystem.securitySystemStatus.disarmed local event = event_factory({ state_change = true }) if extra_data ~= nil then - device.log.info(string.format("securitySystemStatus extra data ignored (keys=%s)", table.concat((function() + device:set_field("securitySystem_last_context", extra_data, { persist = false }) + device.log.info(string.format("securitySystemStatus extra data captured (keys=%s)", table.concat((function() local keys = {} for k, _ in pairs(extra_data) do keys[#keys + 1] = tostring(k) @@ -68,17 +94,331 @@ local function emit_status_event(device, status, extra_data) device:emit_event(event) end +local function get_pref_number(value) + if type(value) == "number" then + return value + end + if type(value) == "string" and value ~= "" then + return tonumber(value) + end + return nil +end + +local function is_pin_length_valid(device, pin) + if pin == nil or pin == "" then + return false + end + local min_len = get_pref_number(device.preferences.minCodeLength) + local max_len = get_pref_number(device.preferences.maxCodeLength) + local len = string.len(tostring(pin)) + + if min_len ~= nil and len < min_len then + return false + end + if max_len ~= nil and len > max_len then + return false + end + return true +end + +local function parse_user_map(value, validator) + local map = {} + if value == nil or value == "" then + return map + end + + for pair in string.gmatch(value, "[^,]+") do + local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") + if code ~= nil and name ~= nil and code ~= "" and name ~= "" then + if validator == nil or validator(code) then + map[code] = name + end + end + end + + return map +end + +local function parse_delete_list(value) + local items = {} + if value == nil or value == "" then + return items + end + + for token in string.gmatch(value, "[^,]+") do + local code = token:match("^%s*(.-)%s*$") + if code ~= nil and code ~= "" then + items[code] = true + end + end + + return items +end + +local function get_lock_codes(device) + return device:get_field(LOCK_CODES_FIELD) or {} +end + +local function get_lock_code_pins(device) + return device:get_field(LOCK_CODE_PINS_FIELD) or {} +end + +local function build_lock_codes_payload(device, lock_codes, lock_pins) + local payload = {} + local show_pins = device.preferences.showPinSnapshot ~= false + + for slot, name in pairs(lock_codes or {}) do + local pin = lock_pins and lock_pins[slot] or nil + if show_pins and pin ~= nil and pin ~= "" then + payload[slot] = string.format("%s (%s)", name, pin) + else + payload[slot] = name + end + end + + return payload +end + +local function emit_lock_codes(device, lock_codes, lock_pins) + local payload = build_lock_codes_payload(device, lock_codes, lock_pins) + device:emit_event(LockCodes.lockCodes(json.encode(utils.deep_copy(payload)), { state_change = true }, { visibility = { displayed = true } })) +end + +local function emit_lock_code_limits(device) + local min_len = get_pref_number(device.preferences.minCodeLength) + local max_len = get_pref_number(device.preferences.maxCodeLength) + local max_codes = get_pref_number(device.preferences.maxCodes) + local code_len = device:get_field(LOCK_CODE_LENGTH_FIELD) + + if min_len ~= nil then + device:emit_event(LockCodes.minCodeLength(min_len, { visibility = { displayed = false } })) + end + if max_len ~= nil then + device:emit_event(LockCodes.maxCodeLength(max_len, { visibility = { displayed = false } })) + end + if max_codes ~= nil then + device:emit_event(LockCodes.maxCodes(max_codes, { visibility = { displayed = false } })) + end + if code_len ~= nil then + device:emit_event(LockCodes.codeLength(code_len, { visibility = { displayed = false } })) + end +end + +local function get_next_index(map_section) + local max_index = 0 + for _, entry in pairs(map_section or {}) do + if type(entry.index) == "number" and entry.index > max_index then + max_index = entry.index + end + end + return max_index + 1 +end + +local function merge_user_section(base_section, updates) + local merged = {} + for code, entry in pairs(base_section or {}) do + merged[code] = { name = entry.name, index = entry.index } + end + + local next_index = get_next_index(merged) + for code, name in pairs(updates or {}) do + local existing = merged[code] + if existing ~= nil then + existing.name = name + else + merged[code] = { name = name, index = next_index } + next_index = next_index + 1 + end + end + + return merged +end + +local function update_user_map_from_prefs(device, base_map) + local pin_updates = parse_user_map(device.preferences.pinMap, function(pin) + if is_pin_length_valid(device, pin) then + return true + end + log.warn(string.format("Ignoring pinMap entry with invalid length (pin=%s)", tostring(pin))) + return false + end) + local rfid_updates = parse_user_map(device.preferences.rfidMap) + local delete_pins = parse_delete_list(device.preferences.deletePinMap) + local delete_rfids = parse_delete_list(device.preferences.deleteRfidMap) + + if next(pin_updates) == nil and next(rfid_updates) == nil and next(delete_pins) == nil and next(delete_rfids) == nil then + return base_map + end + + local map = { + pins = merge_user_section(base_map and base_map.pins or {}, pin_updates), + rfids = merge_user_section(base_map and base_map.rfids or {}, rfid_updates), + } + + for pin, _ in pairs(delete_pins) do + if map.pins[pin] ~= nil then + map.pins[pin] = nil + end + end + for rfid, _ in pairs(delete_rfids) do + if map.rfids[rfid] ~= nil then + map.rfids[rfid] = nil + end + end + + return map +end + +local function get_user_map(device) + local map = device:get_field("securitySystem_user_map") + if map == nil then + map = update_user_map_from_prefs(device, LOCAL_USER_MAP) + device:set_field("securitySystem_user_map", map, { persist = true }) + end + return map +end + +local function emit_code_changed(device, code_slot, change_type, code_name) + local event = LockCodes.codeChanged(tostring(code_slot) .. change_type, { state_change = true }) + if code_name ~= nil then + event.data = { codeName = code_name } + end + device:emit_event(event) +end + +local function sync_lock_codes_from_user_map(device, map) + local lock_codes = utils.deep_copy(get_lock_codes(device)) + local lock_pins = utils.deep_copy(get_lock_code_pins(device)) + + for slot, pin in pairs(lock_pins or {}) do + if map.pins[pin] == nil then + lock_pins[slot] = nil + lock_codes[slot] = nil + emit_code_changed(device, slot, " deleted", nil) + end + end + + for pin, entry in pairs(map.pins or {}) do + if entry.index ~= nil then + local slot = tostring(entry.index) + lock_codes[slot] = entry.name or lock_codes[slot] or ("Code " .. slot) + lock_pins[slot] = pin + end + end + + device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) + device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) + emit_lock_codes(device, lock_codes, lock_pins) +end + +local function resolve_user_from_code(device, code) + local map = get_user_map(device) + if map.pins ~= nil and map.pins[code] ~= nil then + return map.pins[code], "pin" + end + if map.rfids ~= nil and map.rfids[code] ~= nil then + return map.rfids[code], "rfid" + end + return nil, nil +end + +local function emit_arm_activity(device, status, user_name) + local activity = STATUS_TO_ACTIVITY[status] or status + local actor = user_name or "Unknown" + local event = LockCodes.codeChanged(string.format("%s by %s", activity, actor), { state_change = true }) + if user_name ~= nil then + event.data = { codeName = user_name } + end + device:emit_event(event) +end + +local function update_lock_code_entry(device, code_slot, code_pin, code_name) + local slot = tostring(code_slot) + local lock_codes = get_lock_codes(device) + local lock_pins = get_lock_code_pins(device) + local map = get_user_map(device) + + local change_type = lock_codes[slot] == nil and " set" or " changed" + local existing_pin = lock_pins[slot] + if existing_pin ~= nil and existing_pin ~= code_pin then + map.pins[existing_pin] = nil + end + + if code_pin ~= nil and code_pin ~= "" then + if not is_pin_length_valid(device, code_pin) then + log.warn(string.format("Rejected pin with invalid length (slot=%s, len=%d)", slot, string.len(tostring(code_pin)))) + return + end + end + + local resolved_name = code_name or lock_codes[slot] or ("Code " .. slot) + lock_codes[slot] = resolved_name + if code_pin ~= nil and code_pin ~= "" then + lock_pins[slot] = code_pin + map.pins[code_pin] = { name = resolved_name, index = tonumber(code_slot) } + end + + device:set_field("securitySystem_user_map", map, { persist = true }) + device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) + device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) + emit_code_changed(device, slot, change_type, resolved_name) + emit_lock_codes(device, lock_codes, lock_pins) +end + +local function delete_lock_code_entry(device, code_slot) + local slot = tostring(code_slot) + local lock_codes = get_lock_codes(device) + local lock_pins = get_lock_code_pins(device) + local map = get_user_map(device) + + local code_name = lock_codes[slot] + local pin = lock_pins[slot] + if pin ~= nil then + map.pins[pin] = nil + end + + lock_codes[slot] = nil + lock_pins[slot] = nil + + device:set_field("securitySystem_user_map", map, { persist = true }) + device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) + device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) + emit_code_changed(device, slot, " deleted", code_name) + emit_lock_codes(device, lock_codes, lock_pins) +end + +local function rename_lock_code_entry(device, code_slot, code_name) + local slot = tostring(code_slot) + local lock_codes = get_lock_codes(device) + local lock_pins = get_lock_code_pins(device) + local map = get_user_map(device) + + local resolved_name = code_name or lock_codes[slot] or ("Code " .. slot) + lock_codes[slot] = resolved_name + + local pin = lock_pins[slot] + if pin ~= nil and map.pins[pin] ~= nil then + map.pins[pin].name = resolved_name + end + + device:set_field("securitySystem_user_map", map, { persist = true }) + device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) + emit_code_changed(device, slot, " changed", resolved_name) + emit_lock_codes(device, lock_codes, lock_pins) +end + local function get_current_status(device) return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" end local function send_panel_status(device, status) + local length = device.preferences.length or 5 local panel_status = STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM device:send(IASACE.client.commands.PanelStatusChanged( device, panel_status, - 0x00, - AudibleNotification.MUTE, + length, + AudibleNotification.DEFAULT_SOUND, AlarmStatus.NO_ALARM )) end @@ -95,12 +435,32 @@ local function handle_arm_command(driver, device, zb_rx) return end - local data = { source = "keypad" } - if pin ~= nil and pin ~= "" then - data.pin = pin + if pin == nil or pin == "" then + log.warn("IAS ACE Arm rejected: missing pin or rfid") + return + end + + if not is_pin_length_valid(device, pin) then + log.warn(string.format("IAS ACE Arm rejected: invalid pin length (len=%d)", pin_len)) + return end + local user, auth_type = resolve_user_from_code(device, pin) + if user == nil then + log.warn("IAS ACE Arm rejected: unknown pin or rfid") + return + end + + local data = { + source = "keypad", + authType = auth_type, + userIndex = user.index, + userName = user.name, + } + device:set_field("securitySystem_last_user", data, { persist = false }) + emit_status_event(device, status, data) + emit_arm_activity(device, status, user.name) device:send(IASACE.client.commands.ArmResponse( device, ARM_MODE_TO_NOTIFICATION[cmd.arm_mode.value] or ArmNotification.ALL_ZONES_DISARMED @@ -108,31 +468,99 @@ local function handle_arm_command(driver, device, zb_rx) end local function handle_get_panel_status(driver, device, zb_rx) + local length = device.preferences.length or 5 local status = get_current_status(device) device:send(IASACE.client.commands.GetPanelStatusResponse( device, STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM, - 0x00, - AudibleNotification.MUTE, + length, + AudibleNotification.DEFAULT_SOUND, AlarmStatus.NO_ALARM )) end local function handle_arm_away(driver, device, command) emit_status_event(device, "armedAway", { source = "app" }) - send_panel_status(device, "armedAway") + emit_arm_activity(device, "armedAway", "App") + if device.preferences.exitDelay == true then + send_panel_status(device, "exitDelay") + else + send_panel_status(device, "armedAway") + end end local function handle_arm_stay(driver, device, command) emit_status_event(device, "armedStay", { source = "app" }) + emit_arm_activity(device, "armedStay", "App") send_panel_status(device, "armedStay") end local function handle_disarm(driver, device, command) emit_status_event(device, "disarmed", { source = "app" }) + emit_arm_activity(device, "disarmed", "App") send_panel_status(device, "disarmed") end +local function handle_update_codes(driver, device, command) + local codes = command.args.codes + if type(codes) == "string" then + local ok, decoded = pcall(json.decode, codes) + if ok then + codes = decoded + end + end + if type(codes) ~= "table" then + log.warn("updateCodes ignored: invalid codes payload") + return + end + + for code_slot, entry in pairs(codes) do + local slot = tonumber(code_slot) or code_slot + local code_name = nil + local code_pin = nil + if type(entry) == "table" then + code_name = entry.name or entry.codeName + code_pin = entry.pin or entry.codePIN or entry.codePin + elseif type(entry) == "string" then + code_name = entry + end + update_lock_code_entry(device, slot, code_pin, code_name) + end +end + +local function handle_set_code(driver, device, command) + update_lock_code_entry(device, command.args.codeSlot, command.args.codePIN, command.args.codeName) +end + +local function handle_delete_code(driver, device, command) + delete_lock_code_entry(device, command.args.codeSlot) +end + +local function handle_name_slot(driver, device, command) + rename_lock_code_entry(device, command.args.codeSlot, command.args.codeName) +end + +local function handle_reload_all_codes(driver, device, command) + emit_lock_codes(device, get_lock_codes(device), get_lock_code_pins(device)) +end + +local function handle_request_code(driver, device, command) + local slot = command.args.codeSlot + device:emit_event(LockCodes.codeReport({ value = slot }, { state_change = true })) +end + +local function handle_set_code_length(driver, device, command) + local length = command.args.length + if type(length) ~= "number" then + length = tonumber(length) + end + if length == nil then + return + end + device:set_field(LOCK_CODE_LENGTH_FIELD, length, { persist = true }) + device:emit_event(LockCodes.codeLength(length, { state_change = true })) +end + local function refresh(driver, device, command) device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) send_panel_status(device, get_current_status(device)) @@ -154,6 +582,19 @@ end local function device_init(driver, device) BATTERY_INIT(driver, device) emit_supported(device) + local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP + local map = update_user_map_from_prefs(device, base_map) + device:set_field("securitySystem_user_map", map, { persist = true }) + sync_lock_codes_from_user_map(device, map) + emit_lock_code_limits(device) +end + +local function info_changed(driver, device, event, args) + local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP + local map = update_user_map_from_prefs(device, base_map) + device:set_field("securitySystem_user_map", map, { persist = true }) + sync_lock_codes_from_user_map(device, map) + emit_lock_code_limits(device) end local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) @@ -179,6 +620,7 @@ local frient_keypad = { added = device_added, doConfigure = do_configure, init = device_init, + infoChanged = info_changed, }, zigbee_handlers = { cluster = { @@ -202,6 +644,15 @@ local frient_keypad = { [SecuritySystem.commands.armStay.NAME] = handle_arm_stay, [SecuritySystem.commands.disarm.NAME] = handle_disarm, }, + [LockCodes.ID] = { + [LockCodes.commands.updateCodes.NAME] = handle_update_codes, + [LockCodes.commands.deleteCode.NAME] = handle_delete_code, + [LockCodes.commands.setCode.NAME] = handle_set_code, + [LockCodes.commands.reloadAllCodes.NAME] = handle_reload_all_codes, + [LockCodes.commands.requestCode.NAME] = handle_request_code, + [LockCodes.commands.setCodeLength.NAME] = handle_set_code_length, + [LockCodes.commands.nameSlot.NAME] = handle_name_slot, + }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = refresh, }, diff --git a/drivers/SmartThings/zigbee-keypad/src/init.lua b/drivers/SmartThings/zigbee-keypad/src/init.lua index 3f28bf5e7c..63a8697909 100644 --- a/drivers/SmartThings/zigbee-keypad/src/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/init.lua @@ -11,6 +11,7 @@ local zigbee_keypad_driver = { capabilities.battery, capabilities.refresh, capabilities.tamperAlert, + capabilities.lockCodes, }, sub_drivers = require("sub_drivers"), health_check = false, From d68ab14ca2755d237fe7d3a23ba014dc54ad30db Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Wed, 25 Feb 2026 10:23:48 +0100 Subject: [PATCH 03/15] Almost finished, no tests --- .../zigbee-keypad/fingerprints.yml | 15 + .../frient-keypad-security-system.yml | 65 ++- .../src/frient-keypad/can_handle.lua | 7 +- .../src/frient-keypad/fingerprints.lua | 11 + .../zigbee-keypad/src/frient-keypad/init.lua | 398 +++++++++++++++--- 5 files changed, 406 insertions(+), 90 deletions(-) create mode 100644 drivers/SmartThings/zigbee-keypad/src/frient-keypad/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-keypad/fingerprints.yml b/drivers/SmartThings/zigbee-keypad/fingerprints.yml index 6048ae596a..e51e68d381 100644 --- a/drivers/SmartThings/zigbee-keypad/fingerprints.yml +++ b/drivers/SmartThings/zigbee-keypad/fingerprints.yml @@ -4,3 +4,18 @@ zigbeeManufacturer: manufacturer: "frient A/S" model: KEPZB-110 deviceProfileName: frient-keypad-security-system + - id: "frient A/S/KEPZB-112" + deviceLabel: "frient Alarm Keypad" + manufacturer: "frient A/S" + model: KEPZB-112 + deviceProfileName: frient-keypad-security-system + - id: "frient A/S/KEPZB-120" + deviceLabel: "frient Intelligent Keypad" + manufacturer: "frient A/S" + model: KEPZB-120 + deviceProfileName: frient-keypad-security-system + - id: "frient A/S/KEPZB-122" + deviceLabel: "frient Alarm Keypad" + manufacturer: "frient A/S" + model: KEPZB-122 + deviceProfileName: frient-keypad-security-system diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index dcc2fdd5a4..5bb816531c 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -16,7 +16,7 @@ components: - name: SmartLock preferences: - name: pinMap - title: PIN Map + title: Add PIN code description: "Format: 1234:Alice,4321:Bob. Entries are added/updated; existing entries are kept." required: false preferenceType: string @@ -24,7 +24,7 @@ preferences: stringType: text default: "" - name: rfidMap - title: RFID Map + title: Add RFID description: "Format: ABCD1234:Alice,EFGH5678:Bob. Entries are added/updated; existing entries are kept." required: false preferenceType: string @@ -56,7 +56,7 @@ preferences: default: true - name: minCodeLength title: Minimum PIN Length - description: "Minimum PIN length reported to the app." + description: "Minimum PIN length." required: false preferenceType: integer definition: @@ -65,35 +65,64 @@ preferences: default: 4 - name: maxCodeLength title: Maximum PIN Length - description: "Maximum PIN length reported to the app." + description: "Maximum PIN length." required: false preferenceType: integer definition: minimum: 1 maximum: 32 - default: 8 - - name: maxCodes - title: Maximum Codes - description: "Maximum number of PIN codes reported to the app." + default: 10 + - name: exitDelay + title: Exit Delay + description: "Turn on exit delay when arming. Duration in seconds can be set in the 'Exit Delay Length' setting." required: false - preferenceType: integer + preferenceType: boolean definition: - minimum: 1 - maximum: 500 - default: 30 + default: false - name: length - title: Length - description: "length" + title: Exit Delay Length + description: "Exit delay length in seconds." required: false preferenceType: integer definition: minimum: 0 maximum: 30 default: 5 - - name: exitDelay - title: Exit Delay - description: "Turn on exit delay when arming. Duration in seconds. Default is 60 seconds. Set to 0 to disable exit delay." + - name: autoArmDisarmMode + title: Auto Arm/Disarm Mode + description: "Automatically arm/disarm without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'Pin'." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "RFID" + 2: "Pin" + default: 0 + - name: autoDisarmModeSetting + title: Auto Disarm Mode Setting + description: "When Auto Arm/Disarm Mode is set to 'Pin' or 'RFID', automatically disarm when a valid PIN/RFID is used." required: false preferenceType: boolean definition: - default: false \ No newline at end of file + default: false + - name: autoArmModeSetting + title: Auto Arm Mode Setting + description: "When Auto Arm/Disarm Mode is set to 'Pin' or 'RFID', automatically arm in one of the following modes: 'Disabled', 'Arm Stay', 'Arm Away'." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Arm Away" + 3: "Arm Stay" + default: 0 + - name: pinLengthSetting + title: PIN Length Setting + description: "When Auto Arm/Disarm Mode is set to 'Pin', the length of PINs that will trigger auto arm/disarm." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 32 + default: 4 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua index d9bbbccafb..9401f22403 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua @@ -2,8 +2,11 @@ -- Licensed under the Apache License, Version 2.0 local function frient_keypad_can_handle(opts, driver, device, ...) - if device:get_manufacturer() == "frient A/S" and device:get_model() == "KEPZB-110" then - return true, require("frient-keypad") + local FINGERPRINTS = require("frient-keypad.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("frient-keypad") + end end return false end diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/fingerprints.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/fingerprints.lua new file mode 100644 index 0000000000..d7cb5d7b33 --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_DEVICE_FINGERPRINTS = { + { mfr = "frient A/S", model = "KEPZB-110"}, + { mfr = "frient A/S", model = "KEPZB-112"}, + { mfr = "frient A/S", model = "KEPZB-120"}, + { mfr = "frient A/S", model = "KEPZB-122"}, +} + +return FRIENT_DEVICE_FINGERPRINTS \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index 5e9361660f..0ff8fd5e0b 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -8,6 +8,8 @@ local battery_defaults = require "st.zigbee.defaults.battery_defaults" local utils = require "st.utils" local json = require "st.json" local log = require "log" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" local PowerConfiguration = clusters.PowerConfiguration local IASACE = clusters.IASACE @@ -27,14 +29,16 @@ local BATTERY_INIT = battery_defaults.build_linear_voltage_init(4.0, 6.0) local LOCK_CODES_FIELD = "lockCodes" local LOCK_CODE_PINS_FIELD = "lockCodePins" local LOCK_CODE_LENGTH_FIELD = "lockCodeLength" +--[[ local LOCK_CODES_AT_LIMIT_FIELD = "lockCodesAtLimit" +local DEFAULT_MAX_CODES = 30 ]] +local armCommandFromKeypad = false +local DEVELCO_MANUFACTURER_CODE = 0x1015 -- Update these tables to match your local user map. local LOCAL_USER_MAP = { pins = { - ["1234"] = { name = "User 1", index = 1 }, }, rfids = { - ["ABCD1234"] = { name = "User 2", index = 2 }, }, } @@ -99,7 +103,14 @@ local function get_pref_number(value) return value end if type(value) == "string" and value ~= "" then - return tonumber(value) + local parsed = tonumber(value) + if parsed ~= nil then + return parsed + end + local numeric_fragment = value:match("[-+]?%d+%.?%d*") + if numeric_fragment ~= nil then + return tonumber(numeric_fragment) + end end return nil end @@ -119,22 +130,40 @@ local function is_pin_length_valid(device, pin) return false end return true +end--[[ + + +local function currentCodesCount(device) + local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP + local count = 0 + for _, _ in pairs(base_map) do + count = count + 1 + end + return count end +local function is_below_limit(device) + return device.preferences.maxCodes > currentCodesCount(device) +end ]] + local function parse_user_map(value, validator) local map = {} - if value == nil or value == "" then - return map - end + --[[ if is_below_limit(device) then ]] + if value == nil or value == "" then + return map + end - for pair in string.gmatch(value, "[^,]+") do - local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") - if code ~= nil and name ~= nil and code ~= "" and name ~= "" then - if validator == nil or validator(code) then - map[code] = name + for pair in string.gmatch(value, "[^,]+") do + local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") + if code ~= nil and name ~= nil and code ~= "" and name ~= "" then + if validator == nil or validator(code) then + map[code] = name + end end end - end + --[[ else + log.error("I chuj") + end ]] return map end @@ -161,7 +190,32 @@ end local function get_lock_code_pins(device) return device:get_field(LOCK_CODE_PINS_FIELD) or {} -end +end--[[ + +local function get_max_codes_limit(device) + local max_codes = get_pref_number(device.preferences.maxCodes) + if max_codes == nil then + max_codes = get_pref_number(device:get_latest_state("main", LockCodes.ID, LockCodes.maxCodes.NAME)) + end + if max_codes == nil then + max_codes = DEFAULT_MAX_CODES + end + + max_codes = math.max(1, math.floor(max_codes)) + local state_max_codes = get_pref_number(device:get_latest_state("main", LockCodes.ID, LockCodes.maxCodes.NAME)) + if state_max_codes == nil or math.floor(state_max_codes) ~= max_codes then + device:emit_event(LockCodes.maxCodes(max_codes, { visibility = { displayed = false } })) + end + return max_codes +end ]] + +--[[ local function get_lock_code_count(lock_codes) + local count = 0 + for _, _ in pairs(lock_codes or {}) do + count = count + 1 + end + return count +end ]] local function build_lock_codes_payload(device, lock_codes, lock_pins) local payload = {} @@ -170,7 +224,7 @@ local function build_lock_codes_payload(device, lock_codes, lock_pins) for slot, name in pairs(lock_codes or {}) do local pin = lock_pins and lock_pins[slot] or nil if show_pins and pin ~= nil and pin ~= "" then - payload[slot] = string.format("%s (%s)", name, pin) + payload[slot] = string.format("%s: %s", name, pin) else payload[slot] = name end @@ -187,7 +241,7 @@ end local function emit_lock_code_limits(device) local min_len = get_pref_number(device.preferences.minCodeLength) local max_len = get_pref_number(device.preferences.maxCodeLength) - local max_codes = get_pref_number(device.preferences.maxCodes) + --[[ local max_codes = get_max_codes_limit(device) ]] local code_len = device:get_field(LOCK_CODE_LENGTH_FIELD) if min_len ~= nil then @@ -195,10 +249,10 @@ local function emit_lock_code_limits(device) end if max_len ~= nil then device:emit_event(LockCodes.maxCodeLength(max_len, { visibility = { displayed = false } })) - end + end--[[ if max_codes ~= nil then device:emit_event(LockCodes.maxCodes(max_codes, { visibility = { displayed = false } })) - end + end ]] if code_len ~= nil then device:emit_event(LockCodes.codeLength(code_len, { visibility = { displayed = false } })) end @@ -234,7 +288,7 @@ local function merge_user_section(base_section, updates) return merged end -local function update_user_map_from_prefs(device, base_map) +--[[ local function update_user_map_from_prefs(device, base_map) local pin_updates = parse_user_map(device.preferences.pinMap, function(pin) if is_pin_length_valid(device, pin) then return true @@ -267,15 +321,10 @@ local function update_user_map_from_prefs(device, base_map) end return map -end +end ]] local function get_user_map(device) - local map = device:get_field("securitySystem_user_map") - if map == nil then - map = update_user_map_from_prefs(device, LOCAL_USER_MAP) - device:set_field("securitySystem_user_map", map, { persist = true }) - end - return map + return device:get_field("securitySystem_user_map") end local function emit_code_changed(device, code_slot, change_type, code_name) @@ -284,31 +333,93 @@ local function emit_code_changed(device, code_slot, change_type, code_name) event.data = { codeName = code_name } end device:emit_event(event) +end--[[ + +local function emit_code_failed(device, code_slot, reason) + local event = LockCodes.codeChanged(tostring(code_slot) .. " failed", { state_change = true }) + if reason ~= nil and reason ~= "" then + event.data = { codeName = reason } + end + device:emit_event(event) end +local function emit_capacity_state(device, lock_codes) + local max_codes = get_max_codes_limit(device) + if max_codes == nil then + return + end + log.error("no i szto?") + + local current_count = get_lock_code_count(lock_codes) + local at_limit = current_count >= max_codes + local was_at_limit = device:get_field(LOCK_CODES_AT_LIMIT_FIELD) == true + + if at_limit and not was_at_limit then + emit_code_failed(device, max_codes + 1, string.format("Maximum number of codes reached (%d)", max_codes)) + end + + device:set_field(LOCK_CODES_AT_LIMIT_FIELD, at_limit, { persist = false }) +end ]] + local function sync_lock_codes_from_user_map(device, map) - local lock_codes = utils.deep_copy(get_lock_codes(device)) - local lock_pins = utils.deep_copy(get_lock_code_pins(device)) + --[[ local max_codes = get_max_codes_limit(device) ]] + local previous_lock_codes = utils.deep_copy(get_lock_codes(device)) + local previous_lock_pins = utils.deep_copy(get_lock_code_pins(device)) + local lock_codes = {} + local lock_pins = {} + local used_slots = {} + + local entries = {} + for pin, entry in pairs(map.pins or {}) do + entries[#entries + 1] = { + pin = pin, + name = entry.name, + index = tonumber(entry.index), + } + end - for slot, pin in pairs(lock_pins or {}) do - if map.pins[pin] == nil then - lock_pins[slot] = nil - lock_codes[slot] = nil - emit_code_changed(device, slot, " deleted", nil) + table.sort(entries, function(left, right) + local left_index = left.index or math.huge + local right_index = right.index or math.huge + if left_index == right_index then + return tostring(left.pin) < tostring(right.pin) end + return left_index < right_index + end) + + local next_slot = 1 + for _, entry in ipairs(entries) do + local slot_index = entry.index + if slot_index == nil or slot_index < 1 or used_slots[slot_index] then + while used_slots[next_slot] do + next_slot = next_slot + 1 + end + slot_index = next_slot + end + + used_slots[slot_index] = true + map.pins[entry.pin].index = slot_index + + local slot = tostring(slot_index) + lock_pins[slot] = entry.pin + lock_codes[slot] = entry.name or previous_lock_codes[slot] or ("Code " .. slot) end - for pin, entry in pairs(map.pins or {}) do - if entry.index ~= nil then - local slot = tostring(entry.index) - lock_codes[slot] = entry.name or lock_codes[slot] or ("Code " .. slot) - lock_pins[slot] = pin + for slot, pin in pairs(previous_lock_pins or {}) do + if pin ~= nil and lock_pins[slot] == nil then + emit_code_changed(device, slot, " deleted", nil) end end + log.error("previous lock codes " .. json.encode(previous_lock_codes)) + log.error("previous lock pins " .. json.encode(previous_lock_pins)) + log.error("lock codes" .. json.encode(lock_codes)) + log.error("lock pins " .. json.encode(lock_pins)) + device:set_field("securitySystem_user_map", map, { persist = true }) device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) emit_lock_codes(device, lock_codes, lock_pins) + --[[ emit_capacity_state(device, lock_codes) ]] end local function resolve_user_from_code(device, code) @@ -332,12 +443,28 @@ local function emit_arm_activity(device, status, user_name) device:emit_event(event) end -local function update_lock_code_entry(device, code_slot, code_pin, code_name) +--[[ local function update_lock_code_entry(device, code_slot, code_pin, code_name) local slot = tostring(code_slot) local lock_codes = get_lock_codes(device) local lock_pins = get_lock_code_pins(device) local map = get_user_map(device) + local max_codes = get_max_codes_limit(device) + local numeric_slot = tonumber(code_slot) + log.error("Twój stary") + if lock_codes[slot] == nil and max_codes ~= nil and numeric_slot ~= nil and numeric_slot > max_codes then + local message = string.format("Cannot add code slot %s: slot exceeds maxCodes (%d)", slot, max_codes) + device.log.warn(message) + emit_code_failed(device, slot, string.format("Max codes limit (%d) reached", max_codes)) + return + end + if lock_codes[slot] == nil and max_codes ~= nil and get_lock_code_count(lock_codes) >= max_codes then + local message = string.format("Cannot add code slot %s: maxCodes limit (%d) reached", slot, max_codes) + device.log.warn(message) + emit_code_failed(device, slot, string.format("Max codes limit (%d) reached", max_codes)) + return + end + local change_type = lock_codes[slot] == nil and " set" or " changed" local existing_pin = lock_pins[slot] if existing_pin ~= nil and existing_pin ~= code_pin then @@ -363,6 +490,7 @@ local function update_lock_code_entry(device, code_slot, code_pin, code_name) device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) emit_code_changed(device, slot, change_type, resolved_name) emit_lock_codes(device, lock_codes, lock_pins) + emit_capacity_state(device, lock_codes) end local function delete_lock_code_entry(device, code_slot) @@ -385,6 +513,7 @@ local function delete_lock_code_entry(device, code_slot) device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) emit_code_changed(device, slot, " deleted", code_name) emit_lock_codes(device, lock_codes, lock_pins) + emit_capacity_state(device, lock_codes) end local function rename_lock_code_entry(device, code_slot, code_name) @@ -405,7 +534,8 @@ local function rename_lock_code_entry(device, code_slot, code_name) device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) emit_code_changed(device, slot, " changed", resolved_name) emit_lock_codes(device, lock_codes, lock_pins) -end + emit_capacity_state(device, lock_codes) +end ]] local function get_current_status(device) return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" @@ -423,7 +553,16 @@ local function send_panel_status(device, status) )) end +local function can_process_arm_command(command, status) + if command == status then + return false + else + return true + end +end + local function handle_arm_command(driver, device, zb_rx) + armCommandFromKeypad = true local cmd = zb_rx.body.zcl_body local pin = cmd.arm_disarm_code.value local pin_len = pin ~= nil and string.len(pin) or 0 @@ -447,9 +586,11 @@ local function handle_arm_command(driver, device, zb_rx) local user, auth_type = resolve_user_from_code(device, pin) if user == nil then + device:emit_event(LockCodes.codeChanged(tostring(pin) .. " is not assigned to any user on this keypad. You can create a new user with this code in settings.", { state_change = true })) log.warn("IAS ACE Arm rejected: unknown pin or rfid") return end + log.error("Dupsko") local data = { source = "keypad", @@ -458,13 +599,33 @@ local function handle_arm_command(driver, device, zb_rx) userName = user.name, } device:set_field("securitySystem_last_user", data, { persist = false }) - - emit_status_event(device, status, data) - emit_arm_activity(device, status, user.name) - device:send(IASACE.client.commands.ArmResponse( - device, - ARM_MODE_TO_NOTIFICATION[cmd.arm_mode.value] or ArmNotification.ALL_ZONES_DISARMED - )) + if can_process_arm_command(status, get_current_status(device)) then + if device.preferences.exitDelay == true and status ~= "disarmed" then + log.error("Twój stary") + send_panel_status(device, "exitDelay") + device.thread:call_with_delay(device.preferences.length or 5, function() + emit_status_event(device, status, data) + emit_arm_activity(device, status, user.name) + device:send(IASACE.client.commands.ArmResponse( + device, + ARM_MODE_TO_NOTIFICATION[cmd.arm_mode.value] or ArmNotification.ALL_ZONES_DISARMED + )) + end) + else + emit_status_event(device, status, data) + emit_arm_activity(device, status, user.name) + device:send(IASACE.client.commands.ArmResponse( + device, + ARM_MODE_TO_NOTIFICATION[cmd.arm_mode.value] or ArmNotification.ALL_ZONES_DISARMED + )) + end + else + log.info("Arm command ignored: already in target state or incompatible state") + device:send(IASACE.client.commands.ArmResponse( + device, + 0xFF + )) + end end local function handle_get_panel_status(driver, device, zb_rx) @@ -479,29 +640,51 @@ local function handle_get_panel_status(driver, device, zb_rx) )) end -local function handle_arm_away(driver, device, command) - emit_status_event(device, "armedAway", { source = "app" }) - emit_arm_activity(device, "armedAway", "App") - if device.preferences.exitDelay == true then - send_panel_status(device, "exitDelay") +local function handle_arm(device, status) + local length = device.preferences.length or 5 + if not armCommandFromKeypad and can_process_arm_command(status, get_current_status(device)) then + if device.preferences.exitDelay == true then + send_panel_status(device, "exitDelay") + device.thread:call_with_delay(length, function() + log.error("Shalom") + emit_status_event(device, status, { source = "app" }) + emit_arm_activity(device, status, "App") + send_panel_status(device, status) + end) + else + emit_status_event(device, status, { source = "app" }) + emit_arm_activity(device, status, "App") + send_panel_status(device, status) + end else - send_panel_status(device, "armedAway") + armCommandFromKeypad = false + return end + armCommandFromKeypad = false +end + +local function handle_arm_away(driver, device, command) + handle_arm(device, "armedAway") end local function handle_arm_stay(driver, device, command) - emit_status_event(device, "armedStay", { source = "app" }) - emit_arm_activity(device, "armedStay", "App") - send_panel_status(device, "armedStay") + handle_arm(device, "armedStay") end local function handle_disarm(driver, device, command) - emit_status_event(device, "disarmed", { source = "app" }) - emit_arm_activity(device, "disarmed", "App") - send_panel_status(device, "disarmed") + if can_process_arm_command("disarmed", get_current_status(device)) and not armCommandFromKeypad then + emit_status_event(device, "disarmed", { source = "app" }) + emit_arm_activity(device, "disarmed", "App") + send_panel_status(device, "disarmed") + else + armCommandFromKeypad = false + return + end + armCommandFromKeypad = false end -local function handle_update_codes(driver, device, command) +--[[ local function handle_update_codes(driver, device, command) + log.error("W końcu cię dorwę gnoju") local codes = command.args.codes if type(codes) == "string" then local ok, decoded = pcall(json.decode, codes) @@ -526,30 +709,36 @@ local function handle_update_codes(driver, device, command) end update_lock_code_entry(device, slot, code_pin, code_name) end -end +end ]] -local function handle_set_code(driver, device, command) +--[[ local function handle_set_code(driver, device, command) + log.error("Działa to w ogóle?") update_lock_code_entry(device, command.args.codeSlot, command.args.codePIN, command.args.codeName) end local function handle_delete_code(driver, device, command) + log.error("Działa to w ogóle?") delete_lock_code_entry(device, command.args.codeSlot) end local function handle_name_slot(driver, device, command) + log.error("Działa to w ogóle?") rename_lock_code_entry(device, command.args.codeSlot, command.args.codeName) end local function handle_reload_all_codes(driver, device, command) + log.error("Działa to w ogóle?") emit_lock_codes(device, get_lock_codes(device), get_lock_code_pins(device)) end local function handle_request_code(driver, device, command) + log.error("Działa to w ogóle?") local slot = command.args.codeSlot device:emit_event(LockCodes.codeReport({ value = slot }, { state_change = true })) end local function handle_set_code_length(driver, device, command) + log.error("Działa to w ogóle?") local length = command.args.length if type(length) ~= "number" then length = tonumber(length) @@ -559,7 +748,7 @@ local function handle_set_code_length(driver, device, command) end device:set_field(LOCK_CODE_LENGTH_FIELD, length, { persist = true }) device:emit_event(LockCodes.codeLength(length, { state_change = true })) -end +end ]] local function refresh(driver, device, command) device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) @@ -583,18 +772,87 @@ local function device_init(driver, device) BATTERY_INIT(driver, device) emit_supported(device) local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP - local map = update_user_map_from_prefs(device, base_map) - device:set_field("securitySystem_user_map", map, { persist = true }) - sync_lock_codes_from_user_map(device, map) + device:set_field("securitySystem_user_map", base_map, { persist = true }) + sync_lock_codes_from_user_map(device, base_map) emit_lock_code_limits(device) end +local function send_iasace_mfg_write(device, attr_id, data_type, payload) + local msg = cluster_base.write_manufacturer_specific_attribute(device, IASACE.ID, attr_id, DEVELCO_MANUFACTURER_CODE, data_type, payload) + msg.body.zcl_header.frame_ctrl:set_direction_client() + device:send(msg) +end + local function info_changed(driver, device, event, args) local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP - local map = update_user_map_from_prefs(device, base_map) - device:set_field("securitySystem_user_map", map, { persist = true }) - sync_lock_codes_from_user_map(device, map) emit_lock_code_limits(device) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "pinMap") then + local pin_updates = parse_user_map(device.preferences.pinMap, function(pin) + if is_pin_length_valid(device, pin) then + return true + end + log.warn(string.format("Ignoring pinMap entry with invalid length (pin=%s)", tostring(pin))) + return false + end) + local map = { + pins = merge_user_section(base_map and base_map.pins or {}, pin_updates), + rfids = merge_user_section(base_map and base_map.rfids or {}, base_map.rfids or {}), + } + device:set_field("securitySystem_user_map", map, { persist = true }) + sync_lock_codes_from_user_map(device, map) + end + if (name == "rfidMap") then + local rfid_updates = parse_user_map(device.preferences.rfidMap) + local map = { + pins = merge_user_section(base_map and base_map.pins or {}, base_map.pins or {}), + rfids = merge_user_section(base_map and base_map.rfids or {}, rfid_updates), + } + device:set_field("securitySystem_user_map", map, { persist = true }) + sync_lock_codes_from_user_map(device, map) + end + if (name == "deletePinMap") then + local delete_pins = parse_delete_list(device.preferences.deletePinMap) + for pin, _ in pairs(delete_pins) do + if base_map.pins[pin] ~= nil then + base_map.pins[pin] = nil + end + end + device:set_field("securitySystem_user_map", base_map, { persist = true }) + sync_lock_codes_from_user_map(device, base_map) + end + if (name == "deleteRfidMap") then + local delete_rfids = parse_delete_list(device.preferences.deleteRfidMap) + for rfid, _ in pairs(delete_rfids) do + if base_map.rfids[rfid] ~= nil then + base_map.rfids[rfid] = nil + end + end + device:set_field("securitySystem_user_map", base_map, { persist = true }) + sync_lock_codes_from_user_map(device, base_map) + end + if (name == "autoArmDisarmMode") then + local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) + if autoArmDisarmMode ~= nil then + send_iasace_mfg_write(device, 0x8003, data_types.Enum8, autoArmDisarmMode) + end + elseif (name == "autoDisarmModeSetting") then + local autoDisarmModeSetting = device.preferences.autoDisarmModeSetting + send_iasace_mfg_write(device, 0x8004, data_types.Boolean, autoDisarmModeSetting) + elseif (name == "autoArmModeSetting") then + local autoArmModeSetting = tonumber(device.preferences.autoArmModeSetting) + if autoArmModeSetting ~= nil then + send_iasace_mfg_write(device, 0x8005, data_types.Enum8, autoArmModeSetting) + end + elseif (name == "pinLengthSetting") then + local pinLengthSetting = tonumber(device.preferences.pinLengthSetting) + if pinLengthSetting ~= nil then + send_iasace_mfg_write(device, 0x8006, data_types.Uint8, pinLengthSetting) + end + end + end + end end local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) @@ -643,7 +901,7 @@ local frient_keypad = { [SecuritySystem.commands.armAway.NAME] = handle_arm_away, [SecuritySystem.commands.armStay.NAME] = handle_arm_stay, [SecuritySystem.commands.disarm.NAME] = handle_disarm, - }, + },--[[ [LockCodes.ID] = { [LockCodes.commands.updateCodes.NAME] = handle_update_codes, [LockCodes.commands.deleteCode.NAME] = handle_delete_code, @@ -652,7 +910,7 @@ local frient_keypad = { [LockCodes.commands.requestCode.NAME] = handle_request_code, [LockCodes.commands.setCodeLength.NAME] = handle_set_code_length, [LockCodes.commands.nameSlot.NAME] = handle_name_slot, - }, + }, ]] [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = refresh, }, From 0fa9c13a3341cb0f7e4c758aaa15f3653579e869 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Wed, 25 Feb 2026 13:19:43 +0100 Subject: [PATCH 04/15] split codes snapshot into chunks if length exceeds 255 --- .../zigbee-keypad/src/frient-keypad/init.lua | 434 ++++++------------ 1 file changed, 135 insertions(+), 299 deletions(-) diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index 0ff8fd5e0b..9f04bb41c9 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -29,8 +29,8 @@ local BATTERY_INIT = battery_defaults.build_linear_voltage_init(4.0, 6.0) local LOCK_CODES_FIELD = "lockCodes" local LOCK_CODE_PINS_FIELD = "lockCodePins" local LOCK_CODE_LENGTH_FIELD = "lockCodeLength" ---[[ local LOCK_CODES_AT_LIMIT_FIELD = "lockCodesAtLimit" -local DEFAULT_MAX_CODES = 30 ]] +local LOCK_CODES_MAX_LEN = 255 +local LOCK_CODES_CHUNK_MAX_LEN = 220 local armCommandFromKeypad = false local DEVELCO_MANUFACTURER_CODE = 0x1015 @@ -130,25 +130,10 @@ local function is_pin_length_valid(device, pin) return false end return true -end--[[ - - -local function currentCodesCount(device) - local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP - local count = 0 - for _, _ in pairs(base_map) do - count = count + 1 - end - return count end -local function is_below_limit(device) - return device.preferences.maxCodes > currentCodesCount(device) -end ]] - local function parse_user_map(value, validator) local map = {} - --[[ if is_below_limit(device) then ]] if value == nil or value == "" then return map end @@ -161,9 +146,6 @@ local function parse_user_map(value, validator) end end end - --[[ else - log.error("I chuj") - end ]] return map end @@ -190,32 +172,7 @@ end local function get_lock_code_pins(device) return device:get_field(LOCK_CODE_PINS_FIELD) or {} -end--[[ - -local function get_max_codes_limit(device) - local max_codes = get_pref_number(device.preferences.maxCodes) - if max_codes == nil then - max_codes = get_pref_number(device:get_latest_state("main", LockCodes.ID, LockCodes.maxCodes.NAME)) - end - if max_codes == nil then - max_codes = DEFAULT_MAX_CODES - end - - max_codes = math.max(1, math.floor(max_codes)) - local state_max_codes = get_pref_number(device:get_latest_state("main", LockCodes.ID, LockCodes.maxCodes.NAME)) - if state_max_codes == nil or math.floor(state_max_codes) ~= max_codes then - device:emit_event(LockCodes.maxCodes(max_codes, { visibility = { displayed = false } })) - end - return max_codes -end ]] - ---[[ local function get_lock_code_count(lock_codes) - local count = 0 - for _, _ in pairs(lock_codes or {}) do - count = count + 1 - end - return count -end ]] +end local function build_lock_codes_payload(device, lock_codes, lock_pins) local payload = {} @@ -233,15 +190,99 @@ local function build_lock_codes_payload(device, lock_codes, lock_pins) return payload end +local function get_sorted_slots(lock_codes) + local slots = {} + for slot, _ in pairs(lock_codes or {}) do + slots[#slots + 1] = tostring(slot) + end + + table.sort(slots, function(left, right) + local left_num = tonumber(left) + local right_num = tonumber(right) + if left_num ~= nil and right_num ~= nil then + return left_num < right_num + end + if left_num ~= nil then + return true + end + if right_num ~= nil then + return false + end + return left < right + end) + + return slots +end + +local function emit_lock_codes_chunks(device, lock_codes, lock_pins) + local chunks = {} + local current = "" + local slots = get_sorted_slots(lock_codes) + + for _, slot in ipairs(slots) do + local name = tostring(lock_codes[slot] or ("Code " .. slot)) + local pin = lock_pins and lock_pins[slot] or nil + local entry = pin and pin ~= "" and string.format("%s:%s (%s)", slot, name, pin) or string.format("%s:%s", slot, name) + + if current == "" then + current = entry + elseif (#current + 2 + #entry) <= LOCK_CODES_CHUNK_MAX_LEN then + current = current .. ", " .. entry + else + chunks[#chunks + 1] = current + current = entry + end + end + + if current ~= "" then + chunks[#chunks + 1] = current + end + + for index, chunk in ipairs(chunks) do + local message = string.format("codes %d/%d: %s", index, #chunks, chunk) + device:emit_event(LockCodes.codeChanged(message, { state_change = true })) + end +end + +local function encode_payload(payload) + local ok, encoded = pcall(json.encode, utils.deep_copy(payload)) + if ok and type(encoded) == "string" then + return encoded + end + return "{}" +end + +local function build_partial_payload(payload) + local partial = {} + local slots = get_sorted_slots(payload) + for _, slot in ipairs(slots) do + partial[slot] = tostring(payload[slot] or "") + local encoded = encode_payload(partial) + if #encoded > LOCK_CODES_MAX_LEN then + partial[slot] = nil + break + end + end + return partial +end + local function emit_lock_codes(device, lock_codes, lock_pins) - local payload = build_lock_codes_payload(device, lock_codes, lock_pins) - device:emit_event(LockCodes.lockCodes(json.encode(utils.deep_copy(payload)), { state_change = true }, { visibility = { displayed = true } })) + local full_payload = build_lock_codes_payload(device, lock_codes, lock_pins) + local full_encoded = encode_payload(full_payload) + if #full_encoded <= LOCK_CODES_MAX_LEN then + device:emit_event(LockCodes.lockCodes(full_encoded, { state_change = true }, { visibility = { displayed = true } })) + return + end + + local partial_payload = build_partial_payload(full_payload) + local partial_encoded = encode_payload(partial_payload) + device:emit_event(LockCodes.lockCodes(partial_encoded, { state_change = true }, { visibility = { displayed = true } })) + emit_lock_codes_chunks(device, lock_codes, lock_pins) end local function emit_lock_code_limits(device) local min_len = get_pref_number(device.preferences.minCodeLength) local max_len = get_pref_number(device.preferences.maxCodeLength) - --[[ local max_codes = get_max_codes_limit(device) ]] local code_len = device:get_field(LOCK_CODE_LENGTH_FIELD) if min_len ~= nil then @@ -249,10 +290,7 @@ local function emit_lock_code_limits(device) end if max_len ~= nil then device:emit_event(LockCodes.maxCodeLength(max_len, { visibility = { displayed = false } })) - end--[[ - if max_codes ~= nil then - device:emit_event(LockCodes.maxCodes(max_codes, { visibility = { displayed = false } })) - end ]] + end if code_len ~= nil then device:emit_event(LockCodes.codeLength(code_len, { visibility = { displayed = false } })) end @@ -268,18 +306,47 @@ local function get_next_index(map_section) return max_index + 1 end +local function normalize_user_name(value) + if type(value) == "string" then + return value + end + if type(value) == "table" then + if type(value.name) == "string" then + return value.name + end + if type(value.value) == "string" then + return value.value + end + end + return nil +end + +local function normalize_user_entry(entry) + if type(entry) == "table" then + return { + name = normalize_user_name(entry.name) or normalize_user_name(entry), + index = tonumber(entry.index), + } + end + return { + name = normalize_user_name(entry), + index = nil, + } +end + local function merge_user_section(base_section, updates) local merged = {} for code, entry in pairs(base_section or {}) do - merged[code] = { name = entry.name, index = entry.index } + merged[code] = normalize_user_entry(entry) end local next_index = get_next_index(merged) - for code, name in pairs(updates or {}) do + for code, value in pairs(updates or {}) do + local name = normalize_user_name(value) local existing = merged[code] - if existing ~= nil then + if existing ~= nil and name ~= nil and name ~= "" then existing.name = name - else + elseif existing == nil and name ~= nil and name ~= "" then merged[code] = { name = name, index = next_index } next_index = next_index + 1 end @@ -288,41 +355,6 @@ local function merge_user_section(base_section, updates) return merged end ---[[ local function update_user_map_from_prefs(device, base_map) - local pin_updates = parse_user_map(device.preferences.pinMap, function(pin) - if is_pin_length_valid(device, pin) then - return true - end - log.warn(string.format("Ignoring pinMap entry with invalid length (pin=%s)", tostring(pin))) - return false - end) - local rfid_updates = parse_user_map(device.preferences.rfidMap) - local delete_pins = parse_delete_list(device.preferences.deletePinMap) - local delete_rfids = parse_delete_list(device.preferences.deleteRfidMap) - - if next(pin_updates) == nil and next(rfid_updates) == nil and next(delete_pins) == nil and next(delete_rfids) == nil then - return base_map - end - - local map = { - pins = merge_user_section(base_map and base_map.pins or {}, pin_updates), - rfids = merge_user_section(base_map and base_map.rfids or {}, rfid_updates), - } - - for pin, _ in pairs(delete_pins) do - if map.pins[pin] ~= nil then - map.pins[pin] = nil - end - end - for rfid, _ in pairs(delete_rfids) do - if map.rfids[rfid] ~= nil then - map.rfids[rfid] = nil - end - end - - return map -end ]] - local function get_user_map(device) return device:get_field("securitySystem_user_map") end @@ -333,36 +365,9 @@ local function emit_code_changed(device, code_slot, change_type, code_name) event.data = { codeName = code_name } end device:emit_event(event) -end--[[ - -local function emit_code_failed(device, code_slot, reason) - local event = LockCodes.codeChanged(tostring(code_slot) .. " failed", { state_change = true }) - if reason ~= nil and reason ~= "" then - event.data = { codeName = reason } - end - device:emit_event(event) end -local function emit_capacity_state(device, lock_codes) - local max_codes = get_max_codes_limit(device) - if max_codes == nil then - return - end - log.error("no i szto?") - - local current_count = get_lock_code_count(lock_codes) - local at_limit = current_count >= max_codes - local was_at_limit = device:get_field(LOCK_CODES_AT_LIMIT_FIELD) == true - - if at_limit and not was_at_limit then - emit_code_failed(device, max_codes + 1, string.format("Maximum number of codes reached (%d)", max_codes)) - end - - device:set_field(LOCK_CODES_AT_LIMIT_FIELD, at_limit, { persist = false }) -end ]] - local function sync_lock_codes_from_user_map(device, map) - --[[ local max_codes = get_max_codes_limit(device) ]] local previous_lock_codes = utils.deep_copy(get_lock_codes(device)) local previous_lock_pins = utils.deep_copy(get_lock_code_pins(device)) local lock_codes = {} @@ -371,10 +376,11 @@ local function sync_lock_codes_from_user_map(device, map) local entries = {} for pin, entry in pairs(map.pins or {}) do + local normalized = normalize_user_entry(entry) entries[#entries + 1] = { pin = pin, - name = entry.name, - index = tonumber(entry.index), + name = normalized.name, + index = normalized.index, } end @@ -398,11 +404,13 @@ local function sync_lock_codes_from_user_map(device, map) end used_slots[slot_index] = true + map.pins[entry.pin] = map.pins[entry.pin] or {} map.pins[entry.pin].index = slot_index + map.pins[entry.pin].name = entry.name or map.pins[entry.pin].name or ("Code " .. tostring(slot_index)) local slot = tostring(slot_index) lock_pins[slot] = entry.pin - lock_codes[slot] = entry.name or previous_lock_codes[slot] or ("Code " .. slot) + lock_codes[slot] = entry.name or normalize_user_name(previous_lock_codes[slot]) or ("Code " .. slot) end for slot, pin in pairs(previous_lock_pins or {}) do @@ -419,7 +427,6 @@ local function sync_lock_codes_from_user_map(device, map) device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) emit_lock_codes(device, lock_codes, lock_pins) - --[[ emit_capacity_state(device, lock_codes) ]] end local function resolve_user_from_code(device, code) @@ -443,100 +450,6 @@ local function emit_arm_activity(device, status, user_name) device:emit_event(event) end ---[[ local function update_lock_code_entry(device, code_slot, code_pin, code_name) - local slot = tostring(code_slot) - local lock_codes = get_lock_codes(device) - local lock_pins = get_lock_code_pins(device) - local map = get_user_map(device) - - local max_codes = get_max_codes_limit(device) - local numeric_slot = tonumber(code_slot) - log.error("Twój stary") - if lock_codes[slot] == nil and max_codes ~= nil and numeric_slot ~= nil and numeric_slot > max_codes then - local message = string.format("Cannot add code slot %s: slot exceeds maxCodes (%d)", slot, max_codes) - device.log.warn(message) - emit_code_failed(device, slot, string.format("Max codes limit (%d) reached", max_codes)) - return - end - if lock_codes[slot] == nil and max_codes ~= nil and get_lock_code_count(lock_codes) >= max_codes then - local message = string.format("Cannot add code slot %s: maxCodes limit (%d) reached", slot, max_codes) - device.log.warn(message) - emit_code_failed(device, slot, string.format("Max codes limit (%d) reached", max_codes)) - return - end - - local change_type = lock_codes[slot] == nil and " set" or " changed" - local existing_pin = lock_pins[slot] - if existing_pin ~= nil and existing_pin ~= code_pin then - map.pins[existing_pin] = nil - end - - if code_pin ~= nil and code_pin ~= "" then - if not is_pin_length_valid(device, code_pin) then - log.warn(string.format("Rejected pin with invalid length (slot=%s, len=%d)", slot, string.len(tostring(code_pin)))) - return - end - end - - local resolved_name = code_name or lock_codes[slot] or ("Code " .. slot) - lock_codes[slot] = resolved_name - if code_pin ~= nil and code_pin ~= "" then - lock_pins[slot] = code_pin - map.pins[code_pin] = { name = resolved_name, index = tonumber(code_slot) } - end - - device:set_field("securitySystem_user_map", map, { persist = true }) - device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) - device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) - emit_code_changed(device, slot, change_type, resolved_name) - emit_lock_codes(device, lock_codes, lock_pins) - emit_capacity_state(device, lock_codes) -end - -local function delete_lock_code_entry(device, code_slot) - local slot = tostring(code_slot) - local lock_codes = get_lock_codes(device) - local lock_pins = get_lock_code_pins(device) - local map = get_user_map(device) - - local code_name = lock_codes[slot] - local pin = lock_pins[slot] - if pin ~= nil then - map.pins[pin] = nil - end - - lock_codes[slot] = nil - lock_pins[slot] = nil - - device:set_field("securitySystem_user_map", map, { persist = true }) - device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) - device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) - emit_code_changed(device, slot, " deleted", code_name) - emit_lock_codes(device, lock_codes, lock_pins) - emit_capacity_state(device, lock_codes) -end - -local function rename_lock_code_entry(device, code_slot, code_name) - local slot = tostring(code_slot) - local lock_codes = get_lock_codes(device) - local lock_pins = get_lock_code_pins(device) - local map = get_user_map(device) - - local resolved_name = code_name or lock_codes[slot] or ("Code " .. slot) - lock_codes[slot] = resolved_name - - local pin = lock_pins[slot] - if pin ~= nil and map.pins[pin] ~= nil then - map.pins[pin].name = resolved_name - end - - device:set_field("securitySystem_user_map", map, { persist = true }) - device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) - emit_code_changed(device, slot, " changed", resolved_name) - emit_lock_codes(device, lock_codes, lock_pins) - emit_capacity_state(device, lock_codes) -end ]] - local function get_current_status(device) return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" end @@ -646,7 +559,6 @@ local function handle_arm(device, status) if device.preferences.exitDelay == true then send_panel_status(device, "exitDelay") device.thread:call_with_delay(length, function() - log.error("Shalom") emit_status_event(device, status, { source = "app" }) emit_arm_activity(device, status, "App") send_panel_status(device, status) @@ -683,73 +595,6 @@ local function handle_disarm(driver, device, command) armCommandFromKeypad = false end ---[[ local function handle_update_codes(driver, device, command) - log.error("W końcu cię dorwę gnoju") - local codes = command.args.codes - if type(codes) == "string" then - local ok, decoded = pcall(json.decode, codes) - if ok then - codes = decoded - end - end - if type(codes) ~= "table" then - log.warn("updateCodes ignored: invalid codes payload") - return - end - - for code_slot, entry in pairs(codes) do - local slot = tonumber(code_slot) or code_slot - local code_name = nil - local code_pin = nil - if type(entry) == "table" then - code_name = entry.name or entry.codeName - code_pin = entry.pin or entry.codePIN or entry.codePin - elseif type(entry) == "string" then - code_name = entry - end - update_lock_code_entry(device, slot, code_pin, code_name) - end -end ]] - ---[[ local function handle_set_code(driver, device, command) - log.error("Działa to w ogóle?") - update_lock_code_entry(device, command.args.codeSlot, command.args.codePIN, command.args.codeName) -end - -local function handle_delete_code(driver, device, command) - log.error("Działa to w ogóle?") - delete_lock_code_entry(device, command.args.codeSlot) -end - -local function handle_name_slot(driver, device, command) - log.error("Działa to w ogóle?") - rename_lock_code_entry(device, command.args.codeSlot, command.args.codeName) -end - -local function handle_reload_all_codes(driver, device, command) - log.error("Działa to w ogóle?") - emit_lock_codes(device, get_lock_codes(device), get_lock_code_pins(device)) -end - -local function handle_request_code(driver, device, command) - log.error("Działa to w ogóle?") - local slot = command.args.codeSlot - device:emit_event(LockCodes.codeReport({ value = slot }, { state_change = true })) -end - -local function handle_set_code_length(driver, device, command) - log.error("Działa to w ogóle?") - local length = command.args.length - if type(length) ~= "number" then - length = tonumber(length) - end - if length == nil then - return - end - device:set_field(LOCK_CODE_LENGTH_FIELD, length, { persist = true }) - device:emit_event(LockCodes.codeLength(length, { state_change = true })) -end ]] - local function refresh(driver, device, command) device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) send_panel_status(device, get_current_status(device)) @@ -798,7 +643,7 @@ local function info_changed(driver, device, event, args) end) local map = { pins = merge_user_section(base_map and base_map.pins or {}, pin_updates), - rfids = merge_user_section(base_map and base_map.rfids or {}, base_map.rfids or {}), + rfids = merge_user_section(base_map and base_map.rfids or {}, {}), } device:set_field("securitySystem_user_map", map, { persist = true }) sync_lock_codes_from_user_map(device, map) @@ -806,11 +651,11 @@ local function info_changed(driver, device, event, args) if (name == "rfidMap") then local rfid_updates = parse_user_map(device.preferences.rfidMap) local map = { - pins = merge_user_section(base_map and base_map.pins or {}, base_map.pins or {}), + pins = merge_user_section(base_map and base_map.pins or {}, {}), rfids = merge_user_section(base_map and base_map.rfids or {}, rfid_updates), } device:set_field("securitySystem_user_map", map, { persist = true }) - sync_lock_codes_from_user_map(device, map) + --sync_lock_codes_from_user_map(device, map) end if (name == "deletePinMap") then local delete_pins = parse_delete_list(device.preferences.deletePinMap) @@ -830,7 +675,7 @@ local function info_changed(driver, device, event, args) end end device:set_field("securitySystem_user_map", base_map, { persist = true }) - sync_lock_codes_from_user_map(device, base_map) + --sync_lock_codes_from_user_map(device, base_map) end if (name == "autoArmDisarmMode") then local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) @@ -901,16 +746,7 @@ local frient_keypad = { [SecuritySystem.commands.armAway.NAME] = handle_arm_away, [SecuritySystem.commands.armStay.NAME] = handle_arm_stay, [SecuritySystem.commands.disarm.NAME] = handle_disarm, - },--[[ - [LockCodes.ID] = { - [LockCodes.commands.updateCodes.NAME] = handle_update_codes, - [LockCodes.commands.deleteCode.NAME] = handle_delete_code, - [LockCodes.commands.setCode.NAME] = handle_set_code, - [LockCodes.commands.reloadAllCodes.NAME] = handle_reload_all_codes, - [LockCodes.commands.requestCode.NAME] = handle_request_code, - [LockCodes.commands.setCodeLength.NAME] = handle_set_code_length, - [LockCodes.commands.nameSlot.NAME] = handle_name_slot, - }, ]] + }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = refresh, }, From a9cccce14524d50fc4bd1d4ecd7413b424359795 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Thu, 26 Feb 2026 14:02:05 +0100 Subject: [PATCH 05/15] make rfid unaffected by pin length limitation --- .../frient-keypad-security-system.yml | 44 +-- .../zigbee-keypad/src/frient-keypad/init.lua | 156 ++++---- .../SmartThings/zigbee-keypad/src/init.lua | 1 + .../test_frient_keypad_security_system.lua | 365 ++++++++++++++++++ 4 files changed, 469 insertions(+), 97 deletions(-) create mode 100644 drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index 5bb816531c..49da7da96a 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -6,6 +6,8 @@ components: version: 1 - id: lockCodes version: 1 + - id: lock + version: 1 - id: battery version: 1 - id: tamperAlert @@ -15,33 +17,27 @@ components: categories: - name: SmartLock preferences: - - name: pinMap - title: Add PIN code - description: "Format: 1234:Alice,4321:Bob. Entries are added/updated; existing entries are kept." - required: false - preferenceType: string - definition: - stringType: text - default: "" - - name: rfidMap - title: Add RFID - description: "Format: ABCD1234:Alice,EFGH5678:Bob. Entries are added/updated; existing entries are kept." + - name: mode + title: Use to control Security System Status or Lock Status + description: "Choose whether to use the keypad to control Security System Status (Arm/Disarm) or Lock Status (Lock/Unlock)." required: false - preferenceType: string + preferenceType: enumeration definition: - stringType: text - default: "" - - name: deletePinMap - title: Delete PINs - description: "Comma-separated PINs to delete. Example: 1234,4321" + options: + 0: "Security System Status" + 1: "Lock Status" + default: 0 + - name: pinMap + title: Add PIN code(s) + description: "Format: 1234:Alice,4321:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." required: false preferenceType: string definition: stringType: text default: "" - - name: deleteRfidMap - title: Delete RFIDs - description: "Comma-separated RFIDs to delete. Example: ABCD1234,EFGH5678" + - name: rfidMap + title: Add RFID(s) + description: "Format: ABCD1234:Alice,EFGH5678:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." required: false preferenceType: string definition: @@ -74,14 +70,14 @@ preferences: default: 10 - name: exitDelay title: Exit Delay - description: "Turn on exit delay when arming. Duration in seconds can be set in the 'Exit Delay Length' setting." + description: "Turn on exit delay when arming. Duration in seconds can be set in the 'Exit Delay Duration' setting." required: false preferenceType: boolean definition: default: false - - name: length - title: Exit Delay Length - description: "Exit delay length in seconds." + - name: duration + title: Exit Delay Duration + description: "Exit delay duration in seconds." required: false preferenceType: integer definition: diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index 9f04bb41c9..d3c15ba954 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -17,6 +17,7 @@ local SecuritySystem = capabilities.securitySystem local LockCodes = capabilities.lockCodes local IASZone = clusters.IASZone local tamperAlert = capabilities.tamperAlert +local lock = capabilities.lock local ArmMode = IASACE.types.ArmMode local ArmNotification = IASACE.types.ArmNotification @@ -24,11 +25,8 @@ local PanelStatus = IASACE.types.IasacePanelStatus local AudibleNotification = IASACE.types.IasaceAudibleNotification local AlarmStatus = IASACE.types.IasaceAlarmStatus -local BATTERY_INIT = battery_defaults.build_linear_voltage_init(4.0, 6.0) - local LOCK_CODES_FIELD = "lockCodes" local LOCK_CODE_PINS_FIELD = "lockCodePins" -local LOCK_CODE_LENGTH_FIELD = "lockCodeLength" local LOCK_CODES_MAX_LEN = 255 local LOCK_CODES_CHUNK_MAX_LEN = 220 local armCommandFromKeypad = false @@ -79,6 +77,8 @@ local STATUS_TO_ACTIVITY = { local function emit_supported(device) device:emit_event(SecuritySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } })) device:emit_event(SecuritySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } })) + device:emit_event(lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } })) + device:emit_event(lock.supportedLockCommands({ "lock", "unlock"}, { visibility = { displayed = false } })) end local function emit_status_event(device, status, extra_data) @@ -98,6 +98,11 @@ local function emit_status_event(device, status, extra_data) device:emit_event(event) end +local function emit_lock_event(device, lock_state) + local event = lock.lock(lock_state, { state_change = true }) + device:emit_event(event) +end + local function get_pref_number(value) if type(value) == "number" then return value @@ -116,6 +121,10 @@ local function get_pref_number(value) end local function is_pin_length_valid(device, pin) + local pinStr = tostring(pin) + if pinStr:sub(1,1) == "+" then + return true + end if pin == nil or pin == "" then return false end @@ -150,7 +159,7 @@ local function parse_user_map(value, validator) return map end -local function parse_delete_list(value) +--[[ local function parse_delete_list(value) local items = {} if value == nil or value == "" then return items @@ -164,7 +173,7 @@ local function parse_delete_list(value) end return items -end +end ]] local function get_lock_codes(device) return device:get_field(LOCK_CODES_FIELD) or {} @@ -174,6 +183,10 @@ local function get_lock_code_pins(device) return device:get_field(LOCK_CODE_PINS_FIELD) or {} end +local function get_exit_delay_duration(device) + return device:get_field("securitySystem_exit_delay_duration") or 5 +end + local function build_lock_codes_payload(device, lock_codes, lock_pins) local payload = {} local show_pins = device.preferences.showPinSnapshot ~= false @@ -214,33 +227,37 @@ local function get_sorted_slots(lock_codes) return slots end -local function emit_lock_codes_chunks(device, lock_codes, lock_pins) +local function emit_lock_codes_chunks(device, payload) local chunks = {} - local current = "" - local slots = get_sorted_slots(lock_codes) + local current_chunk = {} + local slots = get_sorted_slots(payload) - for _, slot in ipairs(slots) do - local name = tostring(lock_codes[slot] or ("Code " .. slot)) - local pin = lock_pins and lock_pins[slot] or nil - local entry = pin and pin ~= "" and string.format("%s:%s (%s)", slot, name, pin) or string.format("%s:%s", slot, name) + local function encode_chunk(chunk) + local ok, encoded = pcall(json.encode, utils.deep_copy(chunk)) + if ok and type(encoded) == "string" then + return encoded + end + return "{}" + end - if current == "" then - current = entry - elseif (#current + 2 + #entry) <= LOCK_CODES_CHUNK_MAX_LEN then - current = current .. ", " .. entry - else - chunks[#chunks + 1] = current - current = entry + for _, slot in ipairs(slots) do + current_chunk[slot] = tostring(payload[slot] or "") + local encoded = encode_chunk(current_chunk) + if #encoded > LOCK_CODES_CHUNK_MAX_LEN then + current_chunk[slot] = nil + if next(current_chunk) ~= nil then + chunks[#chunks + 1] = encode_chunk(current_chunk) + end + current_chunk = { [slot] = tostring(payload[slot] or "") } end end - if current ~= "" then - chunks[#chunks + 1] = current + if next(current_chunk) ~= nil then + chunks[#chunks + 1] = encode_chunk(current_chunk) end - for index, chunk in ipairs(chunks) do - local message = string.format("codes %d/%d: %s", index, #chunks, chunk) - device:emit_event(LockCodes.codeChanged(message, { state_change = true })) + for _, chunk in ipairs(chunks) do + device:emit_event(LockCodes.lockCodes(chunk, { state_change = true }, { visibility = { displayed = true } })) end end @@ -277,26 +294,22 @@ local function emit_lock_codes(device, lock_codes, lock_pins) local partial_payload = build_partial_payload(full_payload) local partial_encoded = encode_payload(partial_payload) device:emit_event(LockCodes.lockCodes(partial_encoded, { state_change = true }, { visibility = { displayed = true } })) - emit_lock_codes_chunks(device, lock_codes, lock_pins) + emit_lock_codes_chunks(device, full_payload) end local function emit_lock_code_limits(device) local min_len = get_pref_number(device.preferences.minCodeLength) local max_len = get_pref_number(device.preferences.maxCodeLength) - local code_len = device:get_field(LOCK_CODE_LENGTH_FIELD) if min_len ~= nil then - device:emit_event(LockCodes.minCodeLength(min_len, { visibility = { displayed = false } })) + device:emit_event(LockCodes.minCodeLength(min_len, { visibility = { displayed = true } })) end if max_len ~= nil then - device:emit_event(LockCodes.maxCodeLength(max_len, { visibility = { displayed = false } })) - end - if code_len ~= nil then - device:emit_event(LockCodes.codeLength(code_len, { visibility = { displayed = false } })) + device:emit_event(LockCodes.maxCodeLength(max_len, { visibility = { displayed = true } })) end end -local function get_next_index(map_section) +--[[ local function get_next_index(map_section) local max_index = 0 for _, entry in pairs(map_section or {}) do if type(entry.index) == "number" and entry.index > max_index then @@ -304,7 +317,7 @@ local function get_next_index(map_section) end end return max_index + 1 -end +end ]] local function normalize_user_name(value) if type(value) == "string" then @@ -334,7 +347,7 @@ local function normalize_user_entry(entry) } end -local function merge_user_section(base_section, updates) +--[[ local function merge_user_section(base_section, updates) local merged = {} for code, entry in pairs(base_section or {}) do merged[code] = normalize_user_entry(entry) @@ -353,7 +366,7 @@ local function merge_user_section(base_section, updates) end return merged -end +end ]] local function get_user_map(device) return device:get_field("securitySystem_user_map") @@ -455,12 +468,12 @@ local function get_current_status(device) end local function send_panel_status(device, status) - local length = device.preferences.length or 5 + local duration = get_exit_delay_duration(device) local panel_status = STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM device:send(IASACE.client.commands.PanelStatusChanged( device, panel_status, - length, + duration, AudibleNotification.DEFAULT_SOUND, AlarmStatus.NO_ALARM )) @@ -503,7 +516,6 @@ local function handle_arm_command(driver, device, zb_rx) log.warn("IAS ACE Arm rejected: unknown pin or rfid") return end - log.error("Dupsko") local data = { source = "keypad", @@ -514,10 +526,11 @@ local function handle_arm_command(driver, device, zb_rx) device:set_field("securitySystem_last_user", data, { persist = false }) if can_process_arm_command(status, get_current_status(device)) then if device.preferences.exitDelay == true and status ~= "disarmed" then - log.error("Twój stary") + local duration = get_exit_delay_duration(device) send_panel_status(device, "exitDelay") - device.thread:call_with_delay(device.preferences.length or 5, function() + device.thread:call_with_delay(duration, function() emit_status_event(device, status, data) + emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") emit_arm_activity(device, status, user.name) device:send(IASACE.client.commands.ArmResponse( device, @@ -526,6 +539,7 @@ local function handle_arm_command(driver, device, zb_rx) end) else emit_status_event(device, status, data) + emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") emit_arm_activity(device, status, user.name) device:send(IASACE.client.commands.ArmResponse( device, @@ -542,30 +556,32 @@ local function handle_arm_command(driver, device, zb_rx) end local function handle_get_panel_status(driver, device, zb_rx) - local length = device.preferences.length or 5 + local duration = get_exit_delay_duration(device) local status = get_current_status(device) device:send(IASACE.client.commands.GetPanelStatusResponse( device, STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM, - length, + duration, AudibleNotification.DEFAULT_SOUND, AlarmStatus.NO_ALARM )) end local function handle_arm(device, status) - local length = device.preferences.length or 5 + local duration = get_exit_delay_duration(device) if not armCommandFromKeypad and can_process_arm_command(status, get_current_status(device)) then if device.preferences.exitDelay == true then send_panel_status(device, "exitDelay") - device.thread:call_with_delay(length, function() + device.thread:call_with_delay(duration, function() emit_status_event(device, status, { source = "app" }) emit_arm_activity(device, status, "App") + emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") send_panel_status(device, status) end) else emit_status_event(device, status, { source = "app" }) emit_arm_activity(device, status, "App") + emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") send_panel_status(device, status) end else @@ -587,6 +603,7 @@ local function handle_disarm(driver, device, command) if can_process_arm_command("disarmed", get_current_status(device)) and not armCommandFromKeypad then emit_status_event(device, "disarmed", { source = "app" }) emit_arm_activity(device, "disarmed", "App") + emit_lock_event(device, "unlocked") send_panel_status(device, "disarmed") else armCommandFromKeypad = false @@ -605,6 +622,9 @@ local function device_added(driver, device) if device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) == nil then emit_status_event(device, "disarmed", { source = "driver" }) end + if device:get_latest_state("main", lock.ID, lock.lock.NAME) == nil then + emit_lock_event(device, "unlocked") + end end local function do_configure(self, device) @@ -614,7 +634,7 @@ local function do_configure(self, device) end local function device_init(driver, device) - BATTERY_INIT(driver, device) + battery_defaults.build_linear_voltage_init(4.0, 6.0)(driver, device) emit_supported(device) local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP device:set_field("securitySystem_user_map", base_map, { persist = true }) @@ -642,8 +662,8 @@ local function info_changed(driver, device, event, args) return false end) local map = { - pins = merge_user_section(base_map and base_map.pins or {}, pin_updates), - rfids = merge_user_section(base_map and base_map.rfids or {}, {}), + pins = pin_updates, + rfids = base_map.rfids, } device:set_field("securitySystem_user_map", map, { persist = true }) sync_lock_codes_from_user_map(device, map) @@ -651,51 +671,37 @@ local function info_changed(driver, device, event, args) if (name == "rfidMap") then local rfid_updates = parse_user_map(device.preferences.rfidMap) local map = { - pins = merge_user_section(base_map and base_map.pins or {}, {}), - rfids = merge_user_section(base_map and base_map.rfids or {}, rfid_updates), + pins = base_map.pins, + rfids = rfid_updates, } device:set_field("securitySystem_user_map", map, { persist = true }) - --sync_lock_codes_from_user_map(device, map) - end - if (name == "deletePinMap") then - local delete_pins = parse_delete_list(device.preferences.deletePinMap) - for pin, _ in pairs(delete_pins) do - if base_map.pins[pin] ~= nil then - base_map.pins[pin] = nil - end - end - device:set_field("securitySystem_user_map", base_map, { persist = true }) - sync_lock_codes_from_user_map(device, base_map) - end - if (name == "deleteRfidMap") then - local delete_rfids = parse_delete_list(device.preferences.deleteRfidMap) - for rfid, _ in pairs(delete_rfids) do - if base_map.rfids[rfid] ~= nil then - base_map.rfids[rfid] = nil - end - end - device:set_field("securitySystem_user_map", base_map, { persist = true }) - --sync_lock_codes_from_user_map(device, base_map) end if (name == "autoArmDisarmMode") then local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) if autoArmDisarmMode ~= nil then send_iasace_mfg_write(device, 0x8003, data_types.Enum8, autoArmDisarmMode) end - elseif (name == "autoDisarmModeSetting") then + end + if (name == "autoDisarmModeSetting") then local autoDisarmModeSetting = device.preferences.autoDisarmModeSetting send_iasace_mfg_write(device, 0x8004, data_types.Boolean, autoDisarmModeSetting) - elseif (name == "autoArmModeSetting") then + end + if (name == "autoArmModeSetting") then local autoArmModeSetting = tonumber(device.preferences.autoArmModeSetting) if autoArmModeSetting ~= nil then send_iasace_mfg_write(device, 0x8005, data_types.Enum8, autoArmModeSetting) end - elseif (name == "pinLengthSetting") then + end + if (name == "pinLengthSetting") then local pinLengthSetting = tonumber(device.preferences.pinLengthSetting) if pinLengthSetting ~= nil then send_iasace_mfg_write(device, 0x8006, data_types.Uint8, pinLengthSetting) end end + if (name == "duration") then + local duration = tonumber(device.preferences.duration) + device:set_field("securitySystem_exit_delay_duration", duration, { persist = true }) + end end end end @@ -747,6 +753,10 @@ local frient_keypad = { [SecuritySystem.commands.armStay.NAME] = handle_arm_stay, [SecuritySystem.commands.disarm.NAME] = handle_disarm, }, + [lock.ID] = { + [lock.commands.lock.NAME] = handle_arm_away, + [lock.commands.unlock.NAME] = handle_disarm, + }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = refresh, }, diff --git a/drivers/SmartThings/zigbee-keypad/src/init.lua b/drivers/SmartThings/zigbee-keypad/src/init.lua index 63a8697909..ac2170cb7e 100644 --- a/drivers/SmartThings/zigbee-keypad/src/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/init.lua @@ -12,6 +12,7 @@ local zigbee_keypad_driver = { capabilities.refresh, capabilities.tamperAlert, capabilities.lockCodes, + capabilities.lock, }, sub_drivers = require("sub_drivers"), health_check = false, diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua new file mode 100644 index 0000000000..3006e998bb --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua @@ -0,0 +1,365 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local json = require "st.json" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local IASACE = clusters.IASACE +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration + +local ArmMode = IASACE.types.ArmMode +local ArmNotification = IASACE.types.ArmNotification +local PanelStatus = IASACE.types.IasacePanelStatus +local AudibleNotification = IASACE.types.IasaceAudibleNotification +local AlarmStatus = IASACE.types.IasaceAlarmStatus + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-keypad-security-system.yml"), + fingerprinted_endpoint_id = 0x2C, + zigbee_endpoints = { + [0x2C] = { + id = 0x2C, + manufacturer = "frient A/S", + model = "KEPZB-110", + server_clusters = { 0x0001, 0x0500, 0x0501 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.securitySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.securitySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }) + ) + ) +end + +test.set_test_init_function(test_init) + +local function info_changed_device_data(preference_updates) + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + for key, value in pairs(preference_updates or {}) do + device_info_copy.preferences[key] = value + end + return dkjson.encode(device_info_copy) +end + +test.register_coroutine_test( + "Added lifecycle emits supported statuses and default disarmed state", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.securitySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.securitySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true })) + ) + end +) + +test.register_coroutine_test( + "doConfigure binds clusters and configures battery reporting", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASACE.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Refresh command reads battery and sends panel status", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.refresh.ID, component = "main", command = capabilities.refresh.commands.refresh.NAME, args = {} } + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_message_test( + "Battery voltage report is handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 0x3C) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + } +) + +test.register_message_test( + "IAS Zone tamper attribute report emits tamper detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + } +) + +test.register_message_test( + "IAS Zone status change notification emits tamper clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + } +) + +test.register_coroutine_test( + "App armAway emits security status, activity, and panel status", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.securitySystem.ID, component = "main", command = capabilities.securitySystem.commands.armAway.NAME, args = {} } + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("armed away by App", { state_change = true, data = { codeName = "App" } })) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_coroutine_test( + "GetPanelStatus command returns current panel state", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, IASACE.server.commands.GetPanelStatus.build_test_rx(mock_device) }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.GetPanelStatusResponse( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_coroutine_test( + "infoChanged pinMap add then delete updates lockCodes and deletion event", + function() + local add_data = info_changed_device_data({ pinMap = "1234:Alice" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Alice: 1234" }), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + + local delete_data = info_changed_device_data({ deletePinMap = "1234", pinMap = "1234:Alice" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", delete_data }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 deleted", { state_change = true }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + end +) + +test.register_coroutine_test( + "IAS ACE Arm with known PIN arms system and responds", + function() + local add_data = info_changed_device_data({ pinMap = "5678:Bob" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Bob: 5678" }), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.ARM_ALL_ZONES, "5678", 0) + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("armed away by Bob", { state_change = true, data = { codeName = "Bob" } })) + ) + test.socket.zigbee:__expect_send( + { mock_device.id, IASACE.client.commands.ArmResponse(mock_device, ArmNotification.ALL_ZONES_ARMED) } + ) + end +) + +test.register_coroutine_test( + "IAS ACE Arm with unknown PIN emits guidance event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.ARM_ALL_ZONES, "9999", 0) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged( + "9999 is not assigned to any user on this keypad. You can create a new user with this code in settings.", + { state_change = true } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Overflow lockCodes payload falls back to chunked codeChanged events with user and pin", + function() + local very_long_name = string.rep("A", 280) + local add_data = info_changed_device_data({ pinMap = "1234:" .. very_long_name }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + + local chunk_message = json.encode({ ["1"] = very_long_name .. ": 1234" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged(chunk_message, { state_change = true })) + ) + end +) + +test.run_registered_tests() + From 715d6c3bdc89ad30cfcb3ec1c9f415c8d4ae46dd Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 2 Mar 2026 07:22:38 +0100 Subject: [PATCH 06/15] two profiles --- .../profiles/frient-keypad-lock-status.yml | 118 +++++++++++ .../frient-keypad-security-system.yml | 4 +- .../zigbee-keypad/src/frient-keypad/init.lua | 194 ++++++++++++++---- 3 files changed, 269 insertions(+), 47 deletions(-) create mode 100644 drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml new file mode 100644 index 0000000000..9e610a5e5a --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml @@ -0,0 +1,118 @@ +name: frient-keypad-lock-status +components: +- id: main + capabilities: + - id: lockCodes + version: 1 + - id: lock + version: 1 + - id: battery + version: 1 + - id: tamperAlert + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +preferences: + - name: mode + title: Control Security System or Lock + description: "Choose whether to use the keypad to control Security System Status (Arm/Disarm) or Lock Status (Lock/Unlock)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Security System Status" + 1: "Lock Status" + default: 1 + - name: pinMap + title: Add PIN code(s) + description: "Format: 1234:Alice,4321:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." + required: false + preferenceType: string + definition: + stringType: text + default: "" + - name: rfidMap + title: Add RFID(s) + description: "Format: ABCD1234:Alice,EFGH5678:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." + required: false + preferenceType: string + definition: + stringType: text + default: "" + - name: showPinSnapshot + title: Show PIN Snapshot + description: "Display the current PIN/RFID list in the device UI (sensitive)." + required: false + preferenceType: boolean + definition: + default: true + - name: minCodeLength + title: Minimum PIN Length + description: "Minimum PIN length." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 32 + default: 4 + - name: maxCodeLength + title: Maximum PIN Length + description: "Maximum PIN length." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 32 + default: 10 + - name: exitDelay + title: Exit Delay + description: "Turn on exit delay when arming. Duration in seconds can be set in the 'Exit Delay Duration' setting." + required: false + preferenceType: boolean + definition: + default: false + - name: duration + title: Exit Delay Duration + description: "Exit delay duration in seconds." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 30 + default: 5 + - name: autoArmDisarmMode + title: Auto Lock/Unlock Mode + description: "Automatically lock/unlock without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'Pin'." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "RFID" + 2: "Pin" + default: 0 + - name: autoDisarmModeSetting + title: Auto Unlock Mode Setting + description: "When Auto Lock/Unlock Mode is set to 'Pin' or 'RFID', automatically unlock when a valid PIN/RFID is used." + required: false + preferenceType: boolean + definition: + default: false + - name: autoArmModeSettingBool + title: Auto Lock Setting + description: "When Auto Lock/Unlock Mode is set to 'Pin' or 'RFID', automatically lock when a valid PIN/RFID is used." + required: false + preferenceType: boolean + definition: + default: false + - name: pinLengthSetting + title: PIN Length Setting + description: "When Auto Lock/Unlock Mode is set to 'Pin', the length of PINs that will trigger auto lock/unlock." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 32 + default: 4 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index 49da7da96a..a165906fa5 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -6,8 +6,6 @@ components: version: 1 - id: lockCodes version: 1 - - id: lock - version: 1 - id: battery version: 1 - id: tamperAlert @@ -18,7 +16,7 @@ components: - name: SmartLock preferences: - name: mode - title: Use to control Security System Status or Lock Status + title: Control Security System or Lock description: "Choose whether to use the keypad to control Security System Status (Arm/Disarm) or Lock Status (Lock/Unlock)." required: false preferenceType: enumeration diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index d3c15ba954..f512f3d624 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -46,6 +46,20 @@ local SECURITY_STATUS_EVENTS = { disarmed = SecuritySystem.securitySystemStatus.disarmed, } +local LOCK_STATUS_EVENTS = { + locked = lock.lock.locked, + unlocked = lock.lock.unlocked, +} + +local function should_use_lock_mode(device) + local mode = tonumber(device.preferences and device.preferences.mode) + if mode ~= nil then + return mode == 1 + end + + return device:supports_capability(capabilities.lock) and not device:supports_capability(capabilities.securitySystem) +end + local ARM_MODE_TO_STATUS = { [ArmMode.DISARM] = "disarmed", [ArmMode.ARM_DAY_HOME_ZONES_ONLY] = "armedStay", @@ -74,18 +88,25 @@ local STATUS_TO_ACTIVITY = { exitDelay = "exit delay", } +local LOCK_STATUS_TO_ACTIVITY = { + locked = "locked", + unlocked = "unlocked", +} + local function emit_supported(device) - device:emit_event(SecuritySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } })) - device:emit_event(SecuritySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } })) - device:emit_event(lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } })) - device:emit_event(lock.supportedLockCommands({ "lock", "unlock"}, { visibility = { displayed = false } })) + if should_use_lock_mode(device) then + device:emit_event(lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } })) + device:emit_event(lock.supportedLockCommands({ "lock", "unlock"}, { visibility = { displayed = false } })) + else + device:emit_event(SecuritySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } })) + device:emit_event(SecuritySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } })) + end end local function emit_status_event(device, status, extra_data) local event_factory = SECURITY_STATUS_EVENTS[status] or SecuritySystem.securitySystemStatus.disarmed local event = event_factory({ state_change = true }) if extra_data ~= nil then - device:set_field("securitySystem_last_context", extra_data, { persist = false }) device.log.info(string.format("securitySystemStatus extra data captured (keys=%s)", table.concat((function() local keys = {} for k, _ in pairs(extra_data) do @@ -98,10 +119,44 @@ local function emit_status_event(device, status, extra_data) device:emit_event(event) end -local function emit_lock_event(device, lock_state) - local event = lock.lock(lock_state, { state_change = true }) +local function emit_lock_event(device, lock_state, extra_data) + local event_factory = LOCK_STATUS_EVENTS[lock_state] or lock.lock.unlocked + local event = event_factory({ state_change = true }) + if extra_data ~= nil then + device.log.info(string.format("lockStatus extra data captured (keys=%s)", table.concat((function() + local keys = {} + for k, _ in pairs(extra_data) do + keys[#keys + 1] = tostring(k) + end + return keys + end)(), ","))) + end + device.log.info(string.format("Emitting lockStatus=%s", lock_state)) + device:emit_event(event) +end + +local function emit_mode_status_event(device, status, extra_data) + if should_use_lock_mode(device) then + emit_lock_event(device, status == "disarmed" and "unlocked" or "locked", extra_data) + else + emit_status_event(device, status, extra_data) + end +end + +local function emit_mode_arm_activity(device, status, user_name) + local activity + if should_use_lock_mode(device) then + activity = LOCK_STATUS_TO_ACTIVITY[status] or status + else + activity = STATUS_TO_ACTIVITY[status] or status + end + local actor = user_name or "Unknown" + local event = LockCodes.codeChanged(string.format("%s by %s", activity, actor), { state_change = true }) + if user_name ~= nil then + event.data = { codeName = user_name } + end device:emit_event(event) -end +end local function get_pref_number(value) if type(value) == "number" then @@ -159,22 +214,6 @@ local function parse_user_map(value, validator) return map end ---[[ local function parse_delete_list(value) - local items = {} - if value == nil or value == "" then - return items - end - - for token in string.gmatch(value, "[^,]+") do - local code = token:match("^%s*(.-)%s*$") - if code ~= nil and code ~= "" then - items[code] = true - end - end - - return items -end ]] - local function get_lock_codes(device) return device:get_field(LOCK_CODES_FIELD) or {} end @@ -184,7 +223,7 @@ local function get_lock_code_pins(device) end local function get_exit_delay_duration(device) - return device:get_field("securitySystem_exit_delay_duration") or 5 + return device:get_field("exit_delay_duration") or 5 end local function build_lock_codes_payload(device, lock_codes, lock_pins) @@ -454,17 +493,28 @@ local function resolve_user_from_code(device, code) end local function emit_arm_activity(device, status, user_name) - local activity = STATUS_TO_ACTIVITY[status] or status + local activity + if should_use_lock_mode(device) then + activity = LOCK_STATUS_TO_ACTIVITY[status] or status + else + activity = STATUS_TO_ACTIVITY[status] or status + end local actor = user_name or "Unknown" local event = LockCodes.codeChanged(string.format("%s by %s", activity, actor), { state_change = true }) if user_name ~= nil then event.data = { codeName = user_name } end device:emit_event(event) + log.error("czy tu?") end local function get_current_status(device) - return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" + if should_use_lock_mode(device) then + local lock_status = device:get_latest_state("main", lock.ID, lock.lock.NAME) or "unlocked" + return lock_status == "locked" and "armedAway" or "disarmed" + else + return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" + end end local function send_panel_status(device, status) @@ -477,6 +527,7 @@ local function send_panel_status(device, status) AudibleNotification.DEFAULT_SOUND, AlarmStatus.NO_ALARM )) + log.error("to tu?") end local function can_process_arm_command(command, status) @@ -529,8 +580,7 @@ local function handle_arm_command(driver, device, zb_rx) local duration = get_exit_delay_duration(device) send_panel_status(device, "exitDelay") device.thread:call_with_delay(duration, function() - emit_status_event(device, status, data) - emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") + emit_mode_status_event(device, status, data) emit_arm_activity(device, status, user.name) device:send(IASACE.client.commands.ArmResponse( device, @@ -538,8 +588,7 @@ local function handle_arm_command(driver, device, zb_rx) )) end) else - emit_status_event(device, status, data) - emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") + emit_mode_status_event(device, status, data) emit_arm_activity(device, status, user.name) device:send(IASACE.client.commands.ArmResponse( device, @@ -573,15 +622,13 @@ local function handle_arm(device, status) if device.preferences.exitDelay == true then send_panel_status(device, "exitDelay") device.thread:call_with_delay(duration, function() - emit_status_event(device, status, { source = "app" }) + emit_mode_status_event(device, status, { source = "app" }) emit_arm_activity(device, status, "App") - emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") send_panel_status(device, status) end) else - emit_status_event(device, status, { source = "app" }) + emit_mode_status_event(device, status, { source = "app" }) emit_arm_activity(device, status, "App") - emit_lock_event(device, status == "armedAway" and "locked" or "unlocked") send_panel_status(device, status) end else @@ -601,9 +648,8 @@ end local function handle_disarm(driver, device, command) if can_process_arm_command("disarmed", get_current_status(device)) and not armCommandFromKeypad then - emit_status_event(device, "disarmed", { source = "app" }) + emit_mode_status_event(device, "disarmed", { source = "app" }) emit_arm_activity(device, "disarmed", "App") - emit_lock_event(device, "unlocked") send_panel_status(device, "disarmed") else armCommandFromKeypad = false @@ -617,14 +663,29 @@ local function refresh(driver, device, command) send_panel_status(device, get_current_status(device)) end +local function get_and_update_state(device) + if should_use_lock_mode(device) then + if device:get_latest_state("main", lock.ID, lock.lock.NAME) == nil then + emit_lock_event(device, "unlocked", { source = "driver" }) + emit_arm_activity(device, "unlocked", "App") + else + emit_lock_event(device, device:get_latest_state("main", lock.ID, lock.lock.NAME), { source = "driver" }) + emit_arm_activity(device, device:get_latest_state("main", lock.ID, lock.lock.NAME), "App") + end + else + if device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) == nil then + emit_status_event(device, "disarmed", { source = "driver" }) + emit_arm_activity(device, "disarmed", "App") + else + emit_status_event(device, device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME), { source = "driver" }) + emit_arm_activity(device, device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME), "App") + end + end +end + local function device_added(driver, device) emit_supported(device) - if device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) == nil then - emit_status_event(device, "disarmed", { source = "driver" }) - end - if device:get_latest_state("main", lock.ID, lock.lock.NAME) == nil then - emit_lock_event(device, "unlocked") - end + get_and_update_state(device) end local function do_configure(self, device) @@ -648,6 +709,30 @@ local function send_iasace_mfg_write(device, attr_id, data_type, payload) device:send(msg) end +local function assign_preference_values(device) + local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) + if autoArmDisarmMode ~= nil then + send_iasace_mfg_write(device, 0x8003, data_types.Enum8, autoArmDisarmMode) + end + local autoDisarmModeSetting = device.preferences.autoDisarmModeSetting + send_iasace_mfg_write(device, 0x8004, data_types.Boolean, autoDisarmModeSetting) + local autoArmModeSetting = tonumber(device.preferences.autoArmModeSetting) + if autoArmModeSetting ~= nil then + send_iasace_mfg_write(device, 0x8005, data_types.Enum8, autoArmModeSetting) + end + if should_use_lock_mode(device) then + if device.preferences.autoArmModeSettingBool == true then + send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 1) + else + send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 0) + end + end + local pinLengthSetting = tonumber(device.preferences.pinLengthSetting) + if pinLengthSetting ~= nil then + send_iasace_mfg_write(device, 0x8006, data_types.Uint8, pinLengthSetting) + end +end + local function info_changed(driver, device, event, args) local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP emit_lock_code_limits(device) @@ -692,6 +777,14 @@ local function info_changed(driver, device, event, args) send_iasace_mfg_write(device, 0x8005, data_types.Enum8, autoArmModeSetting) end end + if (name == "autoArmModeSettingBool") then + local autoArmModeSetting = device.preferences.autoArmModeSettingBool + if autoArmModeSetting == true then + send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 1) + else + send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 0) + end + end if (name == "pinLengthSetting") then local pinLengthSetting = tonumber(device.preferences.pinLengthSetting) if pinLengthSetting ~= nil then @@ -700,7 +793,20 @@ local function info_changed(driver, device, event, args) end if (name == "duration") then local duration = tonumber(device.preferences.duration) - device:set_field("securitySystem_exit_delay_duration", duration, { persist = true }) + device:set_field("exit_delay_duration", duration, { persist = true }) + end + if (name == "mode") then + local mode = tonumber(device.preferences.mode) + if mode == 1 then + device:try_update_metadata({ profile = "frient-keypad-lock-status" }) + else + device:try_update_metadata({ profile = "frient-keypad-security-system" }) + end + device.thread:call_with_delay(3, function() + emit_supported(device) + get_and_update_state(device) + assign_preference_values(device) + end) end end end From 8cd56c0261521a7f32e09647bf4b726b5c2d63d1 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 2 Mar 2026 13:13:52 +0100 Subject: [PATCH 07/15] WIP --- .../profiles/frient-keypad-lock-status.yml | 12 +-- .../frient-keypad-security-system.yml | 22 +++--- .../zigbee-keypad/src/frient-keypad/init.lua | 77 +++---------------- 3 files changed, 26 insertions(+), 85 deletions(-) diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml index 9e610a5e5a..5278ae4f27 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml @@ -43,7 +43,7 @@ preferences: default: "" - name: showPinSnapshot title: Show PIN Snapshot - description: "Display the current PIN/RFID list in the device UI (sensitive)." + description: "Display the current PIN/RFID list in the device history (sensitive)." required: false preferenceType: boolean definition: @@ -84,7 +84,7 @@ preferences: default: 5 - name: autoArmDisarmMode title: Auto Lock/Unlock Mode - description: "Automatically lock/unlock without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'Pin'." + description: "Automatically lock/unlock without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'PIN'." required: false preferenceType: enumeration definition: @@ -95,21 +95,21 @@ preferences: default: 0 - name: autoDisarmModeSetting title: Auto Unlock Mode Setting - description: "When Auto Lock/Unlock Mode is set to 'Pin' or 'RFID', automatically unlock when a valid PIN/RFID is used." + description: "When Auto Lock/Unlock Mode is set to 'PIN' or 'RFID', automatically unlock when a valid PIN/RFID is used." required: false preferenceType: boolean definition: default: false - name: autoArmModeSettingBool title: Auto Lock Setting - description: "When Auto Lock/Unlock Mode is set to 'Pin' or 'RFID', automatically lock when a valid PIN/RFID is used." + description: "When Auto Lock/Unlock Mode is set to 'PIN' or 'RFID', automatically lock when a valid PIN/RFID is used." required: false preferenceType: boolean definition: default: false - name: pinLengthSetting - title: PIN Length Setting - description: "When Auto Lock/Unlock Mode is set to 'Pin', the length of PINs that will trigger auto lock/unlock." + title: PIN length to auto lock/unlock + description: "When Auto Lock/Unlock Mode is set to 'PIN', the length of PINs that will trigger auto lock/unlock." required: false preferenceType: integer definition: diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index a165906fa5..c654a7fa94 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -43,14 +43,14 @@ preferences: default: "" - name: showPinSnapshot title: Show PIN Snapshot - description: "Display the current PIN/RFID list in the device UI (sensitive)." + description: "Display the current PIN/RFID list in the device history (sensitive)." required: false preferenceType: boolean definition: default: true - name: minCodeLength - title: Minimum PIN Length - description: "Minimum PIN length." + title: Minimal PIN Length + description: "Minimal allowed PIN length." required: false preferenceType: integer definition: @@ -58,8 +58,8 @@ preferences: maximum: 32 default: 4 - name: maxCodeLength - title: Maximum PIN Length - description: "Maximum PIN length." + title: Maximal PIN Length + description: "Maximal allowed PIN length." required: false preferenceType: integer definition: @@ -84,25 +84,25 @@ preferences: default: 5 - name: autoArmDisarmMode title: Auto Arm/Disarm Mode - description: "Automatically arm/disarm without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'Pin'." + description: "Automatically arm/disarm without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'PIN'." required: false preferenceType: enumeration definition: options: 0: "Disabled" 1: "RFID" - 2: "Pin" + 2: "PIN" default: 0 - name: autoDisarmModeSetting title: Auto Disarm Mode Setting - description: "When Auto Arm/Disarm Mode is set to 'Pin' or 'RFID', automatically disarm when a valid PIN/RFID is used." + description: "When Auto Arm/Disarm Mode is set to 'PIN' or 'RFID', automatically disarm when a valid PIN/RFID is used." required: false preferenceType: boolean definition: default: false - name: autoArmModeSetting title: Auto Arm Mode Setting - description: "When Auto Arm/Disarm Mode is set to 'Pin' or 'RFID', automatically arm in one of the following modes: 'Disabled', 'Arm Stay', 'Arm Away'." + description: "When Auto Arm/Disarm Mode is set to 'PIN' or 'RFID', automatically arm in one of the following modes: 'Disabled', 'Arm Stay', 'Arm Away'." required: false preferenceType: enumeration definition: @@ -112,8 +112,8 @@ preferences: 3: "Arm Stay" default: 0 - name: pinLengthSetting - title: PIN Length Setting - description: "When Auto Arm/Disarm Mode is set to 'Pin', the length of PINs that will trigger auto arm/disarm." + title: PIN length to auto arm/disarm + description: "When Auto Arm/Disarm Mode is set to 'PIN', the length of PINs that will trigger auto arm/disarm." required: false preferenceType: integer definition: diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index f512f3d624..cdf7b5c1fe 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -143,21 +143,6 @@ local function emit_mode_status_event(device, status, extra_data) end end -local function emit_mode_arm_activity(device, status, user_name) - local activity - if should_use_lock_mode(device) then - activity = LOCK_STATUS_TO_ACTIVITY[status] or status - else - activity = STATUS_TO_ACTIVITY[status] or status - end - local actor = user_name or "Unknown" - local event = LockCodes.codeChanged(string.format("%s by %s", activity, actor), { state_change = true }) - if user_name ~= nil then - event.data = { codeName = user_name } - end - device:emit_event(event) -end - local function get_pref_number(value) if type(value) == "number" then return value @@ -348,16 +333,6 @@ local function emit_lock_code_limits(device) end end ---[[ local function get_next_index(map_section) - local max_index = 0 - for _, entry in pairs(map_section or {}) do - if type(entry.index) == "number" and entry.index > max_index then - max_index = entry.index - end - end - return max_index + 1 -end ]] - local function normalize_user_name(value) if type(value) == "string" then return value @@ -386,27 +361,6 @@ local function normalize_user_entry(entry) } end ---[[ local function merge_user_section(base_section, updates) - local merged = {} - for code, entry in pairs(base_section or {}) do - merged[code] = normalize_user_entry(entry) - end - - local next_index = get_next_index(merged) - for code, value in pairs(updates or {}) do - local name = normalize_user_name(value) - local existing = merged[code] - if existing ~= nil and name ~= nil and name ~= "" then - existing.name = name - elseif existing == nil and name ~= nil and name ~= "" then - merged[code] = { name = name, index = next_index } - next_index = next_index + 1 - end - end - - return merged -end ]] - local function get_user_map(device) return device:get_field("securitySystem_user_map") end @@ -505,7 +459,6 @@ local function emit_arm_activity(device, status, user_name) event.data = { codeName = user_name } end device:emit_event(event) - log.error("czy tu?") end local function get_current_status(device) @@ -752,50 +705,42 @@ local function info_changed(driver, device, event, args) } device:set_field("securitySystem_user_map", map, { persist = true }) sync_lock_codes_from_user_map(device, map) - end - if (name == "rfidMap") then + elseif (name == "rfidMap") then local rfid_updates = parse_user_map(device.preferences.rfidMap) local map = { pins = base_map.pins, rfids = rfid_updates, } device:set_field("securitySystem_user_map", map, { persist = true }) - end - if (name == "autoArmDisarmMode") then + elseif (name == "autoArmDisarmMode") then local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) if autoArmDisarmMode ~= nil then send_iasace_mfg_write(device, 0x8003, data_types.Enum8, autoArmDisarmMode) end - end - if (name == "autoDisarmModeSetting") then + elseif (name == "autoDisarmModeSetting") then local autoDisarmModeSetting = device.preferences.autoDisarmModeSetting send_iasace_mfg_write(device, 0x8004, data_types.Boolean, autoDisarmModeSetting) - end - if (name == "autoArmModeSetting") then + elseif (name == "autoArmModeSetting") then local autoArmModeSetting = tonumber(device.preferences.autoArmModeSetting) if autoArmModeSetting ~= nil then send_iasace_mfg_write(device, 0x8005, data_types.Enum8, autoArmModeSetting) end - end - if (name == "autoArmModeSettingBool") then + elseif (name == "autoArmModeSettingBool") then local autoArmModeSetting = device.preferences.autoArmModeSettingBool if autoArmModeSetting == true then send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 1) else send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 0) end - end - if (name == "pinLengthSetting") then + elseif (name == "pinLengthSetting") then local pinLengthSetting = tonumber(device.preferences.pinLengthSetting) if pinLengthSetting ~= nil then send_iasace_mfg_write(device, 0x8006, data_types.Uint8, pinLengthSetting) end - end - if (name == "duration") then + elseif (name == "duration") then local duration = tonumber(device.preferences.duration) device:set_field("exit_delay_duration", duration, { persist = true }) - end - if (name == "mode") then + elseif (name == "mode") then local mode = tonumber(device.preferences.mode) if mode == 1 then device:try_update_metadata({ profile = "frient-keypad-lock-status" }) @@ -820,10 +765,6 @@ local function generate_event_from_zone_status(driver, device, zone_status, zigb end end -local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) - generate_event_from_zone_status(driver, device, zone_status, zb_rx) -end - local function ias_zone_status_change_handler(driver, device, zb_rx) local zone_status = zb_rx.body.zcl_body.zone_status generate_event_from_zone_status(driver, device, zone_status, zb_rx) @@ -849,7 +790,7 @@ local frient_keypad = { }, attr = { [IASZone.ID] = { - [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status }, } }, From 667d81f54fa254eb6a61e48767e5c176a3983367 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 3 Mar 2026 07:25:18 +0100 Subject: [PATCH 08/15] securitySystem tests --- .../zigbee-keypad/src/frient-keypad/init.lua | 16 +++++--- .../test_frient_keypad_security_system.lua | 38 +++++++++++-------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index cdf7b5c1fe..2e74890b47 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -162,7 +162,7 @@ end local function is_pin_length_valid(device, pin) local pinStr = tostring(pin) - if pinStr:sub(1,1) == "+" then + if pinStr:sub(1,1) == "+" then -- device adds + to the rfid codes, so ignore length check for those return true end if pin == nil or pin == "" then @@ -410,9 +410,13 @@ local function sync_lock_codes_from_user_map(device, map) end used_slots[slot_index] = true - map.pins[entry.pin] = map.pins[entry.pin] or {} - map.pins[entry.pin].index = slot_index - map.pins[entry.pin].name = entry.name or map.pins[entry.pin].name or ("Code " .. tostring(slot_index)) + local existing_entry = map.pins[entry.pin] + if type(existing_entry) ~= "table" then + existing_entry = { name = normalize_user_name(existing_entry) } + end + existing_entry.index = slot_index + existing_entry.name = entry.name or existing_entry.name or ("Code " .. tostring(slot_index)) + map.pins[entry.pin] = existing_entry local slot = tostring(slot_index) lock_pins[slot] = entry.pin @@ -438,10 +442,10 @@ end local function resolve_user_from_code(device, code) local map = get_user_map(device) if map.pins ~= nil and map.pins[code] ~= nil then - return map.pins[code], "pin" + return normalize_user_entry(map.pins[code]), "pin" end if map.rfids ~= nil and map.rfids[code] ~= nil then - return map.rfids[code], "rfid" + return normalize_user_entry(map.rfids[code]), "rfid" end return nil, nil end diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua index 3006e998bb..91b5e61e9e 100644 --- a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua +++ b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua @@ -83,7 +83,7 @@ local function info_changed_device_data(preference_updates) end test.register_coroutine_test( - "Added lifecycle emits supported statuses and default disarmed state", + "Added lifecycle emits supported statuses only", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) @@ -99,9 +99,18 @@ test.register_coroutine_test( capabilities.securitySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } }) ) ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true })) - ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("disarmed by App", { state_change = true, data = { codeName = "App" } }) + ) + ) end ) @@ -252,7 +261,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "infoChanged pinMap add then delete updates lockCodes and deletion event", + "infoChanged pinMap add updates lockCodes", function() local add_data = info_changed_device_data({ pinMap = "1234:Alice" }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) @@ -272,13 +281,6 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 deleted", { state_change = true }))) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) - ) - ) end ) @@ -338,7 +340,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Overflow lockCodes payload falls back to chunked codeChanged events with user and pin", + "Overflow lockCodes payload emits lockCodes event", function() local very_long_name = string.rep("A", 280) local add_data = info_changed_device_data({ pinMap = "1234:" .. very_long_name }) @@ -354,9 +356,15 @@ test.register_coroutine_test( ) ) - local chunk_message = json.encode({ ["1"] = very_long_name .. ": 1234" }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged(chunk_message, { state_change = true })) + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes( + json.encode({ ["1"] = very_long_name .. ": 1234" }), + { state_change = true }, + { visibility = { displayed = true } } + ) + ) ) end ) From 5ca57a555e178eae16f91c1e5ab2676e6c2c52b2 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 3 Mar 2026 12:57:57 +0100 Subject: [PATCH 09/15] WIP --- .../profiles/frient-keypad-lock-status.yml | 2 +- .../frient-keypad-security-system.yml | 2 +- .../zigbee-keypad/src/frient-keypad/init.lua | 25 +- .../test/test_frient_keypad_lock_status.lua | 373 ++++++++++++++++++ .../test_frient_keypad_security_system.lua | 6 +- 5 files changed, 390 insertions(+), 18 deletions(-) create mode 100644 drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml index 5278ae4f27..fcbbfef76a 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml @@ -13,7 +13,7 @@ components: - id: refresh version: 1 categories: - - name: SmartLock + - name: SecurityPanel preferences: - name: mode title: Control Security System or Lock diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index c654a7fa94..d75c62f71b 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -13,7 +13,7 @@ components: - id: refresh version: 1 categories: - - name: SmartLock + - name: SecurityPanel preferences: - name: mode title: Control Security System or Lock diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index 2e74890b47..0a0223c9f2 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -143,7 +143,7 @@ local function emit_mode_status_event(device, status, extra_data) end end -local function get_pref_number(value) +--[[ local function get_pref_number(value) if type(value) == "number" then return value end @@ -158,7 +158,7 @@ local function get_pref_number(value) end end return nil -end +end ]] local function is_pin_length_valid(device, pin) local pinStr = tostring(pin) @@ -168,8 +168,8 @@ local function is_pin_length_valid(device, pin) if pin == nil or pin == "" then return false end - local min_len = get_pref_number(device.preferences.minCodeLength) - local max_len = get_pref_number(device.preferences.maxCodeLength) + local min_len = device.preferences.minCodeLength + local max_len = device.preferences.maxCodeLength local len = string.len(tostring(pin)) if min_len ~= nil and len < min_len then @@ -322,8 +322,8 @@ local function emit_lock_codes(device, lock_codes, lock_pins) end local function emit_lock_code_limits(device) - local min_len = get_pref_number(device.preferences.minCodeLength) - local max_len = get_pref_number(device.preferences.maxCodeLength) + local min_len = device.preferences.minCodeLength + local max_len = device.preferences.maxCodeLength if min_len ~= nil then device:emit_event(LockCodes.minCodeLength(min_len, { visibility = { displayed = true } })) @@ -428,10 +428,6 @@ local function sync_lock_codes_from_user_map(device, map) emit_code_changed(device, slot, " deleted", nil) end end - log.error("previous lock codes " .. json.encode(previous_lock_codes)) - log.error("previous lock pins " .. json.encode(previous_lock_pins)) - log.error("lock codes" .. json.encode(lock_codes)) - log.error("lock pins " .. json.encode(lock_pins)) device:set_field("securitySystem_user_map", map, { persist = true }) device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) @@ -453,9 +449,13 @@ end local function emit_arm_activity(device, status, user_name) local activity if should_use_lock_mode(device) then - activity = LOCK_STATUS_TO_ACTIVITY[status] or status + if status == "locked" or status == "unlocked" then + activity = "Lock " .. (LOCK_STATUS_TO_ACTIVITY[status] or status) + else + activity = "Lock " .. LOCK_STATUS_TO_ACTIVITY[status == "disarmed" and "unlocked" or "locked"] + end else - activity = STATUS_TO_ACTIVITY[status] or status + activity = "Security System " .. (STATUS_TO_ACTIVITY[status] or status) end local actor = user_name or "Unknown" local event = LockCodes.codeChanged(string.format("%s by %s", activity, actor), { state_change = true }) @@ -484,7 +484,6 @@ local function send_panel_status(device, status) AudibleNotification.DEFAULT_SOUND, AlarmStatus.NO_ALARM )) - log.error("to tu?") end local function can_process_arm_command(command, status) diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua new file mode 100644 index 0000000000..fb30f67f6c --- /dev/null +++ b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua @@ -0,0 +1,373 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local json = require "st.json" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local IASACE = clusters.IASACE +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration + +local ArmMode = IASACE.types.ArmMode +local ArmNotification = IASACE.types.ArmNotification +local PanelStatus = IASACE.types.IasacePanelStatus +local AudibleNotification = IASACE.types.IasaceAudibleNotification +local AlarmStatus = IASACE.types.IasaceAlarmStatus + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-keypad-lock-status.yml"), + fingerprinted_endpoint_id = 0x2C, + zigbee_endpoints = { + [0x2C] = { + id = 0x2C, + manufacturer = "frient A/S", + model = "KEPZB-110", + server_clusters = { 0x0001, 0x0500, 0x0501 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lock.supportedLockCommands({ "lock", "unlock" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }) + ) + ) +end + +test.set_test_init_function(test_init) + +local function info_changed_device_data(preference_updates) + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + for key, value in pairs(preference_updates or {}) do + device_info_copy.preferences[key] = value + end + return dkjson.encode(device_info_copy) +end + +test.register_coroutine_test( + "doConfigure binds clusters and configures battery reporting", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASACE.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Refresh command reads battery and sends panel status", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.refresh.ID, component = "main", command = capabilities.refresh.commands.refresh.NAME, args = {} } + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_message_test( + "Battery voltage report is handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 0x3C) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + } +) + +test.register_message_test( + "IAS Zone tamper attribute report emits tamper detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + } +) + +test.register_message_test( + "IAS Zone status change notification emits tamper clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + } +) + +test.register_coroutine_test( + "App lock emits lock status, activity, and panel status", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lock.ID, component = "main", command = capabilities.lock.commands.lock.NAME, args = {} } + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Lock locked by App", { state_change = true, data = { codeName = "App" } })) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_coroutine_test( + "GetPanelStatus command returns current panel state", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, IASACE.server.commands.GetPanelStatus.build_test_rx(mock_device) }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.GetPanelStatusResponse( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_coroutine_test( + "infoChanged pinMap add updates lockCodes", + function() + local add_data = info_changed_device_data({ pinMap = "1234:Alice" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Alice: 1234" }), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + + local delete_data = info_changed_device_data({ deletePinMap = "1234", pinMap = "1234:Alice" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", delete_data }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + end +) + +test.register_coroutine_test( + "IAS ACE Arm with known PIN arms system and responds", + function() + local add_data = info_changed_device_data({ pinMap = "5678:Bob" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Bob: 5678" }), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.ARM_ALL_ZONES, "5678", 0) + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Lock locked by Bob", { state_change = true, data = { codeName = "Bob" } })) + ) + test.socket.zigbee:__expect_send( + { mock_device.id, IASACE.client.commands.ArmResponse(mock_device, ArmNotification.ALL_ZONES_ARMED) } + ) + end +) + +test.register_coroutine_test( + "IAS ACE Arm with unknown PIN emits guidance event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.ARM_ALL_ZONES, "9999", 0) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged( + "9999 is not assigned to any user on this keypad. You can create a new user with this code in settings.", + { state_change = true } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Overflow lockCodes payload emits lockCodes event", + function() + local very_long_name = string.rep("A", 280) + local add_data = info_changed_device_data({ pinMap = "1234:" .. very_long_name }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes( + json.encode({ ["1"] = very_long_name .. ": 1234" }), + { state_change = true }, + { visibility = { displayed = true } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "Added lifecycle emits supported statuses only", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lock.supportedLockCommands({ "lock", "unlock" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlocked({ state_change = true }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock unlocked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + end +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua index 91b5e61e9e..24399f1314 100644 --- a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua +++ b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua @@ -108,7 +108,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockCodes.codeChanged("disarmed by App", { state_change = true, data = { codeName = "App" } }) + capabilities.lockCodes.codeChanged("Security System disarmed by App", { state_change = true, data = { codeName = "App" } }) ) ) end @@ -223,7 +223,7 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("armed away by App", { state_change = true, data = { codeName = "App" } })) + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System armed away by App", { state_change = true, data = { codeName = "App" } })) ) test.socket.zigbee:__expect_send( { @@ -311,7 +311,7 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("armed away by Bob", { state_change = true, data = { codeName = "Bob" } })) + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System armed away by Bob", { state_change = true, data = { codeName = "Bob" } })) ) test.socket.zigbee:__expect_send( { mock_device.id, IASACE.client.commands.ArmResponse(mock_device, ArmNotification.ALL_ZONES_ARMED) } From 36e496c92d633553f1bca8e14b5f34dafbb9d72f Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 13 Apr 2026 14:16:19 +0200 Subject: [PATCH 10/15] accomodate in one profile --- .../profiles/frient-keypad-lock-status.yml | 118 ---- .../frient-keypad-security-system.yml | 16 +- .../zigbee-keypad/src/frient-keypad/init.lua | 573 +++++++----------- .../SmartThings/zigbee-keypad/src/init.lua | 2 +- .../test/test_frient_keypad_lock_status.lua | 373 ------------ .../test_frient_keypad_security_system.lua | 192 +++++- 6 files changed, 421 insertions(+), 853 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml delete mode 100644 drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml deleted file mode 100644 index fcbbfef76a..0000000000 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-lock-status.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: frient-keypad-lock-status -components: -- id: main - capabilities: - - id: lockCodes - version: 1 - - id: lock - version: 1 - - id: battery - version: 1 - - id: tamperAlert - version: 1 - - id: refresh - version: 1 - categories: - - name: SecurityPanel -preferences: - - name: mode - title: Control Security System or Lock - description: "Choose whether to use the keypad to control Security System Status (Arm/Disarm) or Lock Status (Lock/Unlock)." - required: false - preferenceType: enumeration - definition: - options: - 0: "Security System Status" - 1: "Lock Status" - default: 1 - - name: pinMap - title: Add PIN code(s) - description: "Format: 1234:Alice,4321:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." - required: false - preferenceType: string - definition: - stringType: text - default: "" - - name: rfidMap - title: Add RFID(s) - description: "Format: ABCD1234:Alice,EFGH5678:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." - required: false - preferenceType: string - definition: - stringType: text - default: "" - - name: showPinSnapshot - title: Show PIN Snapshot - description: "Display the current PIN/RFID list in the device history (sensitive)." - required: false - preferenceType: boolean - definition: - default: true - - name: minCodeLength - title: Minimum PIN Length - description: "Minimum PIN length." - required: false - preferenceType: integer - definition: - minimum: 1 - maximum: 32 - default: 4 - - name: maxCodeLength - title: Maximum PIN Length - description: "Maximum PIN length." - required: false - preferenceType: integer - definition: - minimum: 1 - maximum: 32 - default: 10 - - name: exitDelay - title: Exit Delay - description: "Turn on exit delay when arming. Duration in seconds can be set in the 'Exit Delay Duration' setting." - required: false - preferenceType: boolean - definition: - default: false - - name: duration - title: Exit Delay Duration - description: "Exit delay duration in seconds." - required: false - preferenceType: integer - definition: - minimum: 0 - maximum: 30 - default: 5 - - name: autoArmDisarmMode - title: Auto Lock/Unlock Mode - description: "Automatically lock/unlock without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'PIN'." - required: false - preferenceType: enumeration - definition: - options: - 0: "Disabled" - 1: "RFID" - 2: "Pin" - default: 0 - - name: autoDisarmModeSetting - title: Auto Unlock Mode Setting - description: "When Auto Lock/Unlock Mode is set to 'PIN' or 'RFID', automatically unlock when a valid PIN/RFID is used." - required: false - preferenceType: boolean - definition: - default: false - - name: autoArmModeSettingBool - title: Auto Lock Setting - description: "When Auto Lock/Unlock Mode is set to 'PIN' or 'RFID', automatically lock when a valid PIN/RFID is used." - required: false - preferenceType: boolean - definition: - default: false - - name: pinLengthSetting - title: PIN length to auto lock/unlock - description: "When Auto Lock/Unlock Mode is set to 'PIN', the length of PINs that will trigger auto lock/unlock." - required: false - preferenceType: integer - definition: - minimum: 1 - maximum: 32 - default: 4 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index d75c62f71b..0aa46a7e87 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -12,22 +12,24 @@ components: version: 1 - id: refresh version: 1 + - id: mode + version: 1 categories: - name: SecurityPanel preferences: - name: mode - title: Control Security System or Lock - description: "Choose whether to use the keypad to control Security System Status (Arm/Disarm) or Lock Status (Lock/Unlock)." + title: Control Security System or Mode + description: "Choose whether to use the keypad to control Security System Status (Arm/Disarm) or Mode Status (Lock/Unlock)." required: false preferenceType: enumeration definition: options: 0: "Security System Status" - 1: "Lock Status" + 1: "Mode Status" default: 0 - name: pinMap title: Add PIN code(s) - description: "Format: 1234:Alice,4321:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." + description: "Format: 1234:Alice,4321:Bob (other format will not be accepted). Only PIN(s) present in this setting are recognized." required: false preferenceType: string definition: @@ -35,7 +37,7 @@ preferences: default: "" - name: rfidMap title: Add RFID(s) - description: "Format: ABCD1234:Alice,EFGH5678:Bob (other format will not be accepted). Entries are added/updated; existing entries are kept." + description: "Format: +ABCD1234:Alice,+EFGH5678:Bob (other format will not be accepted, the + prefix is required). Only RFID(s) present in this setting are recognized." required: false preferenceType: string definition: @@ -65,10 +67,10 @@ preferences: definition: minimum: 1 maximum: 32 - default: 10 + default: 15 - name: exitDelay title: Exit Delay - description: "Turn on exit delay when arming. Duration in seconds can be set in the 'Exit Delay Duration' setting." + description: "Turn on exit delay when arming away. Duration in seconds can be set in the 'Exit Delay Duration' setting." required: false preferenceType: boolean definition: diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index 0a0223c9f2..a28b0ec8c9 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -13,11 +13,11 @@ local data_types = require "st.zigbee.data_types" local PowerConfiguration = clusters.PowerConfiguration local IASACE = clusters.IASACE +local IASZone = clusters.IASZone local SecuritySystem = capabilities.securitySystem local LockCodes = capabilities.lockCodes -local IASZone = clusters.IASZone local tamperAlert = capabilities.tamperAlert -local lock = capabilities.lock +local mode = capabilities.mode local ArmMode = IASACE.types.ArmMode local ArmNotification = IASACE.types.ArmNotification @@ -25,41 +25,20 @@ local PanelStatus = IASACE.types.IasacePanelStatus local AudibleNotification = IASACE.types.IasaceAudibleNotification local AlarmStatus = IASACE.types.IasaceAlarmStatus -local LOCK_CODES_FIELD = "lockCodes" -local LOCK_CODE_PINS_FIELD = "lockCodePins" -local LOCK_CODES_MAX_LEN = 255 -local LOCK_CODES_CHUNK_MAX_LEN = 220 local armCommandFromKeypad = false local DEVELCO_MANUFACTURER_CODE = 0x1015 --- Update these tables to match your local user map. -local LOCAL_USER_MAP = { - pins = { - }, - rfids = { - }, -} - local SECURITY_STATUS_EVENTS = { armedAway = SecuritySystem.securitySystemStatus.armedAway, armedStay = SecuritySystem.securitySystemStatus.armedStay, disarmed = SecuritySystem.securitySystemStatus.disarmed, } -local LOCK_STATUS_EVENTS = { - locked = lock.lock.locked, - unlocked = lock.lock.unlocked, +local MODE_STATUS_VALUES = { + Locked = "Locked", + Unlocked = "Unlocked", } -local function should_use_lock_mode(device) - local mode = tonumber(device.preferences and device.preferences.mode) - if mode ~= nil then - return mode == 1 - end - - return device:supports_capability(capabilities.lock) and not device:supports_capability(capabilities.securitySystem) -end - local ARM_MODE_TO_STATUS = { [ArmMode.DISARM] = "disarmed", [ArmMode.ARM_DAY_HOME_ZONES_ONLY] = "armedStay", @@ -89,18 +68,15 @@ local STATUS_TO_ACTIVITY = { } local LOCK_STATUS_TO_ACTIVITY = { - locked = "locked", - unlocked = "unlocked", + Locked = "Locked", + Unlocked = "Unlocked", } local function emit_supported(device) - if should_use_lock_mode(device) then - device:emit_event(lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } })) - device:emit_event(lock.supportedLockCommands({ "lock", "unlock"}, { visibility = { displayed = false } })) - else + device:emit_event(mode.supportedModes({ "Locked", "Unlocked" }, { visibility = { displayed = false } })) + device:emit_event(mode.supportedArguments({ "Locked", "Unlocked" }, { visibility = { displayed = false } })) device:emit_event(SecuritySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } })) device:emit_event(SecuritySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } })) - end end local function emit_status_event(device, status, extra_data) @@ -115,13 +91,13 @@ local function emit_status_event(device, status, extra_data) return keys end)(), ","))) end - device.log.info(string.format("Emitting securitySystemStatus=%s", status)) + device.log.info(string.format("Emitting securitySystemStatus=%s", status, {visibility = { displayed = true }})) device:emit_event(event) end -local function emit_lock_event(device, lock_state, extra_data) - local event_factory = LOCK_STATUS_EVENTS[lock_state] or lock.lock.unlocked - local event = event_factory({ state_change = true }) +local function emit_mode_event(device, lock_state, extra_data) + local mode_value = MODE_STATUS_VALUES[lock_state] or "Unlocked" + local event = mode.mode(mode_value, { state_change = true }) if extra_data ~= nil then device.log.info(string.format("lockStatus extra data captured (keys=%s)", table.concat((function() local keys = {} @@ -131,35 +107,18 @@ local function emit_lock_event(device, lock_state, extra_data) return keys end)(), ","))) end - device.log.info(string.format("Emitting lockStatus=%s", lock_state)) + device.log.info(string.format("Emitting lockStatus=%s", lock_state, {visibility = { displayed = true }})) device:emit_event(event) end local function emit_mode_status_event(device, status, extra_data) - if should_use_lock_mode(device) then - emit_lock_event(device, status == "disarmed" and "unlocked" or "locked", extra_data) - else + if tonumber(device.preferences.mode) == 1 then + emit_mode_event(device, status == "disarmed" and "Unlocked" or "Locked", extra_data) + elseif tonumber(device.preferences.mode) == 0 then emit_status_event(device, status, extra_data) end end ---[[ local function get_pref_number(value) - if type(value) == "number" then - return value - end - if type(value) == "string" and value ~= "" then - local parsed = tonumber(value) - if parsed ~= nil then - return parsed - end - local numeric_fragment = value:match("[-+]?%d+%.?%d*") - if numeric_fragment ~= nil then - return tonumber(numeric_fragment) - end - end - return nil -end ]] - local function is_pin_length_valid(device, pin) local pinStr = tostring(pin) if pinStr:sub(1,1) == "+" then -- device adds + to the rfid codes, so ignore length check for those @@ -181,7 +140,7 @@ local function is_pin_length_valid(device, pin) return true end -local function parse_user_map(value, validator) +local function parse_user_map(value) local map = {} if value == nil or value == "" then return map @@ -190,25 +149,59 @@ local function parse_user_map(value, validator) for pair in string.gmatch(value, "[^,]+") do local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") if code ~= nil and name ~= nil and code ~= "" and name ~= "" then - if validator == nil or validator(code) then - map[code] = name - end + map[code] = name end end return map end -local function get_lock_codes(device) - return device:get_field(LOCK_CODES_FIELD) or {} +local function get_exit_delay_duration(device) + local duration = device.preferences.duration + return duration or 5 end -local function get_lock_code_pins(device) - return device:get_field(LOCK_CODE_PINS_FIELD) or {} +local function build_lock_code_state_from_prefs(device) + local pin_updates = parse_user_map(device.preferences.pinMap) + local rfid_updates = parse_user_map(device.preferences.rfidMap) + + local lock_codes = {} + local lock_code_pins = {} + local pins = {} + local rfids = {} + + for pin, _ in pairs(pin_updates) do + pins[#pins + 1] = pin + end + + for rfid, _ in pairs(rfid_updates) do + rfids[#rfids + 1] = rfid + end + + table.sort(pins) + table.sort(rfids) + + for slot_index, pin in ipairs(pins) do + local slot_key = tostring(slot_index) + lock_code_pins[slot_key] = pin + lock_codes[slot_key] = pin_updates[pin] + end + + local rfid_start = #pins + 1 + for offset, rfid in ipairs(rfids) do + local slot_key = tostring(rfid_start + offset - 1) + lock_code_pins[slot_key] = rfid + lock_codes[slot_key] = rfid_updates[rfid] + end + + return lock_codes, lock_code_pins end -local function get_exit_delay_duration(device) - return device:get_field("exit_delay_duration") or 5 +local function build_user_map_from_prefs(device) + return { + pins = parse_user_map(device.preferences.pinMap), + rfids = parse_user_map(device.preferences.rfidMap), + } end local function build_lock_codes_payload(device, lock_codes, lock_pins) @@ -227,64 +220,6 @@ local function build_lock_codes_payload(device, lock_codes, lock_pins) return payload end -local function get_sorted_slots(lock_codes) - local slots = {} - for slot, _ in pairs(lock_codes or {}) do - slots[#slots + 1] = tostring(slot) - end - - table.sort(slots, function(left, right) - local left_num = tonumber(left) - local right_num = tonumber(right) - if left_num ~= nil and right_num ~= nil then - return left_num < right_num - end - if left_num ~= nil then - return true - end - if right_num ~= nil then - return false - end - return left < right - end) - - return slots -end - -local function emit_lock_codes_chunks(device, payload) - local chunks = {} - local current_chunk = {} - local slots = get_sorted_slots(payload) - - local function encode_chunk(chunk) - local ok, encoded = pcall(json.encode, utils.deep_copy(chunk)) - if ok and type(encoded) == "string" then - return encoded - end - return "{}" - end - - for _, slot in ipairs(slots) do - current_chunk[slot] = tostring(payload[slot] or "") - local encoded = encode_chunk(current_chunk) - if #encoded > LOCK_CODES_CHUNK_MAX_LEN then - current_chunk[slot] = nil - if next(current_chunk) ~= nil then - chunks[#chunks + 1] = encode_chunk(current_chunk) - end - current_chunk = { [slot] = tostring(payload[slot] or "") } - end - end - - if next(current_chunk) ~= nil then - chunks[#chunks + 1] = encode_chunk(current_chunk) - end - - for _, chunk in ipairs(chunks) do - device:emit_event(LockCodes.lockCodes(chunk, { state_change = true }, { visibility = { displayed = true } })) - end -end - local function encode_payload(payload) local ok, encoded = pcall(json.encode, utils.deep_copy(payload)) if ok and type(encoded) == "string" then @@ -293,32 +228,10 @@ local function encode_payload(payload) return "{}" end -local function build_partial_payload(payload) - local partial = {} - local slots = get_sorted_slots(payload) - for _, slot in ipairs(slots) do - partial[slot] = tostring(payload[slot] or "") - local encoded = encode_payload(partial) - if #encoded > LOCK_CODES_MAX_LEN then - partial[slot] = nil - break - end - end - return partial -end - local function emit_lock_codes(device, lock_codes, lock_pins) local full_payload = build_lock_codes_payload(device, lock_codes, lock_pins) local full_encoded = encode_payload(full_payload) - if #full_encoded <= LOCK_CODES_MAX_LEN then - device:emit_event(LockCodes.lockCodes(full_encoded, { state_change = true }, { visibility = { displayed = true } })) - return - end - - local partial_payload = build_partial_payload(full_payload) - local partial_encoded = encode_payload(partial_payload) - device:emit_event(LockCodes.lockCodes(partial_encoded, { state_change = true }, { visibility = { displayed = true } })) - emit_lock_codes_chunks(device, full_payload) + device:emit_event(LockCodes.lockCodes(full_encoded, { state_change = true }, { visibility = { displayed = true } })) end local function emit_lock_code_limits(device) @@ -348,113 +261,80 @@ local function normalize_user_name(value) return nil end -local function normalize_user_entry(entry) - if type(entry) == "table" then - return { - name = normalize_user_name(entry.name) or normalize_user_name(entry), - index = tonumber(entry.index), - } +local function get_user_map(device) + local map = device:get_field("user_map") + if map == nil then + map = build_user_map_from_prefs(device) end - return { - name = normalize_user_name(entry), - index = nil, - } + return map end -local function get_user_map(device) - return device:get_field("securitySystem_user_map") -end +local function resolve_user_from_code(device, code) + local user_map = get_user_map(device) or {} + local pin_map = user_map.pins or {} + local rfid_map = user_map.rfids or {} + local pins = {} + local rfids = {} -local function emit_code_changed(device, code_slot, change_type, code_name) - local event = LockCodes.codeChanged(tostring(code_slot) .. change_type, { state_change = true }) - if code_name ~= nil then - event.data = { codeName = code_name } + for pin, _ in pairs(pin_map) do + pins[#pins + 1] = pin end - device:emit_event(event) -end - -local function sync_lock_codes_from_user_map(device, map) - local previous_lock_codes = utils.deep_copy(get_lock_codes(device)) - local previous_lock_pins = utils.deep_copy(get_lock_code_pins(device)) - local lock_codes = {} - local lock_pins = {} - local used_slots = {} - - local entries = {} - for pin, entry in pairs(map.pins or {}) do - local normalized = normalize_user_entry(entry) - entries[#entries + 1] = { - pin = pin, - name = normalized.name, - index = normalized.index, - } + for rfid, _ in pairs(rfid_map) do + rfids[#rfids + 1] = rfid end - table.sort(entries, function(left, right) - local left_index = left.index or math.huge - local right_index = right.index or math.huge - if left_index == right_index then - return tostring(left.pin) < tostring(right.pin) - end - return left_index < right_index - end) - - local next_slot = 1 - for _, entry in ipairs(entries) do - local slot_index = entry.index - if slot_index == nil or slot_index < 1 or used_slots[slot_index] then - while used_slots[next_slot] do - next_slot = next_slot + 1 - end - slot_index = next_slot - end + table.sort(pins) + table.sort(rfids) - used_slots[slot_index] = true - local existing_entry = map.pins[entry.pin] - if type(existing_entry) ~= "table" then - existing_entry = { name = normalize_user_name(existing_entry) } + for index, pin in ipairs(pins) do + if pin == code then + return { name = normalize_user_name(pin_map[pin]), index = index }, "pin" end - existing_entry.index = slot_index - existing_entry.name = entry.name or existing_entry.name or ("Code " .. tostring(slot_index)) - map.pins[entry.pin] = existing_entry - - local slot = tostring(slot_index) - lock_pins[slot] = entry.pin - lock_codes[slot] = entry.name or normalize_user_name(previous_lock_codes[slot]) or ("Code " .. slot) end - for slot, pin in pairs(previous_lock_pins or {}) do - if pin ~= nil and lock_pins[slot] == nil then - emit_code_changed(device, slot, " deleted", nil) + for offset, rfid in ipairs(rfids) do + if rfid == code then + return { name = normalize_user_name(rfid_map[rfid]), index = #pins + offset }, "rfid" end end - device:set_field("securitySystem_user_map", map, { persist = true }) - device:set_field(LOCK_CODES_FIELD, lock_codes, { persist = true }) - device:set_field(LOCK_CODE_PINS_FIELD, lock_pins, { persist = true }) - emit_lock_codes(device, lock_codes, lock_pins) + return nil, nil end -local function resolve_user_from_code(device, code) - local map = get_user_map(device) - if map.pins ~= nil and map.pins[code] ~= nil then - return normalize_user_entry(map.pins[code]), "pin" +local function emit_mode_activity(device, status, user_name) + local activity + if status == "Locked" or status == "Unlocked" then + activity = "Lock " .. (LOCK_STATUS_TO_ACTIVITY[status] or status) + else + activity = "Lock " .. LOCK_STATUS_TO_ACTIVITY[status == "disarmed" and "Unlocked" or "Locked"] + end + local actor = user_name or "Unknown" + local event = LockCodes.codeChanged(string.format("%s by %s", activity, actor), { state_change = true }) + if user_name ~= nil then + event.data = { codeName = user_name } end - if map.rfids ~= nil and map.rfids[code] ~= nil then - return normalize_user_entry(map.rfids[code]), "rfid" + device:emit_event(event) +end + +local function emit_security_activity(device, status, user_name) + local activity = "Security System " .. (STATUS_TO_ACTIVITY[status] or status) + local actor = user_name or "Unknown" + local event = LockCodes.codeChanged(string.format("%s by %s", activity, actor), { state_change = true }) + if user_name ~= nil then + event.data = { codeName = user_name } end - return nil, nil + device:emit_event(event) end local function emit_arm_activity(device, status, user_name) local activity - if should_use_lock_mode(device) then - if status == "locked" or status == "unlocked" then + if tonumber(device.preferences.mode) == 1 then + if status == "Locked" or status == "Unlocked" then activity = "Lock " .. (LOCK_STATUS_TO_ACTIVITY[status] or status) else - activity = "Lock " .. LOCK_STATUS_TO_ACTIVITY[status == "disarmed" and "unlocked" or "locked"] + activity = "Lock " .. LOCK_STATUS_TO_ACTIVITY[status == "disarmed" and "Unlocked" or "Locked"] end - else + elseif tonumber(device.preferences.mode) == 0 then activity = "Security System " .. (STATUS_TO_ACTIVITY[status] or status) end local actor = user_name or "Unknown" @@ -465,12 +345,20 @@ local function emit_arm_activity(device, status, user_name) device:emit_event(event) end +local function get_current_mode_status(device) + local lock_status = device:get_latest_state("main", mode.ID, mode.mode.NAME) or "Unlocked" + return lock_status == "Locked" and "armedAway" or "disarmed" +end + +local function get_current_security_status(device) + return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" +end + local function get_current_status(device) - if should_use_lock_mode(device) then - local lock_status = device:get_latest_state("main", lock.ID, lock.lock.NAME) or "unlocked" - return lock_status == "locked" and "armedAway" or "disarmed" - else - return device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) or "disarmed" + if tonumber(device.preferences.mode) == 1 then + return get_current_mode_status(device) + elseif tonumber(device.preferences.mode) == 0 then + return get_current_security_status(device) end end @@ -530,9 +418,9 @@ local function handle_arm_command(driver, device, zb_rx) userIndex = user.index, userName = user.name, } - device:set_field("securitySystem_last_user", data, { persist = false }) + if can_process_arm_command(status, get_current_status(device)) then - if device.preferences.exitDelay == true and status ~= "disarmed" then + if device.preferences.exitDelay == true and status == "armedAway" and tonumber(device.preferences.mode) == 0 then local duration = get_exit_delay_duration(device) send_panel_status(device, "exitDelay") device.thread:call_with_delay(duration, function() @@ -558,6 +446,7 @@ local function handle_arm_command(driver, device, zb_rx) 0xFF )) end + armCommandFromKeypad = false end local function handle_get_panel_status(driver, device, zb_rx) @@ -571,27 +460,48 @@ local function handle_get_panel_status(driver, device, zb_rx) AlarmStatus.NO_ALARM )) end - local function handle_arm(device, status) local duration = get_exit_delay_duration(device) - if not armCommandFromKeypad and can_process_arm_command(status, get_current_status(device)) then - if device.preferences.exitDelay == true then + if not armCommandFromKeypad and can_process_arm_command(status, get_current_security_status(device)) then + if device.preferences.exitDelay == true and status == "armedAway" and tonumber(device.preferences.mode) == 0 then send_panel_status(device, "exitDelay") device.thread:call_with_delay(duration, function() - emit_mode_status_event(device, status, { source = "app" }) - emit_arm_activity(device, status, "App") + emit_status_event(device, status, { source = "app" }) + emit_security_activity(device, status, "App") send_panel_status(device, status) end) else - emit_mode_status_event(device, status, { source = "app" }) - emit_arm_activity(device, status, "App") + emit_status_event(device, status, { source = "app" }) + emit_security_activity(device, status, "App") + if tonumber(device.preferences.mode) == 0 then + send_panel_status(device, status) + end + end + else + return + end +end + +local function handle_lock(device, status) + --[[ local duration = get_exit_delay_duration(device) ]] + if not armCommandFromKeypad and can_process_arm_command(status, get_current_mode_status(device)) then + --[[ if device.preferences.exitDelay == true and tonumber(device.preferences.mode) == 1 then + send_panel_status(device, "exitDelay") + device.thread:call_with_delay(duration, function() + emit_mode_event(device, status, { source = "app" }) + emit_mode_activity(device, status, "App") + send_panel_status(device, status) + end) + else ]] + emit_mode_event(device, status, { source = "app" }) + emit_mode_activity(device, status, "App") + if tonumber(device.preferences.mode) == 1 then send_panel_status(device, status) end + --[[ end ]] else - armCommandFromKeypad = false return end - armCommandFromKeypad = false end local function handle_arm_away(driver, device, command) @@ -603,45 +513,89 @@ local function handle_arm_stay(driver, device, command) end local function handle_disarm(driver, device, command) - if can_process_arm_command("disarmed", get_current_status(device)) and not armCommandFromKeypad then - emit_mode_status_event(device, "disarmed", { source = "app" }) - emit_arm_activity(device, "disarmed", "App") - send_panel_status(device, "disarmed") + if can_process_arm_command("disarmed", get_current_security_status(device)) and not armCommandFromKeypad then + emit_status_event(device, "disarmed", { source = "app" }) + emit_security_activity(device, "disarmed", "App") + if tonumber(device.preferences.mode) == 0 then + send_panel_status(device, "disarmed") + end + else + return + end +end + +local function handle_unlock(driver, device, command) + if can_process_arm_command("Unlocked", get_current_mode_status(device)) and not armCommandFromKeypad then + emit_mode_event(device, "Unlocked", { source = "app" }) + emit_mode_activity(device, "Unlocked", "App") + if tonumber(device.preferences.mode) == 1 then + send_panel_status(device, "Unlocked") + end + else + return + end +end + +local function handle_set_mode(driver, device, command) + local desired = command.args.mode + if desired == "Locked" then + handle_lock(device, "Locked") + log.error("Locking") + elseif desired == "Unlocked" then + handle_unlock(driver, device, command) + log.error("Unlocking") else - armCommandFromKeypad = false - return + log.warn(string.format("Unsupported mode requested: %s", tostring(desired))) end - armCommandFromKeypad = false end -local function refresh(driver, device, command) +local function update_user_map(device) + local map = build_user_map_from_prefs(device) + device:set_field("user_map", map, { persist = true }) + local lock_codes, lock_code_pins = build_lock_code_state_from_prefs(device) + emit_lock_codes(device, lock_codes, lock_code_pins) +end + +local function refresh(driver, device) device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) send_panel_status(device, get_current_status(device)) end +local function set_states(device) + local current_mode = device:get_latest_state("main", mode.ID, mode.mode.NAME) + if current_mode == nil then + current_mode = "Unlocked" + end + emit_mode_event(device, current_mode, { source = "driver" }) + emit_mode_activity(device, current_mode, "App") + local current_security_status = device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) + if current_security_status == nil then + current_security_status = "disarmed" + end + emit_status_event(device, current_security_status, { source = "driver" }) + emit_security_activity(device, current_security_status, "App") +end + local function get_and_update_state(device) - if should_use_lock_mode(device) then - if device:get_latest_state("main", lock.ID, lock.lock.NAME) == nil then - emit_lock_event(device, "unlocked", { source = "driver" }) - emit_arm_activity(device, "unlocked", "App") - else - emit_lock_event(device, device:get_latest_state("main", lock.ID, lock.lock.NAME), { source = "driver" }) - emit_arm_activity(device, device:get_latest_state("main", lock.ID, lock.lock.NAME), "App") + if tonumber(device.preferences.mode) == 1 then + local current_mode = device:get_latest_state("main", mode.ID, mode.mode.NAME) + if current_mode == nil then + current_mode = "Unlocked" end - else - if device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) == nil then - emit_status_event(device, "disarmed", { source = "driver" }) - emit_arm_activity(device, "disarmed", "App") - else - emit_status_event(device, device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME), { source = "driver" }) - emit_arm_activity(device, device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME), "App") + emit_mode_event(device, current_mode, { source = "driver" }) + emit_mode_activity(device, current_mode, "App") + elseif tonumber(device.preferences.mode) == 0 then + local current_security_status = device:get_latest_state("main", SecuritySystem.ID, SecuritySystem.securitySystemStatus.NAME) + if current_security_status == nil then + current_security_status = "disarmed" end + emit_status_event(device, current_security_status, { source = "driver" }) + emit_security_activity(device, current_security_status, "App") end end local function device_added(driver, device) emit_supported(device) - get_and_update_state(device) end local function do_configure(self, device) @@ -650,71 +604,27 @@ local function do_configure(self, device) device:send(PowerConfiguration.attributes.BatteryVoltage:configure_reporting(device, 30, 21600, 1)) end -local function device_init(driver, device) - battery_defaults.build_linear_voltage_init(4.0, 6.0)(driver, device) - emit_supported(device) - local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP - device:set_field("securitySystem_user_map", base_map, { persist = true }) - sync_lock_codes_from_user_map(device, base_map) - emit_lock_code_limits(device) -end - local function send_iasace_mfg_write(device, attr_id, data_type, payload) local msg = cluster_base.write_manufacturer_specific_attribute(device, IASACE.ID, attr_id, DEVELCO_MANUFACTURER_CODE, data_type, payload) msg.body.zcl_header.frame_ctrl:set_direction_client() device:send(msg) end -local function assign_preference_values(device) - local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) - if autoArmDisarmMode ~= nil then - send_iasace_mfg_write(device, 0x8003, data_types.Enum8, autoArmDisarmMode) - end - local autoDisarmModeSetting = device.preferences.autoDisarmModeSetting - send_iasace_mfg_write(device, 0x8004, data_types.Boolean, autoDisarmModeSetting) - local autoArmModeSetting = tonumber(device.preferences.autoArmModeSetting) - if autoArmModeSetting ~= nil then - send_iasace_mfg_write(device, 0x8005, data_types.Enum8, autoArmModeSetting) - end - if should_use_lock_mode(device) then - if device.preferences.autoArmModeSettingBool == true then - send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 1) - else - send_iasace_mfg_write(device, 0x8005, data_types.Enum8, 0) - end - end - local pinLengthSetting = tonumber(device.preferences.pinLengthSetting) - if pinLengthSetting ~= nil then - send_iasace_mfg_write(device, 0x8006, data_types.Uint8, pinLengthSetting) - end +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(4.0, 6.0)(driver, device) + update_user_map(device) + emit_lock_code_limits(device) + set_states(device) end local function info_changed(driver, device, event, args) - local base_map = device:get_field("securitySystem_user_map") or LOCAL_USER_MAP emit_lock_code_limits(device) for name, value in pairs(device.preferences) do if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then if (name == "pinMap") then - local pin_updates = parse_user_map(device.preferences.pinMap, function(pin) - if is_pin_length_valid(device, pin) then - return true - end - log.warn(string.format("Ignoring pinMap entry with invalid length (pin=%s)", tostring(pin))) - return false - end) - local map = { - pins = pin_updates, - rfids = base_map.rfids, - } - device:set_field("securitySystem_user_map", map, { persist = true }) - sync_lock_codes_from_user_map(device, map) + update_user_map(device) elseif (name == "rfidMap") then - local rfid_updates = parse_user_map(device.preferences.rfidMap) - local map = { - pins = base_map.pins, - rfids = rfid_updates, - } - device:set_field("securitySystem_user_map", map, { persist = true }) + update_user_map(device) elseif (name == "autoArmDisarmMode") then local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) if autoArmDisarmMode ~= nil then @@ -740,21 +650,9 @@ local function info_changed(driver, device, event, args) if pinLengthSetting ~= nil then send_iasace_mfg_write(device, 0x8006, data_types.Uint8, pinLengthSetting) end - elseif (name == "duration") then - local duration = tonumber(device.preferences.duration) - device:set_field("exit_delay_duration", duration, { persist = true }) elseif (name == "mode") then - local mode = tonumber(device.preferences.mode) - if mode == 1 then - device:try_update_metadata({ profile = "frient-keypad-lock-status" }) - else - device:try_update_metadata({ profile = "frient-keypad-security-system" }) - end - device.thread:call_with_delay(3, function() - emit_supported(device) - get_and_update_state(device) - assign_preference_values(device) - end) + get_and_update_state(device) + refresh(driver, device) end end end @@ -803,9 +701,8 @@ local frient_keypad = { [SecuritySystem.commands.armStay.NAME] = handle_arm_stay, [SecuritySystem.commands.disarm.NAME] = handle_disarm, }, - [lock.ID] = { - [lock.commands.lock.NAME] = handle_arm_away, - [lock.commands.unlock.NAME] = handle_disarm, + [mode.ID] = { + [mode.commands.setMode.NAME] = handle_set_mode, }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = refresh, diff --git a/drivers/SmartThings/zigbee-keypad/src/init.lua b/drivers/SmartThings/zigbee-keypad/src/init.lua index ac2170cb7e..7aa6b4b835 100644 --- a/drivers/SmartThings/zigbee-keypad/src/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/init.lua @@ -12,7 +12,7 @@ local zigbee_keypad_driver = { capabilities.refresh, capabilities.tamperAlert, capabilities.lockCodes, - capabilities.lock, + capabilities.mode, }, sub_drivers = require("sub_drivers"), health_check = false, diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua deleted file mode 100644 index fb30f67f6c..0000000000 --- a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_lock_status.lua +++ /dev/null @@ -1,373 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local test = require "integration_test" -local capabilities = require "st.capabilities" -local clusters = require "st.zigbee.zcl.clusters" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local json = require "st.json" -local utils = require "st.utils" -local dkjson = require "dkjson" - -local IASACE = clusters.IASACE -local IASZone = clusters.IASZone -local PowerConfiguration = clusters.PowerConfiguration - -local ArmMode = IASACE.types.ArmMode -local ArmNotification = IASACE.types.ArmNotification -local PanelStatus = IASACE.types.IasacePanelStatus -local AudibleNotification = IASACE.types.IasaceAudibleNotification -local AlarmStatus = IASACE.types.IasaceAlarmStatus - -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("frient-keypad-lock-status.yml"), - fingerprinted_endpoint_id = 0x2C, - zigbee_endpoints = { - [0x2C] = { - id = 0x2C, - manufacturer = "frient A/S", - model = "KEPZB-110", - server_clusters = { 0x0001, 0x0500, 0x0501 } - } - } - } -) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init() - test.mock_device.add_test_device(mock_device) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lock.supportedLockCommands({ "lock", "unlock" }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }) - ) - ) -end - -test.set_test_init_function(test_init) - -local function info_changed_device_data(preference_updates) - local device_info_copy = utils.deep_copy(mock_device.raw_st_data) - for key, value in pairs(preference_updates or {}) do - device_info_copy.preferences[key] = value - end - return dkjson.encode(device_info_copy) -end - -test.register_coroutine_test( - "doConfigure binds clusters and configures battery reporting", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASACE.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) - }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end -) - -test.register_coroutine_test( - "Refresh command reads battery and sends panel status", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = capabilities.refresh.ID, component = "main", command = capabilities.refresh.commands.refresh.NAME, args = {} } - }) - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - IASACE.client.commands.PanelStatusChanged( - mock_device, - PanelStatus.PANEL_DISARMED_READY_TO_ARM, - 5, - AudibleNotification.DEFAULT_SOUND, - AlarmStatus.NO_ALARM - ) - } - ) - end -) - -test.register_message_test( - "Battery voltage report is handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 0x3C) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) - } - } -) - -test.register_message_test( - "IAS Zone tamper attribute report emits tamper detected", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) - } - } -) - -test.register_message_test( - "IAS Zone status change notification emits tamper clear", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) - } - } -) - -test.register_coroutine_test( - "App lock emits lock status, activity, and panel status", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = capabilities.lock.ID, component = "main", command = capabilities.lock.commands.lock.NAME, args = {} } - }) - - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lock.lock.locked({ state_change = true })) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Lock locked by App", { state_change = true, data = { codeName = "App" } })) - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - IASACE.client.commands.PanelStatusChanged( - mock_device, - PanelStatus.ARMED_AWAY, - 5, - AudibleNotification.DEFAULT_SOUND, - AlarmStatus.NO_ALARM - ) - } - ) - end -) - -test.register_coroutine_test( - "GetPanelStatus command returns current panel state", - function() - test.socket.zigbee:__queue_receive({ mock_device.id, IASACE.server.commands.GetPanelStatus.build_test_rx(mock_device) }) - - test.socket.zigbee:__expect_send( - { - mock_device.id, - IASACE.client.commands.GetPanelStatusResponse( - mock_device, - PanelStatus.PANEL_DISARMED_READY_TO_ARM, - 5, - AudibleNotification.DEFAULT_SOUND, - AlarmStatus.NO_ALARM - ) - } - ) - end -) - -test.register_coroutine_test( - "infoChanged pinMap add updates lockCodes", - function() - local add_data = info_changed_device_data({ pinMap = "1234:Alice" }) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) - - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Alice: 1234" }), { state_change = true }, { visibility = { displayed = true } }) - ) - ) - - local delete_data = info_changed_device_data({ deletePinMap = "1234", pinMap = "1234:Alice" }) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", delete_data }) - - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) - end -) - -test.register_coroutine_test( - "IAS ACE Arm with known PIN arms system and responds", - function() - local add_data = info_changed_device_data({ pinMap = "5678:Bob" }) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) - - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Bob: 5678" }), { state_change = true }, { visibility = { displayed = true } }) - ) - ) - test.wait_for_events() - - test.socket.zigbee:__queue_receive({ - mock_device.id, - IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.ARM_ALL_ZONES, "5678", 0) - }) - - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lock.lock.locked({ state_change = true })) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Lock locked by Bob", { state_change = true, data = { codeName = "Bob" } })) - ) - test.socket.zigbee:__expect_send( - { mock_device.id, IASACE.client.commands.ArmResponse(mock_device, ArmNotification.ALL_ZONES_ARMED) } - ) - end -) - -test.register_coroutine_test( - "IAS ACE Arm with unknown PIN emits guidance event", - function() - test.socket.zigbee:__queue_receive({ - mock_device.id, - IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.ARM_ALL_ZONES, "9999", 0) - }) - - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.codeChanged( - "9999 is not assigned to any user on this keypad. You can create a new user with this code in settings.", - { state_change = true } - ) - ) - ) - end -) - -test.register_coroutine_test( - "Overflow lockCodes payload emits lockCodes event", - function() - local very_long_name = string.rep("A", 280) - local add_data = info_changed_device_data({ pinMap = "1234:" .. very_long_name }) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) - - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) - ) - ) - - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.lockCodes( - json.encode({ ["1"] = very_long_name .. ": 1234" }), - { state_change = true }, - { visibility = { displayed = true } } - ) - ) - ) - end -) - -test.register_coroutine_test( - "Added lifecycle emits supported statuses only", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lock.supportedLockValues({ "locked", "unlocked"}, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lock.supportedLockCommands({ "lock", "unlock" }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lock.lock.unlocked({ state_change = true }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.codeChanged("Lock unlocked by App", { state_change = true, data = { codeName = "App" } }) - ) - ) - end -) - -test.run_registered_tests() - diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua index 24399f1314..12e1ef171b 100644 --- a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua +++ b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua @@ -40,7 +40,25 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) test.socket.capability:__set_channel_ordering("relaxed") + --[[ test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedModes({ "Locked", "Unlocked" }, { visibility = { displayed = false } }) + ) + ) test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedArguments({ "Locked", "Unlocked" }, { visibility = { displayed = false } }) + ) + ) ]] + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + --[[ test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.securitySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } }) @@ -51,23 +69,41 @@ local function test_init() "main", capabilities.securitySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } }) ) + ) ]] + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }) + ) ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) + capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }) + capabilities.mode.mode("Unlocked", { state_change = true }) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }) + capabilities.lockCodes.codeChanged("Lock Unlocked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Security System disarmed by App", { state_change = true, data = { codeName = "App" } }) ) ) end @@ -83,10 +119,22 @@ local function info_changed_device_data(preference_updates) end test.register_coroutine_test( - "Added lifecycle emits supported statuses only", + "Added lifecycle emits supported events", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedModes({ "Locked", "Unlocked" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedArguments({ "Locked", "Unlocked" }, { visibility = { displayed = false } }) + ) + ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -99,7 +147,19 @@ test.register_coroutine_test( capabilities.securitySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } }) ) ) - test.socket.capability:__expect_send( + --[[ test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.mode("unlocked", { state_change = true }) + ) + ) ]] + --[[ test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock unlocked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) ]] + --[[ test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true }) @@ -110,7 +170,7 @@ test.register_coroutine_test( "main", capabilities.lockCodes.codeChanged("Security System disarmed by App", { state_change = true, data = { codeName = "App" } }) ) - ) + ) ]] end ) @@ -268,7 +328,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -280,7 +340,113 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", delete_data }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + end +) + +test.register_coroutine_test( + "infoChanged mode to 1 emits mode activity and refresh", + function() + local update_data = info_changed_device_data({ mode = 1 }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mode.mode("Unlocked", { state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock Unlocked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_coroutine_test( + "Mode setMode locked emits mode and panel status", + function() + local update_data = info_changed_device_data({ mode = 1 }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mode.mode("Unlocked", { state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock Unlocked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.mode.ID, component = "main", command = capabilities.mode.commands.setMode.NAME, args = { "Locked" }, named_args = { mode = "Locked" } } + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mode.mode("Locked", { state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock Locked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) end ) @@ -292,7 +458,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -348,13 +514,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) - ) - ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message( From 66391eca4aa28059c912f123bb9f21ee5b4fd8de Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 27 Apr 2026 14:40:31 +0200 Subject: [PATCH 11/15] finished driver with tests Co-authored-by: Copilot --- .../frient-keypad-security-system.yml | 21 ++-- .../zigbee-keypad/src/frient-keypad/init.lua | 113 +++++++++++------- .../SmartThings/zigbee-keypad/src/init.lua | 1 + .../test_frient_keypad_security_system.lua | 36 +++++- 4 files changed, 114 insertions(+), 57 deletions(-) diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index 0aa46a7e87..fa8ed83039 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -14,6 +14,8 @@ components: version: 1 - id: mode version: 1 + - id: panicAlarm + version: 1 categories: - name: SecurityPanel preferences: @@ -45,11 +47,11 @@ preferences: default: "" - name: showPinSnapshot title: Show PIN Snapshot - description: "Display the current PIN/RFID list in the device history (sensitive)." + description: "Display the current PIN/RFID list in the device history after adding them (sensitive)." required: false preferenceType: boolean definition: - default: true + default: false - name: minCodeLength title: Minimal PIN Length description: "Minimal allowed PIN length." @@ -86,7 +88,7 @@ preferences: default: 5 - name: autoArmDisarmMode title: Auto Arm/Disarm Mode - description: "Automatically arm/disarm without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'PIN'." + description: "Automatically arm/disarm (lock/unlock) without pressing a function button on a keypad. Options: 'Disabled', 'RFID', 'PIN'." required: false preferenceType: enumeration definition: @@ -96,15 +98,15 @@ preferences: 2: "PIN" default: 0 - name: autoDisarmModeSetting - title: Auto Disarm Mode Setting + title: Auto Disarm/Unlock Mode Setting description: "When Auto Arm/Disarm Mode is set to 'PIN' or 'RFID', automatically disarm when a valid PIN/RFID is used." required: false preferenceType: boolean definition: default: false - name: autoArmModeSetting - title: Auto Arm Mode Setting - description: "When Auto Arm/Disarm Mode is set to 'PIN' or 'RFID', automatically arm in one of the following modes: 'Disabled', 'Arm Stay', 'Arm Away'." + title: Auto Arm/Lock Mode Setting + description: "When Auto Arm/Disarm Mode is set to 'PIN' or 'RFID', automatically arm in one of the following modes: 'Disabled', 'Arm Stay', 'Arm Away'. Any option other than 'Disabled' allows to lock while controlling Mode Status. " required: false preferenceType: enumeration definition: @@ -115,10 +117,13 @@ preferences: default: 0 - name: pinLengthSetting title: PIN length to auto arm/disarm - description: "When Auto Arm/Disarm Mode is set to 'PIN', the length of PINs that will trigger auto arm/disarm." + description: "When Auto Arm/Disarm Mode is set to 'PIN', the length of PINs that will trigger auto arm/disarm (lock/unlock)." required: false preferenceType: integer definition: minimum: 1 maximum: 32 - default: 4 \ No newline at end of file + default: 4 +metadata: + mnmn: SmartThings + vid: SmartThings-smartthings-frient_Keypad \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index a28b0ec8c9..dde607bca8 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -7,7 +7,6 @@ local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local utils = require "st.utils" local json = require "st.json" -local log = require "log" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" @@ -18,6 +17,7 @@ local SecuritySystem = capabilities.securitySystem local LockCodes = capabilities.lockCodes local tamperAlert = capabilities.tamperAlert local mode = capabilities.mode +local panicAlarm = capabilities.panicAlarm local ArmMode = IASACE.types.ArmMode local ArmNotification = IASACE.types.ArmNotification @@ -27,6 +27,8 @@ local AlarmStatus = IASACE.types.IasaceAlarmStatus local armCommandFromKeypad = false local DEVELCO_MANUFACTURER_CODE = 0x1015 +local EXIT_DELAY_UNTIL = "exit_delay_until" +local EXIT_DELAY_TARGET_STATUS = "exit_delay_target_status" local SECURITY_STATUS_EVENTS = { armedAway = SecuritySystem.securitySystemStatus.armedAway, @@ -82,32 +84,12 @@ end local function emit_status_event(device, status, extra_data) local event_factory = SECURITY_STATUS_EVENTS[status] or SecuritySystem.securitySystemStatus.disarmed local event = event_factory({ state_change = true }) - if extra_data ~= nil then - device.log.info(string.format("securitySystemStatus extra data captured (keys=%s)", table.concat((function() - local keys = {} - for k, _ in pairs(extra_data) do - keys[#keys + 1] = tostring(k) - end - return keys - end)(), ","))) - end - device.log.info(string.format("Emitting securitySystemStatus=%s", status, {visibility = { displayed = true }})) device:emit_event(event) end local function emit_mode_event(device, lock_state, extra_data) local mode_value = MODE_STATUS_VALUES[lock_state] or "Unlocked" local event = mode.mode(mode_value, { state_change = true }) - if extra_data ~= nil then - device.log.info(string.format("lockStatus extra data captured (keys=%s)", table.concat((function() - local keys = {} - for k, _ in pairs(extra_data) do - keys[#keys + 1] = tostring(k) - end - return keys - end)(), ","))) - end - device.log.info(string.format("Emitting lockStatus=%s", lock_state, {visibility = { displayed = true }})) device:emit_event(event) end @@ -161,6 +143,30 @@ local function get_exit_delay_duration(device) return duration or 5 end +local function is_exit_delay_active(device) + local deadline = device:get_field(EXIT_DELAY_UNTIL) + return type(deadline) == "number" and os.time() < deadline +end + +local function clear_exit_delay(device) + device:set_field(EXIT_DELAY_UNTIL, nil, { persist = false }) + device:set_field(EXIT_DELAY_TARGET_STATUS, nil, { persist = false }) +end + +local function start_exit_delay(device, target_status) + local duration = get_exit_delay_duration(device) + device:set_field(EXIT_DELAY_UNTIL, os.time() + duration, { persist = false }) + device:set_field(EXIT_DELAY_TARGET_STATUS, target_status, { persist = false }) + device:send(IASACE.client.commands.PanelStatusChanged( + device, + PanelStatus.EXIT_DELAY, + duration, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + )) + return duration +end + local function build_lock_code_state_from_prefs(device) local pin_updates = parse_user_map(device.preferences.pinMap) local rfid_updates = parse_user_map(device.preferences.rfidMap) @@ -386,29 +392,23 @@ local function handle_arm_command(driver, device, zb_rx) armCommandFromKeypad = true local cmd = zb_rx.body.zcl_body local pin = cmd.arm_disarm_code.value - local pin_len = pin ~= nil and string.len(pin) or 0 - log.info(string.format("IAS ACE Arm received (mode=%s, pin_len=%d)", tostring(cmd.arm_mode.value), pin_len)) local status = ARM_MODE_TO_STATUS[cmd.arm_mode.value] if status == nil then - log.warn("IAS ACE Arm received with unsupported arm mode") return end if pin == nil or pin == "" then - log.warn("IAS ACE Arm rejected: missing pin or rfid") return end if not is_pin_length_valid(device, pin) then - log.warn(string.format("IAS ACE Arm rejected: invalid pin length (len=%d)", pin_len)) return end local user, auth_type = resolve_user_from_code(device, pin) if user == nil then device:emit_event(LockCodes.codeChanged(tostring(pin) .. " is not assigned to any user on this keypad. You can create a new user with this code in settings.", { state_change = true })) - log.warn("IAS ACE Arm rejected: unknown pin or rfid") return end @@ -419,11 +419,17 @@ local function handle_arm_command(driver, device, zb_rx) userName = user.name, } + if is_exit_delay_active(device) then + device:send(IASACE.client.commands.ArmResponse(device, 0xFF)) + armCommandFromKeypad = false + return + end + if can_process_arm_command(status, get_current_status(device)) then if device.preferences.exitDelay == true and status == "armedAway" and tonumber(device.preferences.mode) == 0 then - local duration = get_exit_delay_duration(device) - send_panel_status(device, "exitDelay") + local duration = start_exit_delay(device, status) device.thread:call_with_delay(duration, function() + clear_exit_delay(device) emit_mode_status_event(device, status, data) emit_arm_activity(device, status, user.name) device:send(IASACE.client.commands.ArmResponse( @@ -440,7 +446,6 @@ local function handle_arm_command(driver, device, zb_rx) )) end else - log.info("Arm command ignored: already in target state or incompatible state") device:send(IASACE.client.commands.ArmResponse( device, 0xFF @@ -451,6 +456,16 @@ end local function handle_get_panel_status(driver, device, zb_rx) local duration = get_exit_delay_duration(device) + if is_exit_delay_active(device) then + device:send(IASACE.client.commands.GetPanelStatusResponse( + device, + PanelStatus.EXIT_DELAY, + duration, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + )) + return + end local status = get_current_status(device) device:send(IASACE.client.commands.GetPanelStatusResponse( device, @@ -460,12 +475,23 @@ local function handle_get_panel_status(driver, device, zb_rx) AlarmStatus.NO_ALARM )) end + +local function handle_emergency_command(driver, device, zb_rx) + device:emit_event(panicAlarm.panicAlarm.panic({ state_change = true })) + device.thread:call_with_delay(10, function() + device:emit_event(panicAlarm.panicAlarm.clear({ state_change = true })) + end) +end local function handle_arm(device, status) local duration = get_exit_delay_duration(device) + if is_exit_delay_active(device) then + return + end if not armCommandFromKeypad and can_process_arm_command(status, get_current_security_status(device)) then if device.preferences.exitDelay == true and status == "armedAway" and tonumber(device.preferences.mode) == 0 then - send_panel_status(device, "exitDelay") + duration = start_exit_delay(device, status) device.thread:call_with_delay(duration, function() + clear_exit_delay(device) emit_status_event(device, status, { source = "app" }) emit_security_activity(device, status, "App") send_panel_status(device, status) @@ -483,22 +509,15 @@ local function handle_arm(device, status) end local function handle_lock(device, status) - --[[ local duration = get_exit_delay_duration(device) ]] + if is_exit_delay_active(device) then + return + end if not armCommandFromKeypad and can_process_arm_command(status, get_current_mode_status(device)) then - --[[ if device.preferences.exitDelay == true and tonumber(device.preferences.mode) == 1 then - send_panel_status(device, "exitDelay") - device.thread:call_with_delay(duration, function() - emit_mode_event(device, status, { source = "app" }) - emit_mode_activity(device, status, "App") - send_panel_status(device, status) - end) - else ]] emit_mode_event(device, status, { source = "app" }) emit_mode_activity(device, status, "App") if tonumber(device.preferences.mode) == 1 then send_panel_status(device, status) end - --[[ end ]] else return end @@ -513,6 +532,9 @@ local function handle_arm_stay(driver, device, command) end local function handle_disarm(driver, device, command) + if is_exit_delay_active(device) then + clear_exit_delay(device) + end if can_process_arm_command("disarmed", get_current_security_status(device)) and not armCommandFromKeypad then emit_status_event(device, "disarmed", { source = "app" }) emit_security_activity(device, "disarmed", "App") @@ -525,6 +547,9 @@ local function handle_disarm(driver, device, command) end local function handle_unlock(driver, device, command) + if is_exit_delay_active(device) then + clear_exit_delay(device) + end if can_process_arm_command("Unlocked", get_current_mode_status(device)) and not armCommandFromKeypad then emit_mode_event(device, "Unlocked", { source = "app" }) emit_mode_activity(device, "Unlocked", "App") @@ -540,12 +565,8 @@ local function handle_set_mode(driver, device, command) local desired = command.args.mode if desired == "Locked" then handle_lock(device, "Locked") - log.error("Locking") elseif desired == "Unlocked" then handle_unlock(driver, device, command) - log.error("Unlocking") - else - log.warn(string.format("Unsupported mode requested: %s", tostring(desired))) end end @@ -615,6 +636,7 @@ local function device_init(driver, device) update_user_map(device) emit_lock_code_limits(device) set_states(device) + device:emit_event(panicAlarm.panicAlarm.clear({ state_change = true })) end local function info_changed(driver, device, event, args) @@ -684,6 +706,7 @@ local frient_keypad = { [IASACE.ID] = { [IASACE.server.commands.Arm.ID] = handle_arm_command, [IASACE.server.commands.GetPanelStatus.ID] = handle_get_panel_status, + [IASACE.server.commands.Emergency.ID] = handle_emergency_command, }, [IASZone.ID] = { [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler diff --git a/drivers/SmartThings/zigbee-keypad/src/init.lua b/drivers/SmartThings/zigbee-keypad/src/init.lua index 7aa6b4b835..2b24d3109e 100644 --- a/drivers/SmartThings/zigbee-keypad/src/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/init.lua @@ -13,6 +13,7 @@ local zigbee_keypad_driver = { capabilities.tamperAlert, capabilities.lockCodes, capabilities.mode, + capabilities.panicAlarm, }, sub_drivers = require("sub_drivers"), health_check = false, diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua index 12e1ef171b..4bf2aeb008 100644 --- a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua +++ b/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua @@ -106,6 +106,12 @@ local function test_init() capabilities.lockCodes.codeChanged("Security System disarmed by App", { state_change = true, data = { codeName = "App" } }) ) ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.panicAlarm.panicAlarm.clear({ state_change = true }) + ) + ) end test.set_test_init_function(test_init) @@ -323,7 +329,7 @@ test.register_coroutine_test( test.register_coroutine_test( "infoChanged pinMap add updates lockCodes", function() - local add_data = info_changed_device_data({ pinMap = "1234:Alice" }) + local add_data = info_changed_device_data({ pinMap = "1234:Alice", showPinSnapshot = true }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) test.socket.capability:__set_channel_ordering("relaxed") @@ -336,7 +342,7 @@ test.register_coroutine_test( ) ) - local delete_data = info_changed_device_data({ deletePinMap = "1234", pinMap = "1234:Alice" }) + local delete_data = info_changed_device_data({ deletePinMap = "1234", pinMap = "1234:Alice", showPinSnapshot = true }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", delete_data }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) @@ -453,7 +459,7 @@ test.register_coroutine_test( test.register_coroutine_test( "IAS ACE Arm with known PIN arms system and responds", function() - local add_data = info_changed_device_data({ pinMap = "5678:Bob" }) + local add_data = info_changed_device_data({ pinMap = "5678:Bob", showPinSnapshot = true }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) test.socket.capability:__set_channel_ordering("relaxed") @@ -509,7 +515,7 @@ test.register_coroutine_test( "Overflow lockCodes payload emits lockCodes event", function() local very_long_name = string.rep("A", 280) - local add_data = info_changed_device_data({ pinMap = "1234:" .. very_long_name }) + local add_data = info_changed_device_data({ pinMap = "1234:" .. very_long_name, showPinSnapshot = true }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) test.socket.capability:__set_channel_ordering("relaxed") @@ -529,5 +535,27 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Emergency command triggers panicAlarm, which clears after 10s", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, IASACE.server.commands.Emergency.build_test_rx(mock_device) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.panicAlarm.panicAlarm.panic({ state_change = true }) + ) + ) + test.timer.__create_and_queue_test_time_advance_timer(10, "oneshot") + test.mock_time.advance_time(10) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.panicAlarm.panicAlarm.clear({ state_change = true }) + ) + ) + end +) + test.run_registered_tests() From 59eb34298940a42e4c0f22097e3b5d56215e3cec Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 27 Apr 2026 14:49:45 +0200 Subject: [PATCH 12/15] remove unnecessary value assignment --- drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index dde607bca8..a0384554ba 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -483,7 +483,7 @@ local function handle_emergency_command(driver, device, zb_rx) end) end local function handle_arm(device, status) - local duration = get_exit_delay_duration(device) + local duration if is_exit_delay_active(device) then return end From 707461195305e0d987cf02c5ce4b87140ac94272 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Wed, 6 May 2026 10:20:46 +0200 Subject: [PATCH 13/15] Add preference to enable/disable triggering panicAlarm with SOS button Co-authored-by: Copilot --- .../profiles/frient-keypad-security-system.yml | 7 +++++++ .../SmartThings/zigbee-keypad/src/frient-keypad/init.lua | 3 +++ 2 files changed, 10 insertions(+) diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml index fa8ed83039..b81d10214a 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml @@ -45,6 +45,13 @@ preferences: definition: stringType: text default: "" + - name: panicAlarmActive + title: Panic Alarm Active + description: "When enabled, the SOS button will trigger the alarm. When disabled, the SOS button will be ignored." + required: false + preferenceType: boolean + definition: + default: true - name: showPinSnapshot title: Show PIN Snapshot description: "Display the current PIN/RFID list in the device history after adding them (sensitive)." diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua index a0384554ba..501e7f24f2 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua @@ -477,6 +477,9 @@ local function handle_get_panel_status(driver, device, zb_rx) end local function handle_emergency_command(driver, device, zb_rx) + if device.preferences.panicAlarmActive == false then + return + end device:emit_event(panicAlarm.panicAlarm.panic({ state_change = true })) device.thread:call_with_delay(10, function() device:emit_event(panicAlarm.panicAlarm.clear({ state_change = true })) From e2d44b49c2f36f06514e8ae3affa9b582398cde8 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 11 May 2026 14:09:04 +0200 Subject: [PATCH 14/15] Moved the driver to zigbee-lock and address pr comments --- drivers/SmartThings/zigbee-keypad/config.yml | 6 -- .../zigbee-keypad/fingerprints.yml | 21 ----- .../SmartThings/zigbee-keypad/src/init.lua | 24 ----- .../zigbee-keypad/src/lazy_load_subdriver.lua | 15 --- .../zigbee-keypad/src/sub_drivers.lua | 8 -- .../SmartThings/zigbee-lock/fingerprints.yml | 23 +++++ .../frient-keypad-security-system.yml | 10 +- .../src/frient-keypad/can_handle.lua | 0 .../src/frient-keypad/fingerprints.lua | 0 .../src/frient-keypad/init.lua | 91 +++++++++++++------ .../zigbee-lock/src/sub_drivers.lua | 1 + .../test_frient_keypad_security_system.lua | 72 ++++----------- 12 files changed, 110 insertions(+), 161 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-keypad/config.yml delete mode 100644 drivers/SmartThings/zigbee-keypad/fingerprints.yml delete mode 100644 drivers/SmartThings/zigbee-keypad/src/init.lua delete mode 100644 drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua delete mode 100644 drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua rename drivers/SmartThings/{zigbee-keypad => zigbee-lock}/profiles/frient-keypad-security-system.yml (94%) rename drivers/SmartThings/{zigbee-keypad => zigbee-lock}/src/frient-keypad/can_handle.lua (100%) rename drivers/SmartThings/{zigbee-keypad => zigbee-lock}/src/frient-keypad/fingerprints.lua (100%) rename drivers/SmartThings/{zigbee-keypad => zigbee-lock}/src/frient-keypad/init.lua (85%) rename drivers/SmartThings/{zigbee-keypad => zigbee-lock}/src/test/test_frient_keypad_security_system.lua (88%) diff --git a/drivers/SmartThings/zigbee-keypad/config.yml b/drivers/SmartThings/zigbee-keypad/config.yml deleted file mode 100644 index fbb84bda1f..0000000000 --- a/drivers/SmartThings/zigbee-keypad/config.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: 'Zigbee Keypad' -packageKey: 'zigbee-keypad' -permissions: - zigbee: {} -description: "SmartThings driver for Zigbee keypad devices" -vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/zigbee-keypad/fingerprints.yml b/drivers/SmartThings/zigbee-keypad/fingerprints.yml deleted file mode 100644 index e51e68d381..0000000000 --- a/drivers/SmartThings/zigbee-keypad/fingerprints.yml +++ /dev/null @@ -1,21 +0,0 @@ -zigbeeManufacturer: - - id: "frient A/S/KEPZB-110" - deviceLabel: "frient Intelligent Keypad" - manufacturer: "frient A/S" - model: KEPZB-110 - deviceProfileName: frient-keypad-security-system - - id: "frient A/S/KEPZB-112" - deviceLabel: "frient Alarm Keypad" - manufacturer: "frient A/S" - model: KEPZB-112 - deviceProfileName: frient-keypad-security-system - - id: "frient A/S/KEPZB-120" - deviceLabel: "frient Intelligent Keypad" - manufacturer: "frient A/S" - model: KEPZB-120 - deviceProfileName: frient-keypad-security-system - - id: "frient A/S/KEPZB-122" - deviceLabel: "frient Alarm Keypad" - manufacturer: "frient A/S" - model: KEPZB-122 - deviceProfileName: frient-keypad-security-system diff --git a/drivers/SmartThings/zigbee-keypad/src/init.lua b/drivers/SmartThings/zigbee-keypad/src/init.lua deleted file mode 100644 index 2b24d3109e..0000000000 --- a/drivers/SmartThings/zigbee-keypad/src/init.lua +++ /dev/null @@ -1,24 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local capabilities = require "st.capabilities" -local ZigbeeDriver = require "st.zigbee" -local defaults = require "st.zigbee.defaults" - -local zigbee_keypad_driver = { - supported_capabilities = { - capabilities.securitySystem, - capabilities.battery, - capabilities.refresh, - capabilities.tamperAlert, - capabilities.lockCodes, - capabilities.mode, - capabilities.panicAlarm, - }, - sub_drivers = require("sub_drivers"), - health_check = false, -} - -defaults.register_for_default_handlers(zigbee_keypad_driver, zigbee_keypad_driver.supported_capabilities) -local keypad = ZigbeeDriver("zigbee-keypad", zigbee_keypad_driver) -keypad:run() diff --git a/drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua deleted file mode 100644 index 4a7cc64b45..0000000000 --- a/drivers/SmartThings/zigbee-keypad/src/lazy_load_subdriver.lua +++ /dev/null @@ -1,15 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -return function(sub_driver_name) - -- gets the current lua libs api version - local version = require "version" - local ZigbeeDriver = require "st.zigbee" - if version.api >= 16 then - return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) - elseif version.api >= 9 then - return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) - else - return require(sub_driver_name) - end -end diff --git a/drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua b/drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua deleted file mode 100644 index 83cf18614b..0000000000 --- a/drivers/SmartThings/zigbee-keypad/src/sub_drivers.lua +++ /dev/null @@ -1,8 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local lazy_load_if_possible = require "lazy_load_subdriver" -local sub_drivers = { - lazy_load_if_possible("frient-keypad"), -} -return sub_drivers \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/fingerprints.yml b/drivers/SmartThings/zigbee-lock/fingerprints.yml index 083dd3a11e..9cfa476d62 100644 --- a/drivers/SmartThings/zigbee-lock/fingerprints.yml +++ b/drivers/SmartThings/zigbee-lock/fingerprints.yml @@ -212,6 +212,28 @@ zigbeeManufacturer: manufacturer: Zhenchen model: SG20 deviceProfileName: lock-battery + # frient + - id: "frient A/S/KEPZB-110" + deviceLabel: "frient Intelligent Keypad" + manufacturer: "frient A/S" + model: KEPZB-110 + deviceProfileName: frient-keypad-security-system + - id: "frient A/S/KEPZB-112" + deviceLabel: "frient Alarm Keypad" + manufacturer: "frient A/S" + model: KEPZB-112 + deviceProfileName: frient-keypad-security-system + - id: "frient A/S/KEPZB-120" + deviceLabel: "frient Intelligent Keypad" + manufacturer: "frient A/S" + model: KEPZB-120 + deviceProfileName: frient-keypad-security-system + - id: "frient A/S/KEPZB-122" + deviceLabel: "frient Alarm Keypad" + manufacturer: "frient A/S" + model: KEPZB-122 + deviceProfileName: frient-keypad-security-system + zigbeeGeneric: - id: "genericLock" deviceLabel: Zigbee Lock @@ -220,3 +242,4 @@ zigbeeGeneric: - 0x0101 #Door Lock - 0x0001 #Power Configuration deviceProfileName: lock-battery + diff --git a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-lock/profiles/frient-keypad-security-system.yml similarity index 94% rename from drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml rename to drivers/SmartThings/zigbee-lock/profiles/frient-keypad-security-system.yml index b81d10214a..0fba2527d2 100644 --- a/drivers/SmartThings/zigbee-keypad/profiles/frient-keypad-security-system.yml +++ b/drivers/SmartThings/zigbee-lock/profiles/frient-keypad-security-system.yml @@ -31,7 +31,7 @@ preferences: default: 0 - name: pinMap title: Add PIN code(s) - description: "Format: 1234:Alice,4321:Bob (other format will not be accepted). Only PIN(s) present in this setting are recognized." + description: "Format: 1234:Alice,4321:Bob (other format will not be accepted). Only PIN(s) present in this setting are recognized. PINs that are shorter than current minimal length or longer than current maximal length will be displayed in the settings, but will not be added to the list." required: false preferenceType: string definition: @@ -65,7 +65,7 @@ preferences: required: false preferenceType: integer definition: - minimum: 1 + minimum: 4 maximum: 32 default: 4 - name: maxCodeLength @@ -74,9 +74,9 @@ preferences: required: false preferenceType: integer definition: - minimum: 1 + minimum: 4 maximum: 32 - default: 15 + default: 32 - name: exitDelay title: Exit Delay description: "Turn on exit delay when arming away. Duration in seconds can be set in the 'Exit Delay Duration' setting." @@ -128,7 +128,7 @@ preferences: required: false preferenceType: integer definition: - minimum: 1 + minimum: 4 maximum: 32 default: 4 metadata: diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/can_handle.lua similarity index 100% rename from drivers/SmartThings/zigbee-keypad/src/frient-keypad/can_handle.lua rename to drivers/SmartThings/zigbee-lock/src/frient-keypad/can_handle.lua diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-keypad/src/frient-keypad/fingerprints.lua rename to drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua similarity index 85% rename from drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua rename to drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua index 501e7f24f2..e419d35d54 100644 --- a/drivers/SmartThings/zigbee-keypad/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua @@ -10,7 +10,6 @@ local json = require "st.json" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" -local PowerConfiguration = clusters.PowerConfiguration local IASACE = clusters.IASACE local IASZone = clusters.IASZone local SecuritySystem = capabilities.securitySystem @@ -25,22 +24,25 @@ local PanelStatus = IASACE.types.IasacePanelStatus local AudibleNotification = IASACE.types.IasaceAudibleNotification local AlarmStatus = IASACE.types.IasaceAlarmStatus -local armCommandFromKeypad = false local DEVELCO_MANUFACTURER_CODE = 0x1015 local EXIT_DELAY_UNTIL = "exit_delay_until" local EXIT_DELAY_TARGET_STATUS = "exit_delay_target_status" +local ARM_COMMAND_FROM_KEYPAD = "armCommandFromKeypad" +-- translates a logical security state (armedAway, armedStay, disarmed) into the specific SmartThings securitySystem capability event factory. local SECURITY_STATUS_EVENTS = { armedAway = SecuritySystem.securitySystemStatus.armedAway, armedStay = SecuritySystem.securitySystemStatus.armedStay, disarmed = SecuritySystem.securitySystemStatus.disarmed, } +-- defines the exact string values used for the Mode capability when the keypad is acting like a lock (Locked / Unlocked). local MODE_STATUS_VALUES = { Locked = "Locked", Unlocked = "Unlocked", } +-- converts the Zigbee IAS ACE arm mode coming from the keypad (enum values) into the driver’s internal logical status (armedAway / armedStay / disarmed) that the rest of the driver uses. local ARM_MODE_TO_STATUS = { [ArmMode.DISARM] = "disarmed", [ArmMode.ARM_DAY_HOME_ZONES_ONLY] = "armedStay", @@ -48,6 +50,7 @@ local ARM_MODE_TO_STATUS = { [ArmMode.ARM_ALL_ZONES] = "armedAway", } +-- converts the same Zigbee arm mode into the IAS ACE response code that the driver sends back to the keypad as an acknowledgement. local ARM_MODE_TO_NOTIFICATION = { [ArmMode.DISARM] = ArmNotification.ALL_ZONES_DISARMED, [ArmMode.ARM_DAY_HOME_ZONES_ONLY] = ArmNotification.ONLY_DAY_HOME_ZONES_ARMED, @@ -55,6 +58,7 @@ local ARM_MODE_TO_NOTIFICATION = { [ArmMode.ARM_ALL_ZONES] = ArmNotification.ALL_ZONES_ARMED, } +-- maps the internal logical status to the IAS ACE panel status used in PanelStatusChanged so the keypad UI can show the correct panel state. local STATUS_TO_PANEL = { armedAway = PanelStatus.ARMED_AWAY, armedStay = PanelStatus.ARMED_STAY, @@ -62,6 +66,7 @@ local STATUS_TO_PANEL = { exitDelay = PanelStatus.EXIT_DELAY, } +-- converts the internal logical status into a human‑readable activity string for security system events (used in history/activity text). local STATUS_TO_ACTIVITY = { armedAway = "armed away", armedStay = "armed stay", @@ -69,6 +74,7 @@ local STATUS_TO_ACTIVITY = { exitDelay = "exit delay", } +-- converts lock‑style statuses (Locked/Unlocked) into the activity label used when running in Mode (lock) mode, so the history text is consistent. local LOCK_STATUS_TO_ACTIVITY = { Locked = "Locked", Unlocked = "Unlocked", @@ -122,18 +128,18 @@ local function is_pin_length_valid(device, pin) return true end -local function parse_user_map(value) +local function parse_user_map(device, value) local map = {} - if value == nil or value == "" then - return map - end + if value == nil or value == "" then + return map + end - for pair in string.gmatch(value, "[^,]+") do - local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") - if code ~= nil and name ~= nil and code ~= "" and name ~= "" then - map[code] = name - end + for pair in string.gmatch(value, "[^,]+") do + local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") + if code ~= nil and name ~= nil and code ~= "" and name ~= "" and is_pin_length_valid(device, code) then + map[code] = name end + end return map end @@ -168,8 +174,8 @@ local function start_exit_delay(device, target_status) end local function build_lock_code_state_from_prefs(device) - local pin_updates = parse_user_map(device.preferences.pinMap) - local rfid_updates = parse_user_map(device.preferences.rfidMap) + local pin_updates = parse_user_map(device, device.preferences.pinMap) + local rfid_updates = parse_user_map(device, device.preferences.rfidMap) local lock_codes = {} local lock_code_pins = {} @@ -205,8 +211,8 @@ end local function build_user_map_from_prefs(device) return { - pins = parse_user_map(device.preferences.pinMap), - rfids = parse_user_map(device.preferences.rfidMap), + pins = parse_user_map(device, device.preferences.pinMap), + rfids = parse_user_map(device, device.preferences.rfidMap), } end @@ -368,9 +374,21 @@ local function get_current_status(device) end end +local function normalize_panel_status(device, status) + if tonumber(device.preferences.mode) == 1 then + if status == "Locked" then + return "armedAway" + elseif status == "Unlocked" then + return "disarmed" + end + end + return status +end + local function send_panel_status(device, status) local duration = get_exit_delay_duration(device) - local panel_status = STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM + local normalized = normalize_panel_status(device, status) + local panel_status = STATUS_TO_PANEL[normalized] or PanelStatus.PANEL_DISARMED_READY_TO_ARM device:send(IASACE.client.commands.PanelStatusChanged( device, panel_status, @@ -389,26 +407,30 @@ local function can_process_arm_command(command, status) end local function handle_arm_command(driver, device, zb_rx) - armCommandFromKeypad = true + device:set_field(ARM_COMMAND_FROM_KEYPAD, true, { persist = false }) local cmd = zb_rx.body.zcl_body local pin = cmd.arm_disarm_code.value local status = ARM_MODE_TO_STATUS[cmd.arm_mode.value] if status == nil then + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = false }) return end if pin == nil or pin == "" then + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = false }) return end if not is_pin_length_valid(device, pin) then + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = false }) return end local user, auth_type = resolve_user_from_code(device, pin) if user == nil then device:emit_event(LockCodes.codeChanged(tostring(pin) .. " is not assigned to any user on this keypad. You can create a new user with this code in settings.", { state_change = true })) + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = false }) return end @@ -421,7 +443,7 @@ local function handle_arm_command(driver, device, zb_rx) if is_exit_delay_active(device) then device:send(IASACE.client.commands.ArmResponse(device, 0xFF)) - armCommandFromKeypad = false + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = false }) return end @@ -451,7 +473,7 @@ local function handle_arm_command(driver, device, zb_rx) 0xFF )) end - armCommandFromKeypad = false + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = false }) end local function handle_get_panel_status(driver, device, zb_rx) @@ -478,6 +500,17 @@ end local function handle_emergency_command(driver, device, zb_rx) if device.preferences.panicAlarmActive == false then + local status = get_current_status(device) + local normalized = normalize_panel_status(device, status) + local panel_status = STATUS_TO_PANEL[normalized] or PanelStatus.PANEL_DISARMED_READY_TO_ARM + local duration = get_exit_delay_duration(device) + device:send(IASACE.client.commands.PanelStatusChanged( + device, + panel_status, + duration, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + )) return end device:emit_event(panicAlarm.panicAlarm.panic({ state_change = true })) @@ -490,7 +523,8 @@ local function handle_arm(device, status) if is_exit_delay_active(device) then return end - if not armCommandFromKeypad and can_process_arm_command(status, get_current_security_status(device)) then + local commandFromKeypad = device:get_field(ARM_COMMAND_FROM_KEYPAD) + if not commandFromKeypad and can_process_arm_command(status, get_current_security_status(device)) then if device.preferences.exitDelay == true and status == "armedAway" and tonumber(device.preferences.mode) == 0 then duration = start_exit_delay(device, status) device.thread:call_with_delay(duration, function() @@ -515,7 +549,8 @@ local function handle_lock(device, status) if is_exit_delay_active(device) then return end - if not armCommandFromKeypad and can_process_arm_command(status, get_current_mode_status(device)) then + local commandFromKeypad = device:get_field(ARM_COMMAND_FROM_KEYPAD) + if not commandFromKeypad and can_process_arm_command(status, get_current_mode_status(device)) then emit_mode_event(device, status, { source = "app" }) emit_mode_activity(device, status, "App") if tonumber(device.preferences.mode) == 1 then @@ -538,7 +573,8 @@ local function handle_disarm(driver, device, command) if is_exit_delay_active(device) then clear_exit_delay(device) end - if can_process_arm_command("disarmed", get_current_security_status(device)) and not armCommandFromKeypad then + local commandFromKeypad = device:get_field(ARM_COMMAND_FROM_KEYPAD) + if can_process_arm_command("disarmed", get_current_security_status(device)) and not commandFromKeypad then emit_status_event(device, "disarmed", { source = "app" }) emit_security_activity(device, "disarmed", "App") if tonumber(device.preferences.mode) == 0 then @@ -553,7 +589,8 @@ local function handle_unlock(driver, device, command) if is_exit_delay_active(device) then clear_exit_delay(device) end - if can_process_arm_command("Unlocked", get_current_mode_status(device)) and not armCommandFromKeypad then + local commandFromKeypad = device:get_field(ARM_COMMAND_FROM_KEYPAD) + if can_process_arm_command("Unlocked", get_current_mode_status(device)) and not commandFromKeypad then emit_mode_event(device, "Unlocked", { source = "app" }) emit_mode_activity(device, "Unlocked", "App") if tonumber(device.preferences.mode) == 1 then @@ -581,7 +618,7 @@ local function update_user_map(device) end local function refresh(driver, device) - device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + device:refresh() send_panel_status(device, get_current_status(device)) end @@ -624,8 +661,10 @@ end local function do_configure(self, device) device:send(device_management.build_bind_request(device, IASACE.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(PowerConfiguration.attributes.BatteryVoltage:configure_reporting(device, 30, 21600, 1)) + -- Configure IAS Zone here to avoid enabling IAS Zone defaults for all zigbee-lock devices. + device:send(device_management.build_bind_request(device, IASZone.ID, self.environment_info.hub_zigbee_eui)) + device:send(IASZone.attributes.ZoneStatus:configure_reporting(device, 0, 300, 1)) + device:configure() end local function send_iasace_mfg_write(device, attr_id, data_type, payload) diff --git a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua index ff4bf8980d..e5387448cf 100644 --- a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua @@ -7,5 +7,6 @@ local sub_drivers = { lazy_load_if_possible("yale"), lazy_load_if_possible("yale-fingerprint-lock"), lazy_load_if_possible("lock-without-codes"), + lazy_load_if_possible("frient-keypad"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua similarity index 88% rename from drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua index 4bf2aeb008..6ddf48de26 100644 --- a/drivers/SmartThings/zigbee-keypad/src/test/test_frient_keypad_security_system.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua @@ -40,36 +40,12 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) test.socket.capability:__set_channel_ordering("relaxed") - --[[ test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.mode.supportedModes({ "Locked", "Unlocked" }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.mode.supportedArguments({ "Locked", "Unlocked" }, { visibility = { displayed = false } }) - ) - ) ]] test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.lockCodes.lockCodes(json.encode({}), { state_change = true }, { visibility = { displayed = true } }) ) ) - --[[ test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.securitySystem.supportedSecuritySystemStatuses({ "armedAway", "armedStay", "disarmed" }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.securitySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } }) - ) - ) ]] test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -79,7 +55,7 @@ local function test_init() test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }) + capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }) ) ) test.socket.capability:__expect_send( @@ -153,30 +129,6 @@ test.register_coroutine_test( capabilities.securitySystem.supportedSecuritySystemCommands({ "armAway", "armStay", "disarm" }, { visibility = { displayed = false } }) ) ) - --[[ test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.mode.mode("unlocked", { state_change = true }) - ) - ) ]] - --[[ test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.codeChanged("Lock unlocked by App", { state_change = true, data = { codeName = "App" } }) - ) - ) ]] - --[[ test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockCodes.codeChanged("Security System disarmed by App", { state_change = true, data = { codeName = "App" } }) - ) - ) ]] end ) @@ -190,6 +142,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASACE.ID) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASZone.ID) + }) test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) @@ -198,6 +154,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 0, 300, 1) + }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end @@ -334,7 +294,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -346,7 +306,7 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", delete_data }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) end ) @@ -358,7 +318,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.mode.mode("Unlocked", { state_change = true })) ) @@ -397,7 +357,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.mode.mode("Unlocked", { state_change = true })) ) @@ -446,7 +406,7 @@ test.register_coroutine_test( mock_device.id, IASACE.client.commands.PanelStatusChanged( mock_device, - PanelStatus.PANEL_DISARMED_READY_TO_ARM, + PanelStatus.ARMED_AWAY, 5, AudibleNotification.DEFAULT_SOUND, AlarmStatus.NO_ALARM @@ -464,7 +424,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -520,7 +480,7 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(15, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) test.socket.capability:__expect_send( mock_device:generate_test_message( From 71f64e67bdb9a1bb684691a225c9a928b7d11cef Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Wed, 13 May 2026 15:03:56 +0200 Subject: [PATCH 15/15] improve test coverage --- .../src/frient-keypad/fingerprints.lua | 2 +- .../zigbee-lock/src/frient-keypad/init.lua | 87 ++- .../test_frient_keypad_security_system.lua | 639 +++++++++++++++++- 3 files changed, 704 insertions(+), 24 deletions(-) diff --git a/drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua index d7cb5d7b33..e5f91902bf 100644 --- a/drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua +++ b/drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua @@ -8,4 +8,4 @@ local FRIENT_DEVICE_FINGERPRINTS = { { mfr = "frient A/S", model = "KEPZB-122"}, } -return FRIENT_DEVICE_FINGERPRINTS \ No newline at end of file +return FRIENT_DEVICE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua index e419d35d54..f306c681c7 100644 --- a/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua @@ -109,12 +109,12 @@ end local function is_pin_length_valid(device, pin) local pinStr = tostring(pin) - if pinStr:sub(1,1) == "+" then -- device adds + to the rfid codes, so ignore length check for those - return true - end if pin == nil or pin == "" then return false end + if pinStr:sub(1,1) == "+" then -- device adds + to the rfid codes, so ignore length check for those + return true + end local min_len = device.preferences.minCodeLength local max_len = device.preferences.maxCodeLength local len = string.len(tostring(pin)) @@ -128,7 +128,7 @@ local function is_pin_length_valid(device, pin) return true end -local function parse_user_map(device, value) +local function parse_user_map_from_pin(device, value) local map = {} if value == nil or value == "" then return map @@ -136,7 +136,23 @@ local function parse_user_map(device, value) for pair in string.gmatch(value, "[^,]+") do local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") - if code ~= nil and name ~= nil and code ~= "" and name ~= "" and is_pin_length_valid(device, code) then + if name ~= nil and name ~= "" and is_pin_length_valid(device, code) and tonumber(code) ~= nil then + map[code] = name + end + end + + return map +end + +local function parse_user_map_from_rfid(device, value) + local map = {} + if value == nil or value == "" then + return map + end + + for pair in string.gmatch(value, "[^,]+") do + local code, name = pair:match("^%s*([^:]+)%s*:%s*(.+)%s*$") + if name ~= nil and name ~= "" and is_pin_length_valid(device, code) then map[code] = name end end @@ -174,8 +190,8 @@ local function start_exit_delay(device, target_status) end local function build_lock_code_state_from_prefs(device) - local pin_updates = parse_user_map(device, device.preferences.pinMap) - local rfid_updates = parse_user_map(device, device.preferences.rfidMap) + local pin_updates = parse_user_map_from_pin(device, device.preferences.pinMap) + local rfid_updates = parse_user_map_from_rfid(device, device.preferences.rfidMap) local lock_codes = {} local lock_code_pins = {} @@ -211,8 +227,8 @@ end local function build_user_map_from_prefs(device) return { - pins = parse_user_map(device, device.preferences.pinMap), - rfids = parse_user_map(device, device.preferences.rfidMap), + pins = parse_user_map_from_pin(device, device.preferences.pinMap), + rfids = parse_user_map_from_rfid(device, device.preferences.rfidMap), } end @@ -220,7 +236,14 @@ local function build_lock_codes_payload(device, lock_codes, lock_pins) local payload = {} local show_pins = device.preferences.showPinSnapshot ~= false - for slot, name in pairs(lock_codes or {}) do + local slots = {} + for slot, _ in pairs(lock_codes or {}) do + slots[#slots + 1] = slot + end + table.sort(slots, function(a, b) return tonumber(a) < tonumber(b) end) + + for _, slot in ipairs(slots) do + local name = lock_codes[slot] local pin = lock_pins and lock_pins[slot] or nil if show_pins and pin ~= nil and pin ~= "" then payload[slot] = string.format("%s: %s", name, pin) @@ -233,11 +256,33 @@ local function build_lock_codes_payload(device, lock_codes, lock_pins) end local function encode_payload(payload) - local ok, encoded = pcall(json.encode, utils.deep_copy(payload)) - if ok and type(encoded) == "string" then - return encoded + if type(payload) ~= "table" then + local ok, encoded = pcall(json.encode, payload) + if ok and type(encoded) == "string" then + return encoded + end + return "{}" end - return "{}" + + local keys = {} + for key, _ in pairs(payload) do + keys[#keys + 1] = key + end + if #keys == 0 then + return "[]" + end + table.sort(keys, function(a, b) return tonumber(a) < tonumber(b) end) + + local parts = {} + for _, key in ipairs(keys) do + local ok_key, encoded_key = pcall(json.encode, key) + local ok_val, encoded_val = pcall(json.encode, payload[key]) + if ok_key and ok_val then + parts[#parts + 1] = string.format("%s:%s", encoded_key, encoded_val) + end + end + + return "{" .. table.concat(parts, ",") .. "}" end local function emit_lock_codes(device, lock_codes, lock_pins) @@ -399,11 +444,7 @@ local function send_panel_status(device, status) end local function can_process_arm_command(command, status) - if command == status then - return false - else - return true - end + return command ~= status end local function handle_arm_command(driver, device, zb_rx) @@ -683,12 +724,13 @@ end local function info_changed(driver, device, event, args) emit_lock_code_limits(device) + local should_update_user_map = false for name, value in pairs(device.preferences) do if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then if (name == "pinMap") then - update_user_map(device) + should_update_user_map = true elseif (name == "rfidMap") then - update_user_map(device) + should_update_user_map = true elseif (name == "autoArmDisarmMode") then local autoArmDisarmMode = tonumber(device.preferences.autoArmDisarmMode) if autoArmDisarmMode ~= nil then @@ -720,6 +762,9 @@ local function info_changed(driver, device, event, args) end end end + if should_update_user_map then + update_user_map(device) + end end local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua index 6ddf48de26..5d5c0a9c08 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua @@ -9,6 +9,8 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local json = require "st.json" local utils = require "st.utils" local dkjson = require "dkjson" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" local IASACE = clusters.IASACE local IASZone = clusters.IASZone @@ -220,6 +222,22 @@ test.register_message_test( } ) +test.register_message_test( + "IAS Zone tamper attribute report emits tamper clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + } +) + test.register_message_test( "IAS Zone status change notification emits tamper clear", { @@ -236,6 +254,22 @@ test.register_message_test( } ) +test.register_message_test( + "IAS Zone status change notification emits tamper detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0004, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + } +) + test.register_coroutine_test( "App armAway emits security status, activity, and panel status", function() @@ -301,12 +335,112 @@ test.register_coroutine_test( capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Alice: 1234" }), { state_change = true }, { visibility = { displayed = true } }) ) ) + end +) + +test.register_coroutine_test( + "infoChanged pinMap add updates lockCodes rejecting invalid pins", + function() + local add_data = info_changed_device_data({ pinMap = "1234:Alice,asded:Bob,23ad23:Charlie,4321:David", showPinSnapshot = true }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", add_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes("{\"1\":\"Alice: 1234\",\"2\":\"David: 4321\"}", { state_change = true }, { visibility = { displayed = true } }) + ) + ) + end +) + +test.register_coroutine_test( + "infoChanged pinMap and rfidMap filters invalid pins and keeps RFID prefix", + function() + local update_data = info_changed_device_data({ + pinMap = "123:Short,1234:Good,12AB:Bad,123456789012345678901234567890123:TooLong", + rfidMap = "+AB:Tag", + showPinSnapshot = true + }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes( + "{\"1\":\"Good: 1234\",\"2\":\"Tag: +AB\"}", + { state_change = true }, + { visibility = { displayed = true } } + ) + ) + ) + end +) + +test.register_coroutine_test( + "infoChanged pinMap and rfidMap are sorted deterministically", + function() + local update_data = info_changed_device_data({ + pinMap = "9999:Zed,1111:Ann", + rfidMap = "+BBBB:Bee,+AAAA:Ace", + showPinSnapshot = true + }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes( + "{\"1\":\"Ann: 1111\",\"2\":\"Zed: 9999\",\"3\":\"Ace: +AAAA\",\"4\":\"Bee: +BBBB\"}", + { state_change = true }, + { visibility = { displayed = true } } + ) + ) + ) + end +) - local delete_data = info_changed_device_data({ deletePinMap = "1234", pinMap = "1234:Alice", showPinSnapshot = true }) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", delete_data }) +test.register_coroutine_test( + "infoChanged updates IAS ACE preference writes", + function() + local update_data = info_changed_device_data({ + autoArmDisarmMode = 2, + autoDisarmModeSetting = true, + autoArmModeSetting = 3, + autoArmModeSettingBool = false, + pinLengthSetting = 6, + }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + + local auto_arm_disarm_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8003, 0x1015, data_types.Enum8, 2) + auto_arm_disarm_msg.body.zcl_header.frame_ctrl:set_direction_client() + local auto_disarm_mode_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8004, 0x1015, data_types.Boolean, true) + auto_disarm_mode_msg.body.zcl_header.frame_ctrl:set_direction_client() + local auto_arm_mode_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8005, 0x1015, data_types.Enum8, 3) + auto_arm_mode_msg.body.zcl_header.frame_ctrl:set_direction_client() + local auto_arm_mode_bool_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8005, 0x1015, data_types.Enum8, 0) + auto_arm_mode_bool_msg.body.zcl_header.frame_ctrl:set_direction_client() + local pin_length_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8006, 0x1015, data_types.Uint8, 6) + pin_length_msg.body.zcl_header.frame_ctrl:set_direction_client() + + test.socket.zigbee:__expect_send({ mock_device.id, auto_arm_disarm_msg }) + test.socket.zigbee:__expect_send({ mock_device.id, auto_disarm_mode_msg }) + test.socket.zigbee:__expect_send({ mock_device.id, auto_arm_mode_msg }) + test.socket.zigbee:__expect_send({ mock_device.id, auto_arm_mode_bool_msg }) + test.socket.zigbee:__expect_send({ mock_device.id, pin_length_msg }) end ) @@ -416,6 +550,101 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Mode setMode unlocked emits mode and panel status", + function() + local update_data = info_changed_device_data({ mode = 1 }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mode.mode("Unlocked", { state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock Unlocked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.mode.ID, component = "main", command = capabilities.mode.commands.setMode.NAME, args = { "Locked" }, named_args = { mode = "Locked" } } + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mode.mode("Locked", { state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock Locked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.mode.ID, component = "main", command = capabilities.mode.commands.setMode.NAME, args = { "Unlocked" }, named_args = { mode = "Unlocked" } } + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mode.mode("Unlocked", { state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.codeChanged("Lock Unlocked by App", { state_change = true, data = { codeName = "App" } }) + ) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + test.register_coroutine_test( "IAS ACE Arm with known PIN arms system and responds", function() @@ -517,5 +746,411 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Emergency command does not trigger panicAlarm if panicAlarmActive preference is set to false and sends correct response (AlarmStatus.NO_ALARM) to prevent keypad from blinking the yellow LED", + function() + local update_data = info_changed_device_data({ panicAlarmActive = false }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, IASACE.server.commands.Emergency.build_test_rx(mock_device) }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_coroutine_test( + "Emergency command with panicAlarmActive false uses current panel status", + function() + local update_data = info_changed_device_data({ panicAlarmActive = false }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.securitySystem.ID, component = "main", command = capabilities.securitySystem.commands.armAway.NAME, args = {} } + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System armed away by App", { state_change = true, data = { codeName = "App" } })) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, IASACE.server.commands.Emergency.build_test_rx(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +test.register_coroutine_test( + "PINs and rfids are not displayed when showPinSnapshot is set to false", + function() + local update_data = info_changed_device_data({ rfidMap = "+ABCD1234:Alice", pinMap = "1111:Bob,2222:Charlie", showPinSnapshot = false }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes("{\"1\":\"Bob\",\"2\":\"Charlie\",\"3\":\"Alice\"}", { state_change = true }, { visibility = { displayed = true } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "App armAway with exit delay sends panel status and clears after delay", + function() + local update_data = info_changed_device_data({ exitDelay = true, duration = 10 }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + -- Ensure both channels use relaxed ordering so interleaved zigbee/capability + -- messages from the exit-delay flow are matched reliably by the harness. + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.securitySystem.ID, component = "main", command = capabilities.securitySystem.commands.armAway.NAME, args = {} } + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.EXIT_DELAY, + 10, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + }) + + test.socket.capability:__set_channel_ordering("relaxed") + --[[ test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System armed away by App", { state_change = true, data = { codeName = "App" } })) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 10, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) ]] + + -- allow the command processing to run and any immediate emissions to be delivered + test.wait_for_events() + end +) + +test.register_coroutine_test( + "GetPanelStatus returns EXIT_DELAY during active exit delay", + function() + local update_data = info_changed_device_data({ exitDelay = true, duration = 10 }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.securitySystem.ID, component = "main", command = capabilities.securitySystem.commands.armAway.NAME, args = {} } + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.EXIT_DELAY, + 10, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, IASACE.server.commands.GetPanelStatus.build_test_rx(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.GetPanelStatusResponse( + mock_device, + PanelStatus.EXIT_DELAY, + 10, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + end +) + +--[[ test.register_coroutine_test( + "RFID disarm command in auto-disarm mode works when armedAway", + function() + local update_data = info_changed_device_data({ rfidMap = "+ABCD1234:Alice", autoArmDisarmMode = 0, autoDisarmModeSetting = true }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Alice" }), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + + -- allow the command processing to run and any immediate emissions to be delivered + test.wait_for_events() + local auto_disarm_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8004, 0x1015, data_types.Boolean, true) + auto_disarm_msg.body.zcl_header.frame_ctrl:set_direction_client() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, auto_disarm_msg }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.securitySystem.ID, component = "main", command = capabilities.securitySystem.commands.armAway.NAME, args = {} } + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System armed away by App", { state_change = true, data = { codeName = "App" } })) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.DISARM, "+ABCD1234", 0) + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System disarmed by Alice", { state_change = true, data = { codeName = "Alice" } })) + ) + test.socket.zigbee:__expect_send( + { mock_device.id, IASACE.client.commands.ArmResponse(mock_device, ArmNotification.ALL_ZONES_DISARMED) } + ) + end +) + +test.register_coroutine_test( + "PIN disarm command in auto-disarm mode works when armedAway", + function() + local update_data = info_changed_device_data({ rfidMap = "1234:Alice", autoArmDisarmMode = 2, autoDisarmModeSetting = true}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Alice" }), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + local auto_arm_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8003, 0x1015, data_types.Enum8, 2) + auto_arm_msg.body.zcl_header.frame_ctrl:set_direction_client() + local auto_disarm_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8004, 0x1015, data_types.Boolean, true) + auto_disarm_msg.body.zcl_header.frame_ctrl:set_direction_client() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, auto_arm_msg }) + test.socket.zigbee:__expect_send({ mock_device.id, auto_disarm_msg }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.securitySystem.ID, component = "main", command = capabilities.securitySystem.commands.armAway.NAME, args = {} } + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System armed away by App", { state_change = true, data = { codeName = "App" } })) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.DISARM, "1234", 0) + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System disarmed by Alice", { state_change = true, data = { codeName = "Alice" } })) + ) + test.socket.zigbee:__expect_send( + { mock_device.id, IASACE.client.commands.ArmResponse(mock_device, ArmNotification.ALL_ZONES_DISARMED) } + ) + end +) ]] + +--[[ test.register_coroutine_test( + "PIN disarm command in auto-disarm mode works when armedAway and pin length for auto arming/disarming is changed", + function() + local update_data = info_changed_device_data({ rfidMap = "123456:Alice", autoArmDisarmMode = 2, autoDisarmModeSetting = true, pinLengthSetting = 6 }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", update_data }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(32, { visibility = { displayed = true } }))) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCodes.lockCodes(json.encode({ ["1"] = "Alice" }), { state_change = true }, { visibility = { displayed = true } }) + ) + ) + local auto_arm_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8003, 0x1015, data_types.Enum8, 2) + auto_arm_msg.body.zcl_header.frame_ctrl:set_direction_client() + local auto_disarm_msg = cluster_base.write_manufacturer_specific_attribute(mock_device, IASACE.ID, 0x8004, 0x1015, data_types.Boolean, true) + auto_disarm_msg.body.zcl_header.frame_ctrl:set_direction_client() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, auto_arm_msg }) + test.socket.zigbee:__expect_send({ mock_device.id, auto_disarm_msg }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.securitySystem.ID, component = "main", command = capabilities.securitySystem.commands.armAway.NAME, args = {} } + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.armedAway({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System armed away by App", { state_change = true, data = { codeName = "App" } })) + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASACE.client.commands.PanelStatusChanged( + mock_device, + PanelStatus.ARMED_AWAY, + 5, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + ) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASACE.server.commands.Arm.build_test_rx(mock_device, ArmMode.DISARM, "1234", 0) + }) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.securitySystem.securitySystemStatus.disarmed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("Security System disarmed by Alice", { state_change = true, data = { codeName = "Alice" } })) + ) + test.socket.zigbee:__expect_send( + { mock_device.id, IASACE.client.commands.ArmResponse(mock_device, ArmNotification.ALL_ZONES_DISARMED) } + ) + end +) + ]] + test.run_registered_tests()