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-lock/profiles/frient-keypad-security-system.yml b/drivers/SmartThings/zigbee-lock/profiles/frient-keypad-security-system.yml new file mode 100644 index 0000000000..0fba2527d2 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/profiles/frient-keypad-security-system.yml @@ -0,0 +1,136 @@ +name: frient-keypad-security-system +components: +- id: main + capabilities: + - id: securitySystem + version: 1 + - id: lockCodes + version: 1 + - id: battery + version: 1 + - id: tamperAlert + version: 1 + - id: refresh + version: 1 + - id: mode + version: 1 + - id: panicAlarm + version: 1 + categories: + - name: SecurityPanel +preferences: + - name: mode + 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: "Mode Status" + 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. 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: + stringType: text + default: "" + - name: rfidMap + title: Add RFID(s) + 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: + 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)." + required: false + preferenceType: boolean + definition: + default: false + - name: minCodeLength + title: Minimal PIN Length + description: "Minimal allowed PIN length." + required: false + preferenceType: integer + definition: + minimum: 4 + maximum: 32 + default: 4 + - name: maxCodeLength + title: Maximal PIN Length + description: "Maximal allowed PIN length." + required: false + preferenceType: integer + definition: + minimum: 4 + maximum: 32 + 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." + 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 Arm/Disarm Mode + description: "Automatically arm/disarm (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 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/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: + options: + 0: "Disabled" + 1: "Arm Away" + 3: "Arm Stay" + 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 (lock/unlock)." + required: false + preferenceType: integer + definition: + minimum: 4 + maximum: 32 + default: 4 +metadata: + mnmn: SmartThings + vid: SmartThings-smartthings-frient_Keypad \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/frient-keypad/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/can_handle.lua new file mode 100644 index 0000000000..9401f22403 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/frient-keypad/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_keypad_can_handle(opts, driver, device, ...) + 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 + +return frient_keypad_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/fingerprints.lua new file mode 100644 index 0000000000..e5f91902bf --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/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 diff --git a/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua b/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua new file mode 100644 index 0000000000..f306c681c7 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/frient-keypad/init.lua @@ -0,0 +1,824 @@ +-- 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 utils = require "st.utils" +local json = require "st.json" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local IASACE = clusters.IASACE +local IASZone = clusters.IASZone +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 +local PanelStatus = IASACE.types.IasacePanelStatus +local AudibleNotification = IASACE.types.IasaceAudibleNotification +local AlarmStatus = IASACE.types.IasaceAlarmStatus + +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", + [ArmMode.ARM_NIGHT_SLEEP_ZONES_ONLY] = "armedStay", + [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, + [ArmMode.ARM_NIGHT_SLEEP_ZONES_ONLY] = ArmNotification.ONLY_NIGHT_SLEEP_ZONES_ARMED, + [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, + disarmed = PanelStatus.PANEL_DISARMED_READY_TO_ARM, + 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", + disarmed = "disarmed", + 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", +} + +local function emit_supported(device) + 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 + +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 }) + 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 }) + device:emit_event(event) +end + +local function emit_mode_status_event(device, status, extra_data) + 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 is_pin_length_valid(device, pin) + local pinStr = tostring(pin) + 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)) + + 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_from_pin(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) 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 + + return map +end + +local function get_exit_delay_duration(device) + local duration = device.preferences.duration + 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_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 = {} + 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 build_user_map_from_prefs(device) + return { + pins = parse_user_map_from_pin(device, device.preferences.pinMap), + rfids = parse_user_map_from_rfid(device, device.preferences.rfidMap), + } +end + +local function build_lock_codes_payload(device, lock_codes, lock_pins) + local payload = {} + local show_pins = device.preferences.showPinSnapshot ~= false + + 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) + else + payload[slot] = name + end + end + + return payload +end + +local function encode_payload(payload) + if type(payload) ~= "table" then + local ok, encoded = pcall(json.encode, payload) + if ok and type(encoded) == "string" then + return encoded + end + return "{}" + end + + 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) + local full_payload = build_lock_codes_payload(device, lock_codes, lock_pins) + local full_encoded = encode_payload(full_payload) + device:emit_event(LockCodes.lockCodes(full_encoded, { state_change = true }, { visibility = { displayed = true } })) +end + +local function emit_lock_code_limits(device) + 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 } })) + end + if max_len ~= nil then + device:emit_event(LockCodes.maxCodeLength(max_len, { visibility = { displayed = true } })) + end +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 get_user_map(device) + local map = device:get_field("user_map") + if map == nil then + map = build_user_map_from_prefs(device) + end + return 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 = {} + + for pin, _ in pairs(pin_map) do + pins[#pins + 1] = pin + end + for rfid, _ in pairs(rfid_map) do + rfids[#rfids + 1] = rfid + end + + table.sort(pins) + table.sort(rfids) + + for index, pin in ipairs(pins) do + if pin == code then + return { name = normalize_user_name(pin_map[pin]), index = index }, "pin" + end + end + + 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 + + return nil, nil +end + +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 + 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 + device:emit_event(event) +end + +local function emit_arm_activity(device, status, user_name) + local activity + 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"] + end + elseif tonumber(device.preferences.mode) == 0 then + 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 }) + if user_name ~= nil then + event.data = { codeName = user_name } + end + 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 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 + +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 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, + duration, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + )) +end + +local function can_process_arm_command(command, status) + return command ~= status +end + +local function handle_arm_command(driver, device, zb_rx) + 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 + + local data = { + source = "keypad", + authType = auth_type, + userIndex = user.index, + userName = user.name, + } + + if is_exit_delay_active(device) then + device:send(IASACE.client.commands.ArmResponse(device, 0xFF)) + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = 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 = 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( + device, + ARM_MODE_TO_NOTIFICATION[cmd.arm_mode.value] or ArmNotification.ALL_ZONES_DISARMED + )) + end) + else + emit_mode_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 + device:send(IASACE.client.commands.ArmResponse( + device, + 0xFF + )) + end + device:set_field(ARM_COMMAND_FROM_KEYPAD, false, { persist = false }) +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, + STATUS_TO_PANEL[status] or PanelStatus.PANEL_DISARMED_READY_TO_ARM, + duration, + AudibleNotification.DEFAULT_SOUND, + AlarmStatus.NO_ALARM + )) +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 })) + 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 + if is_exit_delay_active(device) then + return + end + 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() + clear_exit_delay(device) + emit_status_event(device, status, { source = "app" }) + emit_security_activity(device, status, "App") + send_panel_status(device, status) + end) + else + 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) + if is_exit_delay_active(device) then + return + end + 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 + send_panel_status(device, status) + end + else + return + end +end + +local function handle_arm_away(driver, device, command) + handle_arm(device, "armedAway") +end + +local function handle_arm_stay(driver, device, command) + handle_arm(device, "armedStay") +end + +local function handle_disarm(driver, device, command) + if is_exit_delay_active(device) then + clear_exit_delay(device) + end + 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 + send_panel_status(device, "disarmed") + end + else + return + end +end + +local function handle_unlock(driver, device, command) + if is_exit_delay_active(device) then + clear_exit_delay(device) + end + 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 + 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") + elseif desired == "Unlocked" then + handle_unlock(driver, device, command) + end +end + +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:refresh() + 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 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 + 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) +end + +local function do_configure(self, device) + device:send(device_management.build_bind_request(device, IASACE.ID, self.environment_info.hub_zigbee_eui)) + -- 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) + 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 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) + device:emit_event(panicAlarm.panicAlarm.clear({ state_change = true })) +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 + should_update_user_map = true + elseif (name == "rfidMap") then + should_update_user_map = true + 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 + 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 == "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 + 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 + elseif (name == "mode") then + get_and_update_state(device) + refresh(driver, device) + 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) + 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_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, + infoChanged = info_changed, + }, + zigbee_handlers = { + cluster = { + [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 + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status + }, + } + }, + capability_handlers = { + [SecuritySystem.ID] = { + [SecuritySystem.commands.armAway.NAME] = handle_arm_away, + [SecuritySystem.commands.armStay.NAME] = handle_arm_stay, + [SecuritySystem.commands.disarm.NAME] = handle_disarm, + }, + [mode.ID] = { + [mode.commands.setMode.NAME] = handle_set_mode, + }, + [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-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-lock/src/test/test_frient_keypad_security_system.lua b/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua new file mode 100644 index 0000000000..5d5c0a9c08 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_frient_keypad_security_system.lua @@ -0,0 +1,1156 @@ +-- 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 cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +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.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(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.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" } }) + ) + ) + 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) + +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 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", + 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 } }) + ) + ) + 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, 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) + }) + test.socket.zigbee:__expect_send({ + 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 +) + +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 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", + { + { + 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_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() + 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 + ) + } + ) + 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", 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(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 +) + +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 +) + +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(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 + ) + } + ) + 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(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 + ) + } + ) + 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() + 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") + 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"] = "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("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) } + ) + 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, 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( + json.encode({ ["1"] = very_long_name .. ": 1234" }), + { state_change = true }, + { visibility = { displayed = true } } + ) + ) + ) + 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.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() +