From 0c1bb36a2c4407bc3448e255bf1e53b0e3cdd867 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 25 Nov 2025 08:51:25 +0100 Subject: [PATCH 01/14] initial commit --- .../zigbee-switch/fingerprints.yml | 5 + .../profiles/frient-io-output-switch.yml | 23 + .../profiles/switch-4inputs-2outputs.yml | 107 ++++ .../src/configurations/devices.lua | 61 ++ .../zigbee-switch/src/frient-IO/init.lua | 589 ++++++++++++++++++ .../src/frient-IO/unbind_request.lua | 95 +++ .../SmartThings/zigbee-switch/src/init.lua | 3 +- .../src/test/test_frient_IO_module.lua | 509 +++++++++++++++ 8 files changed, 1391 insertions(+), 1 deletion(-) create mode 100644 drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml create mode 100644 drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index d5f2e4d8df..3388ddc43a 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -500,6 +500,11 @@ zigbeeManufacturer: manufacturer: frient A/S model: SMRZB-342 deviceProfileName: frient-switch-power-energy-voltage + - id: "frient/IOMZB-110" + deviceLabel: frient IO Module + manufacturer: frient A/S + model: IOMZB-110 + deviceProfileName: switch-4inputs-2outputs - id: "AduroSmart Eria/AD-DimmableLight3001" deviceLabel: Eria Light manufacturer: AduroSmart Eria diff --git a/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml new file mode 100644 index 0000000000..88f03aea0f --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml @@ -0,0 +1,23 @@ +name: frient-io-output-switch +components: + - id: main + capabilities: + - id: switch + version: 1 +preferences: + - title: "Output: On Time" + name: configOnTime1 + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 6553 + default: 0 + - title: "Output: Off Wait Time" + name: configOffWaitTime1 + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 6553 + default: 0 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml new file mode 100644 index 0000000000..c9272cc3c2 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml @@ -0,0 +1,107 @@ +name: switch-4inputs-2outputs +components: + - id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: Switch + - id: input1 + label: "Input 1" + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: input2 + label: "Input 2" + capabilities: + - id: switch + version: 1 + - id: input3 + label: "Input 3" + capabilities: + - id: switch + version: 1 + - id: input4 + label: "Input 4" + capabilities: + - id: switch + version: 1 +preferences: + # Input 1 + - title: "Input 1: Reverse Polarity" + name: reversePolarity1 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 1: Control Output 1" + name: controlOutput11 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 1: Control Output 2" + name: controlOutput21 + required: true + preferenceType: boolean + definition: + default: false + # Input 2 + - title: "Input 2: Reverse Polarity" + name: reversePolarity2 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 2: Control Output 1" + name: controlOutput12 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 2: Control Output 2" + name: controlOutput22 + required: true + preferenceType: boolean + definition: + default: false + # Input 3 + - title: "Input 3: Reverse Polarity" + name: reversePolarity3 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 3: Control Output 1" + name: controlOutput13 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 3: Control Output 2" + name: controlOutput23 + required: true + preferenceType: boolean + definition: + default: false + # Input 4 + - title: "Input 4: Reverse Polarity" + name: reversePolarity4 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 4: Control Output 1" + name: controlOutput14 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 4: Control Output 2" + name: controlOutput24 + required: true + preferenceType: boolean + definition: + default: false \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua index 9910dde229..01635ccf69 100644 --- a/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua +++ b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua @@ -7,7 +7,10 @@ local IASZone = clusters.IASZone local ElectricalMeasurement = clusters.ElectricalMeasurement local SimpleMetering = clusters.SimpleMetering local Alarms = clusters.Alarms +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff local constants = require "st.zigbee.constants" +local data_types = require "st.zigbee.data_types" local devices = { IKEA_RGB_BULB = { @@ -110,6 +113,64 @@ local devices = { }, } }, + FRIENT_IO_MODULE = { + FINGERPRINTS = { + { mfr = "frient A/S", model = "IOMZB-110" } + }, + CONFIGURATION = { + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OnOff.base_type, + configurable = true, + monitored = true + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OffWaitTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OffWaitTime.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.PresentValue.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.PresentValue.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.Polarity.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.Polarity.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = 0x8000, -- IASActivation + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = data_types.Uint16, + mfg_code = 0x1015, + configurable = true, + monitored = true + } + } + } } return devices \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua new file mode 100644 index 0000000000..a50ca664fd --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -0,0 +1,589 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local log = require "log" +local utils = require "st.utils" + +-- Zigbee Spec Utils +local constants = require "st.zigbee.constants" +local messages = require "st.zigbee.messages" +local zdo_messages = require "st.zigbee.zdo" +local bind_request = require "st.zigbee.zdo.bind_request" +local unbind_request = require "frient-IO.unbind_request" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local switch_defaults = require "st.zigbee.defaults.switch_defaults" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local clusters = require "st.zigbee.zcl.clusters" +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +local OnOffControl = OnOff.types.OnOffControl +-- Capabilities +local capabilities = require "st.capabilities" +local Switch = capabilities.switch +local CHILD_OUTPUT_PROFILE = "frient-io-output-switch" + +local configurationMap = require "configurations" + +local COMPONENTS = { + INPUT_1 = "input1", + INPUT_2 = "input2", + INPUT_3 = "input3", + INPUT_4 = "input4", + OUTPUT_1 = "output1", + OUTPUT_2 = "output2" +} + +local ZIGBEE_BRIDGE_FINGERPRINTS = { + { manufacturer = "frient A/S", model = "IOMZB-110" } +} + +local ZIGBEE_ENDPOINTS = { + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75 +} + +local OUTPUT_INFO = { + ["1"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1, key = "frient-io-output-1", label_suffix = "Output 1" }, + ["2"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2, key = "frient-io-output-2", label_suffix = "Output 2" } +} + +local OUTPUT_BY_ENDPOINT, OUTPUT_BY_KEY = {}, {} +for suffix, info in pairs(OUTPUT_INFO) do + info.suffix = suffix + OUTPUT_BY_ENDPOINT[info.endpoint] = info + OUTPUT_BY_KEY[info.key] = info +end + +local ZIGBEE_MFG_CODES = { + Develco = 0x1015 +} + +local ZIGBEE_MFG_ATTRIBUTES = { + client = { + OnWithTimeOff_OnTime = { + ID = 0x8000, + data_type = data_types.Uint16 + }, + OnWithTimeOff_OffWaitTime = { + ID = 0x8001, + data_type = data_types.Uint16 + } + }, + server = { IASActivation = { + ID = 0x8000, + data_type = data_types.Uint16 + } } +} + +local function write_client_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, data_type, + payload) + local message = cluster_base.write_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, + data_type, payload) + + message.body.zcl_header.frame_ctrl:set_direction_client() + return message +end + +local function write_basic_input_polarity_attr(device, ep_id, payload) + local value = data_types.validate_or_build_type(payload and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload") + device:send(cluster_base.write_attribute(device, data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + value):to_endpoint(ep_id)) +end + +local function ensure_child_devices(device) + if device.parent_assigned_child_key ~= nil then + return + end + + for _, info in pairs(OUTPUT_INFO) do + local child = device:get_child_by_parent_assigned_key(info.key) + if child == nil then + child = device.driver:try_create_device({ + type = "EDGE_CHILD", + parent_device_id = device.id, + parent_assigned_child_key = info.key, + profile = CHILD_OUTPUT_PROFILE, + label = string.format("%s %s", device.label, info.label_suffix), + vendor_provided_label = info.label_suffix + }) + child = child and device:get_child_by_parent_assigned_key(info.key) + end + if child then + child:set_field("endpoint", info.endpoint, { persist = true }) + end + end +end + +local function to_integer(value) + if value == nil then return nil end + if type(value) == "number" then return math.tointeger(value) end + local num = tonumber(value) + return num and math.tointeger(num) or nil +end + +local function sanitize_timing(value) + local int = to_integer(value) or 0 + if int < 0 then + int = 0 + elseif int > 0xFFFF then + int = 0xFFFF + end + return int +end + +local function get_output_timing(device, suffix) + local info = OUTPUT_INFO[suffix] + if not info then return 0, 0 end + local child = device:get_child_by_parent_assigned_key(info.key) + if child then + local on_time = math.floor((sanitize_timing(child.preferences.configOnTime)) * 10) + local off_wait = math.floor((sanitize_timing(child.preferences.configOffWaitTime)) * 10) + return on_time, off_wait + end + local on_time = math.floor((sanitize_timing(device.preferences["configOnTime" .. suffix]))*10) + local off_wait = math.floor((sanitize_timing(device.preferences["configOffWaitTime" .. suffix]))*10) + return on_time, off_wait +end + +local function handle_output_command(device, suffix, command_name) + local info = OUTPUT_INFO[suffix] + if info == nil then return end + local config_on_time, config_off_wait_time = get_output_timing(device, suffix) + local endpoint = info.endpoint + + if command_name == "on" then + if config_on_time == 0 then + device:send(OnOff.server.commands.On(device):to_endpoint(endpoint)) + else + device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), + data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) + end + else + if config_on_time == 0 then + device:send(OnOff.server.commands.Off(device):to_endpoint(endpoint)) + else + device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), + data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) + end + end +end + +local function emit_switch_event_for_endpoint(device, endpoint, event) + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child then + child:emit_event(event) + return + end + end + device:emit_event_for_endpoint(endpoint, event) +end + +local function on_off_attr_handler(driver, device, value, zb_message) + local endpoint = zb_message.address_header.src_endpoint.value + emit_switch_event_for_endpoint(device, endpoint, value.value and Switch.switch.on() or Switch.switch.off()) +end + +local function build_bind_request(device, src_cluster, src_ep_id, dest_ep_id) + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, bind_request.BindRequest.ID) + + local bind_req = bind_request.BindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + bind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = bind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd +end + +local function build_unbind_request(device, src_cluster, src_ep_id, dest_ep_id) + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, unbind_request.UNBIND_REQUEST_CLUSTER_ID) + + local unbind_req = unbind_request.UnbindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + unbind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = unbind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd +end + +local function component_to_endpoint(device, component_id) + if component_id == COMPONENTS.INPUT_1 then + return ZIGBEE_ENDPOINTS.INPUT_1 + elseif component_id == COMPONENTS.INPUT_2 then + return ZIGBEE_ENDPOINTS.INPUT_2 + elseif component_id == COMPONENTS.INPUT_3 then + return ZIGBEE_ENDPOINTS.INPUT_3 + elseif component_id == COMPONENTS.INPUT_4 then + return ZIGBEE_ENDPOINTS.INPUT_4 + elseif component_id == COMPONENTS.OUTPUT_1 then + return ZIGBEE_ENDPOINTS.OUTPUT_1 + elseif component_id == COMPONENTS.OUTPUT_2 then + return ZIGBEE_ENDPOINTS.OUTPUT_2 + else + return device.fingerprinted_endpoint_id + end +end + +local function endpoint_to_component(device, ep) + local ep_id = type(ep) == "table" and ep.value or ep + if ep_id == ZIGBEE_ENDPOINTS.INPUT_1 then + return COMPONENTS.INPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_2 then + return COMPONENTS.INPUT_2 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_3 then + return COMPONENTS.INPUT_3 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_4 then + return COMPONENTS.INPUT_4 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_1 then + return COMPONENTS.OUTPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_2 then + return COMPONENTS.OUTPUT_2 + else + return "main" + end +end + +local function init_handler(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) + + if device.parent_assigned_child_key ~= nil then + return + end + + ensure_child_devices(device) + + local on1, off1 = get_output_timing(device, "1") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + + local on2, off2 = get_output_timing(device, "2") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + + -- Input 1 + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_1, device.preferences.reversePolarity1) + + device:send(device.preferences.controlOutput11 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1)) + + device:send(device.preferences.controlOutput21 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2)) + + -- Input 2 + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_2, device.preferences.reversePolarity2) + + device:send(device.preferences.controlOutput12 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1)) + + device:send(device.preferences.controlOutput22 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2)) + + -- Input 3 + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_3, device.preferences.reversePolarity3) + + device:send(device.preferences.controlOutput13 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1)) + + device:send(device.preferences.controlOutput23 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2)) + + -- Input 4 + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_4, device.preferences.reversePolarity4) + + device:send(device.preferences.controlOutput14 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1)) + + device:send(device.preferences.controlOutput24 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2)) +end + +local function configure_handler(self, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + if attribute.configurable ~= false then + device:add_configured_attribute(attribute) + end + end + end + device:configure() +end + +local function info_changed_handler(self, device, event, args) + if device.parent_assigned_child_key ~= nil then + -- This is a child device + local parent = device:get_parent_device() + if not parent then return end + + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if not info then return end + + -- Child devices have simple preference names without suffix + local on_time = math.floor(sanitize_timing(device.preferences.configOnTime) * 10) + local off_wait = math.floor(sanitize_timing(device.preferences.configOffWaitTime) * 10) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on_time):to_endpoint(info.endpoint)) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off_wait):to_endpoint(info.endpoint)) + return + else + -- Input 1 + if args.old_st_store.preferences.reversePolarity1 ~= device.preferences.reversePolarity1 then + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_1, device.preferences.reversePolarity1) + end + + if args.old_st_store.preferences.controlOutput11 ~= device.preferences.controlOutput11 then + device:send(device.preferences.controlOutput11 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1)) + end + + if args.old_st_store.preferences.controlOutput21 ~= device.preferences.controlOutput21 then + device:send(device.preferences.controlOutput21 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2)) + end + + -- Input 2 + if args.old_st_store.preferences.reversePolarity2 ~= device.preferences.reversePolarity2 then + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_2, device.preferences.reversePolarity2) + end + + if args.old_st_store.preferences.controlOutput12 ~= device.preferences.controlOutput12 then + device:send(device.preferences.controlOutput12 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1)) + end + + if args.old_st_store.preferences.controlOutput22 ~= device.preferences.controlOutput22 then + device:send(device.preferences.controlOutput22 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2)) + end + + -- Input 3 + if args.old_st_store.preferences.reversePolarity3 ~= device.preferences.reversePolarity3 then + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_3, device.preferences.reversePolarity3) + end + + if args.old_st_store.preferences.controlOutput13 ~= device.preferences.controlOutput13 then + device:send(device.preferences.controlOutput13 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1)) + end + + if args.old_st_store.preferences.controlOutput23 ~= device.preferences.controlOutput23 then + device:send(device.preferences.controlOutput23 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2)) + end + + -- Input 4 + if args.old_st_store.preferences.reversePolarity4 ~= device.preferences.reversePolarity4 then + write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_4, device.preferences.reversePolarity4) + end + + if args.old_st_store.preferences.controlOutput14 ~= device.preferences.controlOutput14 then + device:send(device.preferences.controlOutput14 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1)) + end + + if args.old_st_store.preferences.controlOutput24 ~= device.preferences.controlOutput24 then + device:send(device.preferences.controlOutput24 + and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2) + or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2)) + end + end +end + +local function present_value_attr_handler(driver, device, value, zb_message) + local ep_id = zb_message.address_header.src_endpoint + device:emit_event_for_endpoint(ep_id, value.value and Switch.switch.on() or Switch.switch.off()) +end + +local function on_off_default_response_handler(driver, device, zb_rx) + local status = zb_rx.body.zcl_body.status.value + local endpoint = zb_rx.address_header.src_endpoint.value + + if status == Status.SUCCESS then + local cmd = zb_rx.body.zcl_body.cmd.value + local event = nil + + if cmd == OnOff.server.commands.On.ID then + event = Switch.switch.on() + elseif cmd == OnOff.server.commands.OnWithTimedOff.ID then + device:send(cluster_base.read_attribute(device, data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID)):to_endpoint(endpoint)) + elseif cmd == OnOff.server.commands.Off.ID then + event = Switch.switch.off() + end + + if event ~= nil then + emit_switch_event_for_endpoint(device, endpoint, event) + end + end +end + +local function switch_on_handler(driver, device, command) + local parent = device:get_parent_device() + if parent then + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if info then + handle_output_command(parent, info.suffix, "on") + return + end + end + + local num = command.component and command.component:match("output(%d)") + if num then + handle_output_command(device, num, "on") + return + end + num = command.component:match("input(%d)") + if num then + log.debug("switch_on_handler", utils.stringify_table(command, "command", false)) + local component = device.profile.components[command.component] + local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) + if value == "on" then + device:emit_component_event(component, + Switch.switch.on({ state_change = true, visibility = { displayed = false } })) + elseif value == "off" then + device:emit_component_event(component, + Switch.switch.off({ state_change = true, visibility = { displayed = false } })) + end + end +end + +local function switch_off_handler(driver, device, command) + local parent = device:get_parent_device() + if parent then + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if info then + handle_output_command(parent, info.suffix, "off") + return + end + end + + local num = command.component and command.component:match("output(%d)") + if num then + handle_output_command(device, num, "off") + return + end + num = command.component:match("input(%d)") + if num then + log.debug("switch_on_handler", utils.stringify_table(command, "command", false)) + local component = device.profile.components[command.component] + local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) + if value == "on" then + device:emit_component_event(component, + Switch.switch.on({ state_change = true, visibility = { displayed = false } })) + elseif value == "off" then + device:emit_component_event(component, + Switch.switch.off({ state_change = true, visibility = { displayed = false } })) + end + end +end + +local frient_bridge_handler = { + NAME = "frient bridge handler", + zigbee_handlers = { + global = { + [OnOff.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = on_off_default_response_handler + } + }, + cluster = {}, + attr = { + [BasicInput.ID] = { + [BasicInput.attributes.PresentValue.ID] = present_value_attr_handler + }, + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = on_off_attr_handler + } + }, + zdo = {} + }, + capability_handlers = { + [Switch.ID] = { + [Switch.commands.on.NAME] = switch_on_handler, + [Switch.commands.off.NAME] = switch_off_handler + } + }, + lifecycle_handlers = { + init = init_handler, + doConfigure = configure_handler, + infoChanged = info_changed_handler + }, + can_handle = function(opts, driver, device, ...) + for _, fingerprint in ipairs(ZIGBEE_BRIDGE_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.manufacturer and device:get_model() == fingerprint.model then + local subdriver = require("frient-IO") + return true, subdriver + end + end + end +} + +return frient_bridge_handler diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua new file mode 100644 index 0000000000..cecaf696b2 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua @@ -0,0 +1,95 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +local data_types = require "st.zigbee.data_types" +local utils = require "st.zigbee.utils" + +local unbind_request = {} + +unbind_request.UNBIND_REQUEST_CLUSTER_ID = 0x0022 +unbind_request.ADDRESS_MODE_16_BIT = 0x01 +unbind_request.ADDRESS_MODE_64_BIT = 0x03 + +local UnbindRequest = { + ID = unbind_request.UNBIND_REQUEST_CLUSTER_ID, + NAME = "UnbindRequest", +} +UnbindRequest.__index = UnbindRequest +unbind_request.UnbindRequest = UnbindRequest + +function UnbindRequest.deserialize(buf) + local self = {} + setmetatable(self, UnbindRequest) + + local fields = { + { name = "src_address", type = data_types.IeeeAddress }, + { name = "src_endpoint", type = data_types.Uint8 }, + { name = "cluster_id", type = data_types.ClusterId }, + { name = "dest_addr_mode", type = data_types.Uint8 }, + } + utils.deserialize_field_list(self, fields, buf) + + if self.dest_addr_mode.value == unbind_request.ADDRESS_MODE_16_BIT then + self.dest_address = data_types.Uint16.deserialize(buf) + else + self.dest_address = data_types.IeeeAddress.deserialize(buf) + self.dest_endpoint = data_types.Uint8.deserialize(buf) + end + return self +end + +--- A helper function used by common code to get all the component pieces of this message frame +function UnbindRequest:get_fields() + local out = {} + out[#out + 1] = self.src_address + out[#out + 1] = self.src_endpoint + out[#out + 1] = self.cluster_id + out[#out + 1] = self.dest_addr_mode + out[#out + 1] = self.dest_address + if self.dest_addr_mode.value == unbind_request.ADDRESS_MODE_64_BIT then + out[#out + 1] = self.dest_endpoint + end + return out +end + +UnbindRequest.get_length = utils.length_from_fields +UnbindRequest._serialize = utils.serialize_from_fields +UnbindRequest.pretty_print = utils.print_from_fields +UnbindRequest.__tostring = UnbindRequest.pretty_print +function UnbindRequest.from_values(orig, src_address, src_endpoint, cluster_id, dest_addr_mode, dest_address, + dest_endpoint) + local out = {} + if src_address == nil or src_endpoint == nil or cluster_id == nil or dest_addr_mode == nil or dest_address == nil then + error("Missing necessary values for bind request", 2) + end + + out.src_address = data_types.validate_or_build_type(src_address, data_types.IeeeAddress, "src_address") + out.src_endpoint = data_types.validate_or_build_type(src_endpoint, data_types.Uint8, "src_endpoint") + out.cluster_id = data_types.validate_or_build_type(cluster_id, data_types.ClusterId, "cluster") + out.dest_addr_mode = data_types.validate_or_build_type(dest_addr_mode, data_types.Uint8, "dest_addr_mode") + if (out.dest_addr_mode.value == unbind_request.ADDRESS_MODE_16_BIT) then + out.dest_address = data_types.validate_or_build_type(dest_address, data_types.Uint16, "dest_address") + elseif out.dest_addr_mode.value == unbind_request.ADDRESS_MODE_64_BIT then + out.dest_address = data_types.validate_or_build_type(dest_address, data_types.IeeeAddress, "dest_address") + out.dest_endpoint = data_types.validate_or_build_type(dest_endpoint, data_types.Uint8, "dest_endpoint") + else + error(string.format("Unrecognized destination address mode: %d", out.dest_addr_mode.value), 2) + end + + setmetatable(out, UnbindRequest) + return out +end + +setmetatable(unbind_request.UnbindRequest, { __call = unbind_request.UnbindRequest.from_values }) + +return unbind_request diff --git a/drivers/SmartThings/zigbee-switch/src/init.lua b/drivers/SmartThings/zigbee-switch/src/init.lua index 696ff8ada9..5f12070282 100644 --- a/drivers/SmartThings/zigbee-switch/src/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/init.lua @@ -96,7 +96,8 @@ local zigbee_switch_driver_template = { lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN lazy_load_if_possible("laisiao"), lazy_load_if_possible("tuya-multi"), - lazy_load_if_possible("frient") + lazy_load_if_possible("frient"), + lazy_load_if_possible("frient-IO") }, zigbee_handlers = { global = { diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua new file mode 100644 index 0000000000..1d9ff237af --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -0,0 +1,509 @@ +-- Copyright 2025 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local messages = require "st.zigbee.messages" +local constants = require "st.zigbee.constants" +local zdo_messages = require "st.zigbee.zdo" +local bind_request = require "st.zigbee.zdo.bind_request" +local unbind_request = require "frient-IO.unbind_request" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local zcl_messages = require "st.zigbee.zcl" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +local Switch = capabilities.switch + +local ZIGBEE_ENDPOINTS = { + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75, +} + +local INPUT_ENDPOINTS = { + ZIGBEE_ENDPOINTS.INPUT_1, + ZIGBEE_ENDPOINTS.INPUT_2, + ZIGBEE_ENDPOINTS.INPUT_3, + ZIGBEE_ENDPOINTS.INPUT_4, +} +local OUTPUT_ENDPOINTS = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, +} + +local DEVELCO_MFG_CODE = 0x1015 +local ON_TIME_ATTR = 0x8000 +local OFF_WAIT_ATTR = 0x8001 + +local function sanitize_timing(value) + local v = tonumber(value) or 0 + if v < 0 then + v = 0 + elseif v > 0xFFFF then + v = 0xFFFF + end + return math.tointeger(v) or 0 +end + +local function to_deciseconds(value) + return math.floor(sanitize_timing(value) * 10) +end + +local function build_client_mfg_write(device, endpoint, attr_id, value) + local msg = cluster_base.write_manufacturer_specific_attribute( + device, + BasicInput.ID, + attr_id, + DEVELCO_MFG_CODE, + data_types.Uint16, + value + ) + msg.body.zcl_header.frame_ctrl:set_direction_client() + return msg:to_endpoint(endpoint) +end + +local function build_basic_input_polarity_write(device, endpoint, enabled) + local polarity_value = data_types.validate_or_build_type( + enabled and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload" + ) + return cluster_base.write_attribute( + device, + data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + polarity_value + ):to_endpoint(endpoint) +end + +local function build_bind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + bind_request.BindRequest.ID + ) + local bind_body = bind_request.BindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + bind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = bind_body }) + return messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) +end + +local function build_unbind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + unbind_request.UNBIND_REQUEST_CLUSTER_ID + ) + local unbind_body = unbind_request.UnbindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + unbind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = unbind_body }) + return messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) +end + +local function build_default_response_msg(device, endpoint, command_id) + local addr_header = messages.AddressHeader( + device:get_short_address(), + endpoint, + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + constants.HA_PROFILE_ID, + OnOff.ID + ) + local response_body = default_response.DefaultResponse(command_id, Status.SUCCESS) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = response_body + }) + return messages.ZigbeeMessageRx({ address_header = addr_header, body = message_body }) +end + +local function build_output_timing(device, child, suffix) + local on_pref + local off_pref + if child.preferences.configOnTime ~= nil or child.preferences.configOffWaitTime ~= nil then + on_pref = child.preferences.configOnTime or 0 + off_pref = child.preferences.configOffWaitTime or 0 + else + on_pref = device.preferences["configOnTime" .. suffix] or 0 + off_pref = device.preferences["configOffWaitTime" .. suffix] or 0 + end + return to_deciseconds(on_pref), to_deciseconds(off_pref) +end + +local mock_parent_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("switch-4inputs-2outputs.yml"), + fingerprinted_endpoint_id = ZIGBEE_ENDPOINTS.INPUT_1, + label = "frient IO Module", + zigbee_endpoints = { + [ZIGBEE_ENDPOINTS.INPUT_1] = { + id = ZIGBEE_ENDPOINTS.INPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_2] = { + id = ZIGBEE_ENDPOINTS.INPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_3] = { + id = ZIGBEE_ENDPOINTS.INPUT_3, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_4] = { + id = ZIGBEE_ENDPOINTS.INPUT_4, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_1] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_2] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + }, +}) + +local mock_output_child_1 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-1", + label = "frient IO Module Output 1", + vendor_provided_label = "Output 1", +}) + +local mock_output_child_2 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-2", + label = "frient IO Module Output 2", + vendor_provided_label = "Output 2", +}) + +local function reset_preferences() + mock_parent_device.preferences.reversePolarity1 = false + mock_parent_device.preferences.reversePolarity2 = false + mock_parent_device.preferences.reversePolarity3 = false + mock_parent_device.preferences.reversePolarity4 = false + + mock_parent_device.preferences.controlOutput11 = false + mock_parent_device.preferences.controlOutput21 = false + mock_parent_device.preferences.controlOutput12 = false + mock_parent_device.preferences.controlOutput22 = false + mock_parent_device.preferences.controlOutput13 = false + mock_parent_device.preferences.controlOutput23 = false + mock_parent_device.preferences.controlOutput14 = false + mock_parent_device.preferences.controlOutput24 = false + + mock_parent_device.preferences.configOnTime1 = 3 + mock_parent_device.preferences.configOffWaitTime1 = 4 + mock_parent_device.preferences.configOnTime2 = 7 + mock_parent_device.preferences.configOffWaitTime2 = 8 + + mock_output_child_1.preferences.configOnTime = 5 + mock_output_child_1.preferences.configOffWaitTime = 6 + mock_output_child_2.preferences.configOnTime = 0 + mock_output_child_2.preferences.configOffWaitTime = 0 +end + +local function register_initial_config_expectations() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.devices:__set_channel_ordering("relaxed") + + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local on2, off2 = build_output_timing(mock_parent_device, mock_output_child_2, "2") + + local function enqueue_output_timing_writes() + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, on1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, off1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, ON_TIME_ATTR, on2) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, OFF_WAIT_ATTR, off2) }) + end + + -- Device init issues two identical writes per output (once during child discovery and once post child sync) + enqueue_output_timing_writes() + enqueue_output_timing_writes() + + for _, endpoint in ipairs(INPUT_ENDPOINTS) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, endpoint, false) }) + for _, output_ep in ipairs(OUTPUT_ENDPOINTS) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, endpoint, output_ep) }) + end + end +end + +local function expect_init_sequence() + register_initial_config_expectations() + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "init" }) +end + +local function expect_switch_registration(device) + test.socket.devices:__expect_send({ + "register_native_capability_attr_handler", + { device_uuid = device.id, capability_id = "switch", capability_attr_id = "switch" }, + }) +end + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + reset_preferences() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_output_child_1) + test.mock_device.add_test_device(mock_output_child_2) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Init configures outputs and routes attribute reports", + function() + expect_init_sequence() + test.wait_for_events() + + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1), + }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + expect_switch_registration(mock_output_child_1) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, false):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2), + }) + test.socket.capability:__expect_send(mock_output_child_2:generate_test_message("main", Switch.switch.off())) + expect_switch_registration(mock_output_child_2) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + BasicInput.attributes.PresentValue:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.INPUT_3), + }) + test.socket.capability:__expect_send(mock_parent_device:generate_test_message("input3", Switch.switch.on())) + expect_switch_registration(mock_parent_device) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Default responses update state and trigger reads", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + + local on_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.On.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, on_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + expect_switch_registration(mock_output_child_1) + + local timed_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.OnWithTimedOff.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, timed_response }) + local read_msg = cluster_base.read_attribute( + mock_parent_device, + data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID) + ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, read_msg }) + + local off_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.Off.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, off_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.off())) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Switch commands drive the correct Zigbee commands", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local timed_on = OnOff.server.commands.OnWithTimedOff( + mock_parent_device, + data_types.Uint8(0), + data_types.Uint16(on1), + data_types.Uint16(off1) + ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local timed_off = OnOff.server.commands.OnWithTimedOff( + mock_parent_device, + data_types.Uint8(0), + data_types.Uint16(on1), + data_types.Uint16(off1) + ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_off }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local direct_on = OnOff.server.commands.On(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local direct_off = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output1", command = "on", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output2", command = "off", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Child preference changes send manufacturer writes", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + mock_output_child_1.preferences.configOnTime = 12 + mock_output_child_1.preferences.configOffWaitTime = 13 + test.socket.device_lifecycle:__queue_receive( + mock_output_child_1:generate_info_changed({ preferences = { configOnTime = 12, configOffWaitTime = 13 } }) + ) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, to_deciseconds(12)), + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, to_deciseconds(13)), + }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Parent preference changes manage polarity and binds", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + mock_parent_device.preferences.reversePolarity1 = true + mock_parent_device.preferences.controlOutput11 = true + mock_parent_device.preferences.controlOutput21 = true + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ + preferences = { + reversePolarity1 = true, + controlOutput11 = true, + controlOutput21 = true, + }, + }) + ) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + + mock_parent_device.preferences.controlOutput11 = false + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ preferences = { controlOutput11 = false } }) + ) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + + mock_parent_device.preferences.reversePolarity3 = true + mock_parent_device.preferences.controlOutput23 = true + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ + preferences = { + reversePolarity3 = true, + controlOutput23 = true, + }, + }) + ) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + + mock_parent_device.preferences.controlOutput23 = false + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ preferences = { controlOutput23 = false } }) + ) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + + test.wait_for_events() + end +) + +test.run_registered_tests() From f680790500cea241307a8127bbf4fe515e32dbc2 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 25 Nov 2025 11:25:33 +0100 Subject: [PATCH 02/14] test changes --- .../src/test/test_frient_IO_module.lua | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua index 1d9ff237af..f36875eb45 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -70,6 +70,7 @@ local function build_client_mfg_write(device, endpoint, attr_id, value) value ) msg.body.zcl_header.frame_ctrl:set_direction_client() + msg.tx_options = data_types.Uint16(0) return msg:to_endpoint(endpoint) end @@ -79,12 +80,14 @@ local function build_basic_input_polarity_write(device, endpoint, enabled) BasicInput.attributes.Polarity.base_type, "payload" ) - return cluster_base.write_attribute( + local msg = cluster_base.write_attribute( device, data_types.ClusterId(BasicInput.ID), data_types.AttributeId(BasicInput.attributes.Polarity.ID), polarity_value - ):to_endpoint(endpoint) + ) + msg.tx_options = data_types.Uint16(0) + return msg:to_endpoint(endpoint) end local function build_bind(device, src_ep, dest_ep) @@ -105,7 +108,9 @@ local function build_bind(device, src_ep, dest_ep) dest_ep ) local message_body = zdo_messages.ZdoMessageBody({ zdo_body = bind_body }) - return messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg end local function build_unbind(device, src_ep, dest_ep) @@ -126,7 +131,9 @@ local function build_unbind(device, src_ep, dest_ep) dest_ep ) local message_body = zdo_messages.ZdoMessageBody({ zdo_body = unbind_body }) - return messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg end local function build_default_response_msg(device, endpoint, command_id) @@ -249,8 +256,12 @@ local function reset_preferences() end local function register_initial_config_expectations() - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.devices:__set_channel_ordering("relaxed") + if test.socket.zigbee and test.socket.zigbee.__set_channel_ordering then + test.socket.zigbee:__set_channel_ordering("relaxed") + end + if test.socket.devices and test.socket.devices.__set_channel_ordering then + test.socket.devices:__set_channel_ordering("relaxed") + end local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") local on2, off2 = build_output_timing(mock_parent_device, mock_output_child_2, "2") @@ -277,6 +288,8 @@ end local function expect_init_sequence() register_initial_config_expectations() test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_output_child_1.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_output_child_2.id, "init" }) end local function expect_switch_registration(device) @@ -294,6 +307,7 @@ local function test_init() test.mock_device.add_test_device(mock_output_child_1) test.mock_device.add_test_device(mock_output_child_2) zigbee_test_utils.init_noop_health_check_timer() + register_initial_config_expectations() end test.set_test_init_function(test_init) @@ -350,7 +364,9 @@ test.register_coroutine_test( mock_parent_device, data_types.ClusterId(OnOff.ID), data_types.AttributeId(OnOff.attributes.OnOff.ID) - ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + ) + read_msg.tx_options = data_types.Uint16(0) + read_msg = read_msg:to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) test.socket.zigbee:__expect_send({ mock_parent_device.id, read_msg }) local off_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.Off.ID) From 65a317aa84170bf305cbd3c843e59b3864435df9 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 25 Nov 2025 13:45:13 +0100 Subject: [PATCH 03/14] add register_native_switch_handler --- .../zigbee-switch/src/frient-IO/init.lua | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua index a50ca664fd..d3e743ba4a 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -111,7 +111,7 @@ local function write_basic_input_polarity_attr(device, ep_id, payload) value):to_endpoint(ep_id)) end -local function ensure_child_devices(device) +local function ensure_child_devices(driver, device) if device.parent_assigned_child_key ~= nil then return end @@ -119,7 +119,7 @@ local function ensure_child_devices(device) for _, info in pairs(OUTPUT_INFO) do local child = device:get_child_by_parent_assigned_key(info.key) if child == nil then - child = device.driver:try_create_device({ + driver:try_create_device({ type = "EDGE_CHILD", parent_device_id = device.id, parent_assigned_child_key = info.key, @@ -127,7 +127,7 @@ local function ensure_child_devices(device) label = string.format("%s %s", device.label, info.label_suffix), vendor_provided_label = info.label_suffix }) - child = child and device:get_child_by_parent_assigned_key(info.key) + child = device:get_child_by_parent_assigned_key(info.key) end if child then child:set_field("endpoint", info.endpoint, { persist = true }) @@ -201,8 +201,27 @@ local function emit_switch_event_for_endpoint(device, endpoint, event) device:emit_event_for_endpoint(endpoint, event) end +local function register_native_switch_handler(device, endpoint) + local field_key = string.format("frient_io_native_%02X", endpoint) + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child and not child:get_field(field_key) then + child:register_native_capability_attr_handler("switch", "switch") + child:set_field(field_key, true) + end + return + end + + if not device:get_field(field_key) then + device:register_native_capability_attr_handler("switch", "switch") + device:set_field(field_key, true) + end +end + local function on_off_attr_handler(driver, device, value, zb_message) local endpoint = zb_message.address_header.src_endpoint.value + register_native_switch_handler(device, endpoint) emit_switch_event_for_endpoint(device, endpoint, value.value and Switch.switch.on() or Switch.switch.off()) end @@ -285,7 +304,7 @@ local function init_handler(self, device) return end - ensure_child_devices(device) + ensure_child_devices(self, device) local on1, off1 = get_output_timing(device, "1") device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, @@ -352,6 +371,10 @@ local function init_handler(self, device) or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2)) end +local function added_handler(self, device) + ensure_child_devices(self, device) +end + local function configure_handler(self, device) local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then @@ -460,6 +483,7 @@ end local function present_value_attr_handler(driver, device, value, zb_message) local ep_id = zb_message.address_header.src_endpoint + register_native_switch_handler(device, ep_id.value) device:emit_event_for_endpoint(ep_id, value.value and Switch.switch.on() or Switch.switch.off()) end @@ -572,6 +596,7 @@ local frient_bridge_handler = { } }, lifecycle_handlers = { + added = added_handler, init = init_handler, doConfigure = configure_handler, infoChanged = info_changed_handler From f295dd1b1f253b45ee8d38475e280216d79a0f8c Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 1 Dec 2025 07:47:55 +0100 Subject: [PATCH 04/14] tests WIP --- .../zigbee-switch/profiles/frient-io-output-switch.yml | 4 ++-- drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua | 2 ++ .../zigbee-switch/src/test/test_frient_IO_module.lua | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml index 88f03aea0f..f3074fa581 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml @@ -6,7 +6,7 @@ components: version: 1 preferences: - title: "Output: On Time" - name: configOnTime1 + name: configOnTime required: true preferenceType: integer definition: @@ -14,7 +14,7 @@ preferences: maximum: 6553 default: 0 - title: "Output: Off Wait Time" - name: configOffWaitTime1 + name: configOffWaitTime required: true preferenceType: integer definition: diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua index d3e743ba4a..a563bfb1d8 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -207,6 +207,7 @@ local function register_native_switch_handler(device, endpoint) if info ~= nil then local child = device:get_child_by_parent_assigned_key(info.key) if child and not child:get_field(field_key) then + log.debug(string.format("register_native_switch_handler: registering native attr handler for child %s on endpoint 0x%02X", child.id, endpoint)) child:register_native_capability_attr_handler("switch", "switch") child:set_field(field_key, true) end @@ -214,6 +215,7 @@ local function register_native_switch_handler(device, endpoint) end if not device:get_field(field_key) then + log.debug(string.format("register_native_switch_handler: registering native attr handler for parent %s on endpoint 0x%02X", device.id, endpoint)) device:register_native_capability_attr_handler("switch", "switch") device:set_field(field_key, true) end diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua index f36875eb45..7523f36c02 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -307,7 +307,7 @@ local function test_init() test.mock_device.add_test_device(mock_output_child_1) test.mock_device.add_test_device(mock_output_child_2) zigbee_test_utils.init_noop_health_check_timer() - register_initial_config_expectations() + --register_initial_config_expectations() end test.set_test_init_function(test_init) From 354fd6ab26a25c6c9f0769798b3a50b385fb112d Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 1 Dec 2025 09:30:51 +0100 Subject: [PATCH 05/14] working tests --- .../src/test/test_frient_IO_module.lua | 129 ++++++++++++------ 1 file changed, 86 insertions(+), 43 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua index 7523f36c02..baeb38a20a 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -169,6 +169,16 @@ local function build_output_timing(device, child, suffix) return to_deciseconds(on_pref), to_deciseconds(off_pref) end +local function copy_table(source) + local result = {} + for key, value in pairs(source) do + result[key] = value + end + return result +end + +local parent_preference_state = {} + local mock_parent_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("switch-4inputs-2outputs.yml"), fingerprinted_endpoint_id = ZIGBEE_ENDPOINTS.INPUT_1, @@ -253,6 +263,53 @@ local function reset_preferences() mock_output_child_1.preferences.configOffWaitTime = 6 mock_output_child_2.preferences.configOnTime = 0 mock_output_child_2.preferences.configOffWaitTime = 0 + + parent_preference_state = copy_table(mock_parent_device.preferences) + + local field_keys = { + "frient_io_native_70", + "frient_io_native_71", + "frient_io_native_72", + "frient_io_native_73", + "frient_io_native_74", + "frient_io_native_75", + } + + for _, key in ipairs(field_keys) do + mock_parent_device:set_field(key, nil, { persist = true }) + end + + mock_output_child_1:set_field("frient_io_native_74", nil, { persist = true }) + mock_output_child_2:set_field("frient_io_native_75", nil, { persist = true }) +end + +local function queue_child_info_changed(child, preferences) + local raw = rawget(child, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(preferences) do + raw.preferences[key] = value + end + end + test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ preferences = preferences })) +end + +local function queue_parent_info_changed(preferences) + local full_preferences = copy_table(parent_preference_state) + for key, value in pairs(preferences) do + full_preferences[key] = value + end + parent_preference_state = copy_table(full_preferences) + + local raw = rawget(mock_parent_device, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(full_preferences) do + raw.preferences[key] = value + end + end + + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ preferences = full_preferences }) + ) end local function register_initial_config_expectations() @@ -273,8 +330,7 @@ local function register_initial_config_expectations() test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, OFF_WAIT_ATTR, off2) }) end - -- Device init issues two identical writes per output (once during child discovery and once post child sync) - enqueue_output_timing_writes() + -- Device init issues one set of manufacturer-specific writes per output during startup enqueue_output_timing_writes() for _, endpoint in ipairs(INPUT_ENDPOINTS) do @@ -286,10 +342,7 @@ local function register_initial_config_expectations() end local function expect_init_sequence() - register_initial_config_expectations() - test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_output_child_1.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_output_child_2.id, "init" }) + -- Initialization expectations are registered during test setup; lifecycle events fire as part of driver startup. end local function expect_switch_registration(device) @@ -303,6 +356,7 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() reset_preferences() + register_initial_config_expectations() test.mock_device.add_test_device(mock_parent_device) test.mock_device.add_test_device(mock_output_child_1) test.mock_device.add_test_device(mock_output_child_2) @@ -339,9 +393,15 @@ test.register_coroutine_test( BasicInput.attributes.PresentValue:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.INPUT_3), }) test.socket.capability:__expect_send(mock_parent_device:generate_test_message("input3", Switch.switch.on())) - expect_switch_registration(mock_parent_device) test.wait_for_events() + + local child1_native = mock_output_child_1:get_field("frient_io_native_74") + assert(child1_native, "expected Output 1 child to register native switch handler") + local child2_native = mock_output_child_2:get_field("frient_io_native_75") + assert(child2_native, "expected Output 2 child to register native switch handler") + local parent_native = mock_parent_device:get_field("frient_io_native_72") + assert(parent_native, "expected parent device to register native switch handler for input 3") end ) @@ -356,7 +416,6 @@ test.register_coroutine_test( local on_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.On.ID) test.socket.zigbee:__queue_receive({ mock_parent_device.id, on_response }) test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) - expect_switch_registration(mock_output_child_1) local timed_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.OnWithTimedOff.ID) test.socket.zigbee:__queue_receive({ mock_parent_device.id, timed_response }) @@ -446,11 +505,7 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") - mock_output_child_1.preferences.configOnTime = 12 - mock_output_child_1.preferences.configOffWaitTime = 13 - test.socket.device_lifecycle:__queue_receive( - mock_output_child_1:generate_info_changed({ preferences = { configOnTime = 12, configOffWaitTime = 13 } }) - ) + queue_child_info_changed(mock_output_child_1, { configOnTime = 12, configOffWaitTime = 13 }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, to_deciseconds(12)), @@ -471,18 +526,11 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") - mock_parent_device.preferences.reversePolarity1 = true - mock_parent_device.preferences.controlOutput11 = true - mock_parent_device.preferences.controlOutput21 = true - test.socket.device_lifecycle:__queue_receive( - mock_parent_device:generate_info_changed({ - preferences = { - reversePolarity1 = true, - controlOutput11 = true, - controlOutput21 = true, - }, - }) - ) + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = true, + controlOutput21 = true, + }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, true), @@ -490,32 +538,27 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) }) - mock_parent_device.preferences.controlOutput11 = false - test.socket.device_lifecycle:__queue_receive( - mock_parent_device:generate_info_changed({ preferences = { controlOutput11 = false } }) - ) + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = false, + controlOutput21 = true, + }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) - mock_parent_device.preferences.reversePolarity3 = true - mock_parent_device.preferences.controlOutput23 = true - test.socket.device_lifecycle:__queue_receive( - mock_parent_device:generate_info_changed({ - preferences = { - reversePolarity3 = true, - controlOutput23 = true, - }, - }) - ) + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = true, + }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, true), }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) - mock_parent_device.preferences.controlOutput23 = false - test.socket.device_lifecycle:__queue_receive( - mock_parent_device:generate_info_changed({ preferences = { controlOutput23 = false } }) - ) + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = false, + }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) test.wait_for_events() From 00c8588c3de94cb290351a205f87527ccbe73d05 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 2 Dec 2025 13:37:13 +0100 Subject: [PATCH 06/14] get rid of unused variables --- drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua | 9 --------- 1 file changed, 9 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua index a563bfb1d8..30c0a38902 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -12,9 +12,6 @@ -- See the License for the specific language governing permissions and -- limitations under the License. -local log = require "log" -local utils = require "st.utils" - -- Zigbee Spec Utils local constants = require "st.zigbee.constants" local messages = require "st.zigbee.messages" @@ -24,13 +21,11 @@ local unbind_request = require "frient-IO.unbind_request" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local zcl_global_commands = require "st.zigbee.zcl.global_commands" -local switch_defaults = require "st.zigbee.defaults.switch_defaults" local Status = require "st.zigbee.generated.types.ZclStatus" local clusters = require "st.zigbee.zcl.clusters" local BasicInput = clusters.BasicInput local OnOff = clusters.OnOff -local OnOffControl = OnOff.types.OnOffControl -- Capabilities local capabilities = require "st.capabilities" local Switch = capabilities.switch @@ -207,7 +202,6 @@ local function register_native_switch_handler(device, endpoint) if info ~= nil then local child = device:get_child_by_parent_assigned_key(info.key) if child and not child:get_field(field_key) then - log.debug(string.format("register_native_switch_handler: registering native attr handler for child %s on endpoint 0x%02X", child.id, endpoint)) child:register_native_capability_attr_handler("switch", "switch") child:set_field(field_key, true) end @@ -215,7 +209,6 @@ local function register_native_switch_handler(device, endpoint) end if not device:get_field(field_key) then - log.debug(string.format("register_native_switch_handler: registering native attr handler for parent %s on endpoint 0x%02X", device.id, endpoint)) device:register_native_capability_attr_handler("switch", "switch") device:set_field(field_key, true) end @@ -529,7 +522,6 @@ local function switch_on_handler(driver, device, command) end num = command.component:match("input(%d)") if num then - log.debug("switch_on_handler", utils.stringify_table(command, "command", false)) local component = device.profile.components[command.component] local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) if value == "on" then @@ -559,7 +551,6 @@ local function switch_off_handler(driver, device, command) end num = command.component:match("input(%d)") if num then - log.debug("switch_on_handler", utils.stringify_table(command, "command", false)) local component = device.profile.components[command.component] local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) if value == "on" then From 54dfe6e39d2bdd3489f742dd030880180ad26d6e Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 2 Dec 2025 14:00:35 +0100 Subject: [PATCH 07/14] test --- .../src/test/test_frient_IO_module.lua | 568 ------------------ 1 file changed, 568 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua deleted file mode 100644 index baeb38a20a..0000000000 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua +++ /dev/null @@ -1,568 +0,0 @@ --- Copyright 2025 SmartThings --- Licensed under the Apache License, Version 2.0 - -local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local t_utils = require "integration_test.utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local data_types = require "st.zigbee.data_types" -local cluster_base = require "st.zigbee.cluster_base" -local messages = require "st.zigbee.messages" -local constants = require "st.zigbee.constants" -local zdo_messages = require "st.zigbee.zdo" -local bind_request = require "st.zigbee.zdo.bind_request" -local unbind_request = require "frient-IO.unbind_request" -local default_response = require "st.zigbee.zcl.global_commands.default_response" -local zcl_messages = require "st.zigbee.zcl" -local Status = require "st.zigbee.generated.types.ZclStatus" - -local BasicInput = clusters.BasicInput -local OnOff = clusters.OnOff -local Switch = capabilities.switch - -local ZIGBEE_ENDPOINTS = { - INPUT_1 = 0x70, - INPUT_2 = 0x71, - INPUT_3 = 0x72, - INPUT_4 = 0x73, - OUTPUT_1 = 0x74, - OUTPUT_2 = 0x75, -} - -local INPUT_ENDPOINTS = { - ZIGBEE_ENDPOINTS.INPUT_1, - ZIGBEE_ENDPOINTS.INPUT_2, - ZIGBEE_ENDPOINTS.INPUT_3, - ZIGBEE_ENDPOINTS.INPUT_4, -} -local OUTPUT_ENDPOINTS = { - ZIGBEE_ENDPOINTS.OUTPUT_1, - ZIGBEE_ENDPOINTS.OUTPUT_2, -} - -local DEVELCO_MFG_CODE = 0x1015 -local ON_TIME_ATTR = 0x8000 -local OFF_WAIT_ATTR = 0x8001 - -local function sanitize_timing(value) - local v = tonumber(value) or 0 - if v < 0 then - v = 0 - elseif v > 0xFFFF then - v = 0xFFFF - end - return math.tointeger(v) or 0 -end - -local function to_deciseconds(value) - return math.floor(sanitize_timing(value) * 10) -end - -local function build_client_mfg_write(device, endpoint, attr_id, value) - local msg = cluster_base.write_manufacturer_specific_attribute( - device, - BasicInput.ID, - attr_id, - DEVELCO_MFG_CODE, - data_types.Uint16, - value - ) - msg.body.zcl_header.frame_ctrl:set_direction_client() - msg.tx_options = data_types.Uint16(0) - return msg:to_endpoint(endpoint) -end - -local function build_basic_input_polarity_write(device, endpoint, enabled) - local polarity_value = data_types.validate_or_build_type( - enabled and 1 or 0, - BasicInput.attributes.Polarity.base_type, - "payload" - ) - local msg = cluster_base.write_attribute( - device, - data_types.ClusterId(BasicInput.ID), - data_types.AttributeId(BasicInput.attributes.Polarity.ID), - polarity_value - ) - msg.tx_options = data_types.Uint16(0) - return msg:to_endpoint(endpoint) -end - -local function build_bind(device, src_ep, dest_ep) - local addr_header = messages.AddressHeader( - constants.HUB.ADDR, - constants.HUB.ENDPOINT, - device:get_short_address(), - device.fingerprinted_endpoint_id, - constants.ZDO_PROFILE_ID, - bind_request.BindRequest.ID - ) - local bind_body = bind_request.BindRequest( - device.zigbee_eui, - src_ep, - BasicInput.ID, - bind_request.ADDRESS_MODE_64_BIT, - device.zigbee_eui, - dest_ep - ) - local message_body = zdo_messages.ZdoMessageBody({ zdo_body = bind_body }) - local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) - msg.tx_options = data_types.Uint16(0) - return msg -end - -local function build_unbind(device, src_ep, dest_ep) - local addr_header = messages.AddressHeader( - constants.HUB.ADDR, - constants.HUB.ENDPOINT, - device:get_short_address(), - device.fingerprinted_endpoint_id, - constants.ZDO_PROFILE_ID, - unbind_request.UNBIND_REQUEST_CLUSTER_ID - ) - local unbind_body = unbind_request.UnbindRequest( - device.zigbee_eui, - src_ep, - BasicInput.ID, - unbind_request.ADDRESS_MODE_64_BIT, - device.zigbee_eui, - dest_ep - ) - local message_body = zdo_messages.ZdoMessageBody({ zdo_body = unbind_body }) - local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) - msg.tx_options = data_types.Uint16(0) - return msg -end - -local function build_default_response_msg(device, endpoint, command_id) - local addr_header = messages.AddressHeader( - device:get_short_address(), - endpoint, - constants.HUB.ADDR, - constants.HUB.ENDPOINT, - constants.HA_PROFILE_ID, - OnOff.ID - ) - local response_body = default_response.DefaultResponse(command_id, Status.SUCCESS) - local zcl_header = zcl_messages.ZclHeader({ - cmd = data_types.ZCLCommandId(response_body.ID) - }) - local message_body = zcl_messages.ZclMessageBody({ - zcl_header = zcl_header, - zcl_body = response_body - }) - return messages.ZigbeeMessageRx({ address_header = addr_header, body = message_body }) -end - -local function build_output_timing(device, child, suffix) - local on_pref - local off_pref - if child.preferences.configOnTime ~= nil or child.preferences.configOffWaitTime ~= nil then - on_pref = child.preferences.configOnTime or 0 - off_pref = child.preferences.configOffWaitTime or 0 - else - on_pref = device.preferences["configOnTime" .. suffix] or 0 - off_pref = device.preferences["configOffWaitTime" .. suffix] or 0 - end - return to_deciseconds(on_pref), to_deciseconds(off_pref) -end - -local function copy_table(source) - local result = {} - for key, value in pairs(source) do - result[key] = value - end - return result -end - -local parent_preference_state = {} - -local mock_parent_device = test.mock_device.build_test_zigbee_device({ - profile = t_utils.get_profile_definition("switch-4inputs-2outputs.yml"), - fingerprinted_endpoint_id = ZIGBEE_ENDPOINTS.INPUT_1, - label = "frient IO Module", - zigbee_endpoints = { - [ZIGBEE_ENDPOINTS.INPUT_1] = { - id = ZIGBEE_ENDPOINTS.INPUT_1, - manufacturer = "frient A/S", - model = "IOMZB-110", - server_clusters = { BasicInput.ID }, - }, - [ZIGBEE_ENDPOINTS.INPUT_2] = { - id = ZIGBEE_ENDPOINTS.INPUT_2, - manufacturer = "frient A/S", - model = "IOMZB-110", - server_clusters = { BasicInput.ID }, - }, - [ZIGBEE_ENDPOINTS.INPUT_3] = { - id = ZIGBEE_ENDPOINTS.INPUT_3, - manufacturer = "frient A/S", - model = "IOMZB-110", - server_clusters = { BasicInput.ID }, - }, - [ZIGBEE_ENDPOINTS.INPUT_4] = { - id = ZIGBEE_ENDPOINTS.INPUT_4, - manufacturer = "frient A/S", - model = "IOMZB-110", - server_clusters = { BasicInput.ID }, - }, - [ZIGBEE_ENDPOINTS.OUTPUT_1] = { - id = ZIGBEE_ENDPOINTS.OUTPUT_1, - manufacturer = "frient A/S", - model = "IOMZB-110", - server_clusters = { OnOff.ID, BasicInput.ID }, - }, - [ZIGBEE_ENDPOINTS.OUTPUT_2] = { - id = ZIGBEE_ENDPOINTS.OUTPUT_2, - manufacturer = "frient A/S", - model = "IOMZB-110", - server_clusters = { OnOff.ID, BasicInput.ID }, - }, - }, -}) - -local mock_output_child_1 = test.mock_device.build_test_child_device({ - profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), - parent_device_id = mock_parent_device.id, - parent_assigned_child_key = "frient-io-output-1", - label = "frient IO Module Output 1", - vendor_provided_label = "Output 1", -}) - -local mock_output_child_2 = test.mock_device.build_test_child_device({ - profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), - parent_device_id = mock_parent_device.id, - parent_assigned_child_key = "frient-io-output-2", - label = "frient IO Module Output 2", - vendor_provided_label = "Output 2", -}) - -local function reset_preferences() - mock_parent_device.preferences.reversePolarity1 = false - mock_parent_device.preferences.reversePolarity2 = false - mock_parent_device.preferences.reversePolarity3 = false - mock_parent_device.preferences.reversePolarity4 = false - - mock_parent_device.preferences.controlOutput11 = false - mock_parent_device.preferences.controlOutput21 = false - mock_parent_device.preferences.controlOutput12 = false - mock_parent_device.preferences.controlOutput22 = false - mock_parent_device.preferences.controlOutput13 = false - mock_parent_device.preferences.controlOutput23 = false - mock_parent_device.preferences.controlOutput14 = false - mock_parent_device.preferences.controlOutput24 = false - - mock_parent_device.preferences.configOnTime1 = 3 - mock_parent_device.preferences.configOffWaitTime1 = 4 - mock_parent_device.preferences.configOnTime2 = 7 - mock_parent_device.preferences.configOffWaitTime2 = 8 - - mock_output_child_1.preferences.configOnTime = 5 - mock_output_child_1.preferences.configOffWaitTime = 6 - mock_output_child_2.preferences.configOnTime = 0 - mock_output_child_2.preferences.configOffWaitTime = 0 - - parent_preference_state = copy_table(mock_parent_device.preferences) - - local field_keys = { - "frient_io_native_70", - "frient_io_native_71", - "frient_io_native_72", - "frient_io_native_73", - "frient_io_native_74", - "frient_io_native_75", - } - - for _, key in ipairs(field_keys) do - mock_parent_device:set_field(key, nil, { persist = true }) - end - - mock_output_child_1:set_field("frient_io_native_74", nil, { persist = true }) - mock_output_child_2:set_field("frient_io_native_75", nil, { persist = true }) -end - -local function queue_child_info_changed(child, preferences) - local raw = rawget(child, "raw_st_data") - if raw and raw.preferences then - for key, value in pairs(preferences) do - raw.preferences[key] = value - end - end - test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ preferences = preferences })) -end - -local function queue_parent_info_changed(preferences) - local full_preferences = copy_table(parent_preference_state) - for key, value in pairs(preferences) do - full_preferences[key] = value - end - parent_preference_state = copy_table(full_preferences) - - local raw = rawget(mock_parent_device, "raw_st_data") - if raw and raw.preferences then - for key, value in pairs(full_preferences) do - raw.preferences[key] = value - end - end - - test.socket.device_lifecycle:__queue_receive( - mock_parent_device:generate_info_changed({ preferences = full_preferences }) - ) -end - -local function register_initial_config_expectations() - if test.socket.zigbee and test.socket.zigbee.__set_channel_ordering then - test.socket.zigbee:__set_channel_ordering("relaxed") - end - if test.socket.devices and test.socket.devices.__set_channel_ordering then - test.socket.devices:__set_channel_ordering("relaxed") - end - - local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") - local on2, off2 = build_output_timing(mock_parent_device, mock_output_child_2, "2") - - local function enqueue_output_timing_writes() - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, on1) }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, off1) }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, ON_TIME_ATTR, on2) }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, OFF_WAIT_ATTR, off2) }) - end - - -- Device init issues one set of manufacturer-specific writes per output during startup - enqueue_output_timing_writes() - - for _, endpoint in ipairs(INPUT_ENDPOINTS) do - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, endpoint, false) }) - for _, output_ep in ipairs(OUTPUT_ENDPOINTS) do - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, endpoint, output_ep) }) - end - end -end - -local function expect_init_sequence() - -- Initialization expectations are registered during test setup; lifecycle events fire as part of driver startup. -end - -local function expect_switch_registration(device) - test.socket.devices:__expect_send({ - "register_native_capability_attr_handler", - { device_uuid = device.id, capability_id = "switch", capability_attr_id = "switch" }, - }) -end - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init() - reset_preferences() - register_initial_config_expectations() - test.mock_device.add_test_device(mock_parent_device) - test.mock_device.add_test_device(mock_output_child_1) - test.mock_device.add_test_device(mock_output_child_2) - zigbee_test_utils.init_noop_health_check_timer() - --register_initial_config_expectations() -end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "Init configures outputs and routes attribute reports", - function() - expect_init_sequence() - test.wait_for_events() - - test.socket.capability:__set_channel_ordering("relaxed") - - test.socket.zigbee:__queue_receive({ - mock_parent_device.id, - OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1), - }) - test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) - expect_switch_registration(mock_output_child_1) - - test.socket.zigbee:__queue_receive({ - mock_parent_device.id, - OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, false):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2), - }) - test.socket.capability:__expect_send(mock_output_child_2:generate_test_message("main", Switch.switch.off())) - expect_switch_registration(mock_output_child_2) - - test.socket.zigbee:__queue_receive({ - mock_parent_device.id, - BasicInput.attributes.PresentValue:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.INPUT_3), - }) - test.socket.capability:__expect_send(mock_parent_device:generate_test_message("input3", Switch.switch.on())) - - test.wait_for_events() - - local child1_native = mock_output_child_1:get_field("frient_io_native_74") - assert(child1_native, "expected Output 1 child to register native switch handler") - local child2_native = mock_output_child_2:get_field("frient_io_native_75") - assert(child2_native, "expected Output 2 child to register native switch handler") - local parent_native = mock_parent_device:get_field("frient_io_native_72") - assert(parent_native, "expected parent device to register native switch handler for input 3") - end -) - -test.register_coroutine_test( - "Default responses update state and trigger reads", - function() - expect_init_sequence() - test.wait_for_events() - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.capability:__set_channel_ordering("relaxed") - - local on_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.On.ID) - test.socket.zigbee:__queue_receive({ mock_parent_device.id, on_response }) - test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) - - local timed_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.OnWithTimedOff.ID) - test.socket.zigbee:__queue_receive({ mock_parent_device.id, timed_response }) - local read_msg = cluster_base.read_attribute( - mock_parent_device, - data_types.ClusterId(OnOff.ID), - data_types.AttributeId(OnOff.attributes.OnOff.ID) - ) - read_msg.tx_options = data_types.Uint16(0) - read_msg = read_msg:to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) - test.socket.zigbee:__expect_send({ mock_parent_device.id, read_msg }) - - local off_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.Off.ID) - test.socket.zigbee:__queue_receive({ mock_parent_device.id, off_response }) - test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.off())) - - test.wait_for_events() - end -) - -test.register_coroutine_test( - "Switch commands drive the correct Zigbee commands", - function() - expect_init_sequence() - test.wait_for_events() - test.socket.zigbee:__set_channel_ordering("relaxed") - - test.socket.capability:__queue_receive({ - mock_output_child_1.id, - { capability = "switch", component = "main", command = "on", args = {} }, - }) - local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") - local timed_on = OnOff.server.commands.OnWithTimedOff( - mock_parent_device, - data_types.Uint8(0), - data_types.Uint16(on1), - data_types.Uint16(off1) - ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) - test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) - - test.socket.capability:__queue_receive({ - mock_output_child_1.id, - { capability = "switch", component = "main", command = "off", args = {} }, - }) - local timed_off = OnOff.server.commands.OnWithTimedOff( - mock_parent_device, - data_types.Uint8(0), - data_types.Uint16(on1), - data_types.Uint16(off1) - ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) - test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_off }) - - test.socket.capability:__queue_receive({ - mock_output_child_2.id, - { capability = "switch", component = "main", command = "on", args = {} }, - }) - local direct_on = OnOff.server.commands.On(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) - test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_on }) - - test.socket.capability:__queue_receive({ - mock_output_child_2.id, - { capability = "switch", component = "main", command = "off", args = {} }, - }) - local direct_off = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) - test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) - - test.socket.capability:__queue_receive({ - mock_parent_device.id, - { capability = "switch", component = "output1", command = "on", args = {} }, - }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) - - test.socket.capability:__queue_receive({ - mock_parent_device.id, - { capability = "switch", component = "output2", command = "off", args = {} }, - }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) - - test.wait_for_events() - end -) - -test.register_coroutine_test( - "Child preference changes send manufacturer writes", - function() - expect_init_sequence() - test.wait_for_events() - test.socket.zigbee:__set_channel_ordering("relaxed") - - queue_child_info_changed(mock_output_child_1, { configOnTime = 12, configOffWaitTime = 13 }) - test.socket.zigbee:__expect_send({ - mock_parent_device.id, - build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, to_deciseconds(12)), - }) - test.socket.zigbee:__expect_send({ - mock_parent_device.id, - build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, to_deciseconds(13)), - }) - - test.wait_for_events() - end -) - -test.register_coroutine_test( - "Parent preference changes manage polarity and binds", - function() - expect_init_sequence() - test.wait_for_events() - test.socket.zigbee:__set_channel_ordering("relaxed") - - queue_parent_info_changed({ - reversePolarity1 = true, - controlOutput11 = true, - controlOutput21 = true, - }) - test.socket.zigbee:__expect_send({ - mock_parent_device.id, - build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, true), - }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) }) - - queue_parent_info_changed({ - reversePolarity1 = true, - controlOutput11 = false, - controlOutput21 = true, - }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) - - queue_parent_info_changed({ - reversePolarity3 = true, - controlOutput23 = true, - }) - test.socket.zigbee:__expect_send({ - mock_parent_device.id, - build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, true), - }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) - - queue_parent_info_changed({ - reversePolarity3 = true, - controlOutput23 = false, - }) - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) - - test.wait_for_events() - end -) - -test.run_registered_tests() From eb662642da9bf7b254abff25f61e2be9b1c51c89 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 2 Dec 2025 14:04:46 +0100 Subject: [PATCH 08/14] additional test --- drivers/SmartThings/zigbee-switch/src/init.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/init.lua b/drivers/SmartThings/zigbee-switch/src/init.lua index a7be3f8801..1b694d2a5e 100644 --- a/drivers/SmartThings/zigbee-switch/src/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/init.lua @@ -98,8 +98,7 @@ local zigbee_switch_driver_template = { lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN lazy_load_if_possible("laisiao"), lazy_load_if_possible("tuya-multi"), - lazy_load_if_possible("frient"), - lazy_load_if_possible("frient-IO") + lazy_load_if_possible("frient") }, zigbee_handlers = { global = { From c2d7945ca6d32a4b4d4e18f8a5081f347ce0f014 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 2 Dec 2025 14:26:17 +0100 Subject: [PATCH 09/14] Revert changes and add can_handle file --- .../src/frient-IO/can_handle.lua | 12 + .../zigbee-switch/src/frient-IO/init.lua | 13 +- .../SmartThings/zigbee-switch/src/init.lua | 3 +- .../src/test/test_frient_IO_module.lua | 568 ++++++++++++++++++ 4 files changed, 583 insertions(+), 13 deletions(-) create mode 100644 drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua new file mode 100644 index 0000000000..4e1d465ee3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Function to determine if the driver can handle this device +return function(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and device:get_model() == "IOMZB-110" then + local subdriver = require("frient-IO") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua index 30c0a38902..4f77ef9035 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -42,10 +42,6 @@ local COMPONENTS = { OUTPUT_2 = "output2" } -local ZIGBEE_BRIDGE_FINGERPRINTS = { - { manufacturer = "frient A/S", model = "IOMZB-110" } -} - local ZIGBEE_ENDPOINTS = { INPUT_1 = 0x70, INPUT_2 = 0x71, @@ -594,14 +590,7 @@ local frient_bridge_handler = { doConfigure = configure_handler, infoChanged = info_changed_handler }, - can_handle = function(opts, driver, device, ...) - for _, fingerprint in ipairs(ZIGBEE_BRIDGE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.manufacturer and device:get_model() == fingerprint.model then - local subdriver = require("frient-IO") - return true, subdriver - end - end - end + can_handle = require("frient-IO.can_handle"), } return frient_bridge_handler diff --git a/drivers/SmartThings/zigbee-switch/src/init.lua b/drivers/SmartThings/zigbee-switch/src/init.lua index 1b694d2a5e..a7be3f8801 100644 --- a/drivers/SmartThings/zigbee-switch/src/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/init.lua @@ -98,7 +98,8 @@ local zigbee_switch_driver_template = { lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN lazy_load_if_possible("laisiao"), lazy_load_if_possible("tuya-multi"), - lazy_load_if_possible("frient") + lazy_load_if_possible("frient"), + lazy_load_if_possible("frient-IO") }, zigbee_handlers = { global = { diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua new file mode 100644 index 0000000000..baeb38a20a --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -0,0 +1,568 @@ +-- Copyright 2025 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local messages = require "st.zigbee.messages" +local constants = require "st.zigbee.constants" +local zdo_messages = require "st.zigbee.zdo" +local bind_request = require "st.zigbee.zdo.bind_request" +local unbind_request = require "frient-IO.unbind_request" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local zcl_messages = require "st.zigbee.zcl" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +local Switch = capabilities.switch + +local ZIGBEE_ENDPOINTS = { + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75, +} + +local INPUT_ENDPOINTS = { + ZIGBEE_ENDPOINTS.INPUT_1, + ZIGBEE_ENDPOINTS.INPUT_2, + ZIGBEE_ENDPOINTS.INPUT_3, + ZIGBEE_ENDPOINTS.INPUT_4, +} +local OUTPUT_ENDPOINTS = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, +} + +local DEVELCO_MFG_CODE = 0x1015 +local ON_TIME_ATTR = 0x8000 +local OFF_WAIT_ATTR = 0x8001 + +local function sanitize_timing(value) + local v = tonumber(value) or 0 + if v < 0 then + v = 0 + elseif v > 0xFFFF then + v = 0xFFFF + end + return math.tointeger(v) or 0 +end + +local function to_deciseconds(value) + return math.floor(sanitize_timing(value) * 10) +end + +local function build_client_mfg_write(device, endpoint, attr_id, value) + local msg = cluster_base.write_manufacturer_specific_attribute( + device, + BasicInput.ID, + attr_id, + DEVELCO_MFG_CODE, + data_types.Uint16, + value + ) + msg.body.zcl_header.frame_ctrl:set_direction_client() + msg.tx_options = data_types.Uint16(0) + return msg:to_endpoint(endpoint) +end + +local function build_basic_input_polarity_write(device, endpoint, enabled) + local polarity_value = data_types.validate_or_build_type( + enabled and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload" + ) + local msg = cluster_base.write_attribute( + device, + data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + polarity_value + ) + msg.tx_options = data_types.Uint16(0) + return msg:to_endpoint(endpoint) +end + +local function build_bind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + bind_request.BindRequest.ID + ) + local bind_body = bind_request.BindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + bind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = bind_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg +end + +local function build_unbind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + unbind_request.UNBIND_REQUEST_CLUSTER_ID + ) + local unbind_body = unbind_request.UnbindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + unbind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = unbind_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg +end + +local function build_default_response_msg(device, endpoint, command_id) + local addr_header = messages.AddressHeader( + device:get_short_address(), + endpoint, + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + constants.HA_PROFILE_ID, + OnOff.ID + ) + local response_body = default_response.DefaultResponse(command_id, Status.SUCCESS) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = response_body + }) + return messages.ZigbeeMessageRx({ address_header = addr_header, body = message_body }) +end + +local function build_output_timing(device, child, suffix) + local on_pref + local off_pref + if child.preferences.configOnTime ~= nil or child.preferences.configOffWaitTime ~= nil then + on_pref = child.preferences.configOnTime or 0 + off_pref = child.preferences.configOffWaitTime or 0 + else + on_pref = device.preferences["configOnTime" .. suffix] or 0 + off_pref = device.preferences["configOffWaitTime" .. suffix] or 0 + end + return to_deciseconds(on_pref), to_deciseconds(off_pref) +end + +local function copy_table(source) + local result = {} + for key, value in pairs(source) do + result[key] = value + end + return result +end + +local parent_preference_state = {} + +local mock_parent_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("switch-4inputs-2outputs.yml"), + fingerprinted_endpoint_id = ZIGBEE_ENDPOINTS.INPUT_1, + label = "frient IO Module", + zigbee_endpoints = { + [ZIGBEE_ENDPOINTS.INPUT_1] = { + id = ZIGBEE_ENDPOINTS.INPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_2] = { + id = ZIGBEE_ENDPOINTS.INPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_3] = { + id = ZIGBEE_ENDPOINTS.INPUT_3, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_4] = { + id = ZIGBEE_ENDPOINTS.INPUT_4, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_1] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_2] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + }, +}) + +local mock_output_child_1 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-1", + label = "frient IO Module Output 1", + vendor_provided_label = "Output 1", +}) + +local mock_output_child_2 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-2", + label = "frient IO Module Output 2", + vendor_provided_label = "Output 2", +}) + +local function reset_preferences() + mock_parent_device.preferences.reversePolarity1 = false + mock_parent_device.preferences.reversePolarity2 = false + mock_parent_device.preferences.reversePolarity3 = false + mock_parent_device.preferences.reversePolarity4 = false + + mock_parent_device.preferences.controlOutput11 = false + mock_parent_device.preferences.controlOutput21 = false + mock_parent_device.preferences.controlOutput12 = false + mock_parent_device.preferences.controlOutput22 = false + mock_parent_device.preferences.controlOutput13 = false + mock_parent_device.preferences.controlOutput23 = false + mock_parent_device.preferences.controlOutput14 = false + mock_parent_device.preferences.controlOutput24 = false + + mock_parent_device.preferences.configOnTime1 = 3 + mock_parent_device.preferences.configOffWaitTime1 = 4 + mock_parent_device.preferences.configOnTime2 = 7 + mock_parent_device.preferences.configOffWaitTime2 = 8 + + mock_output_child_1.preferences.configOnTime = 5 + mock_output_child_1.preferences.configOffWaitTime = 6 + mock_output_child_2.preferences.configOnTime = 0 + mock_output_child_2.preferences.configOffWaitTime = 0 + + parent_preference_state = copy_table(mock_parent_device.preferences) + + local field_keys = { + "frient_io_native_70", + "frient_io_native_71", + "frient_io_native_72", + "frient_io_native_73", + "frient_io_native_74", + "frient_io_native_75", + } + + for _, key in ipairs(field_keys) do + mock_parent_device:set_field(key, nil, { persist = true }) + end + + mock_output_child_1:set_field("frient_io_native_74", nil, { persist = true }) + mock_output_child_2:set_field("frient_io_native_75", nil, { persist = true }) +end + +local function queue_child_info_changed(child, preferences) + local raw = rawget(child, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(preferences) do + raw.preferences[key] = value + end + end + test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ preferences = preferences })) +end + +local function queue_parent_info_changed(preferences) + local full_preferences = copy_table(parent_preference_state) + for key, value in pairs(preferences) do + full_preferences[key] = value + end + parent_preference_state = copy_table(full_preferences) + + local raw = rawget(mock_parent_device, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(full_preferences) do + raw.preferences[key] = value + end + end + + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ preferences = full_preferences }) + ) +end + +local function register_initial_config_expectations() + if test.socket.zigbee and test.socket.zigbee.__set_channel_ordering then + test.socket.zigbee:__set_channel_ordering("relaxed") + end + if test.socket.devices and test.socket.devices.__set_channel_ordering then + test.socket.devices:__set_channel_ordering("relaxed") + end + + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local on2, off2 = build_output_timing(mock_parent_device, mock_output_child_2, "2") + + local function enqueue_output_timing_writes() + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, on1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, off1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, ON_TIME_ATTR, on2) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, OFF_WAIT_ATTR, off2) }) + end + + -- Device init issues one set of manufacturer-specific writes per output during startup + enqueue_output_timing_writes() + + for _, endpoint in ipairs(INPUT_ENDPOINTS) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, endpoint, false) }) + for _, output_ep in ipairs(OUTPUT_ENDPOINTS) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, endpoint, output_ep) }) + end + end +end + +local function expect_init_sequence() + -- Initialization expectations are registered during test setup; lifecycle events fire as part of driver startup. +end + +local function expect_switch_registration(device) + test.socket.devices:__expect_send({ + "register_native_capability_attr_handler", + { device_uuid = device.id, capability_id = "switch", capability_attr_id = "switch" }, + }) +end + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + reset_preferences() + register_initial_config_expectations() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_output_child_1) + test.mock_device.add_test_device(mock_output_child_2) + zigbee_test_utils.init_noop_health_check_timer() + --register_initial_config_expectations() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Init configures outputs and routes attribute reports", + function() + expect_init_sequence() + test.wait_for_events() + + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1), + }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + expect_switch_registration(mock_output_child_1) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, false):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2), + }) + test.socket.capability:__expect_send(mock_output_child_2:generate_test_message("main", Switch.switch.off())) + expect_switch_registration(mock_output_child_2) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + BasicInput.attributes.PresentValue:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.INPUT_3), + }) + test.socket.capability:__expect_send(mock_parent_device:generate_test_message("input3", Switch.switch.on())) + + test.wait_for_events() + + local child1_native = mock_output_child_1:get_field("frient_io_native_74") + assert(child1_native, "expected Output 1 child to register native switch handler") + local child2_native = mock_output_child_2:get_field("frient_io_native_75") + assert(child2_native, "expected Output 2 child to register native switch handler") + local parent_native = mock_parent_device:get_field("frient_io_native_72") + assert(parent_native, "expected parent device to register native switch handler for input 3") + end +) + +test.register_coroutine_test( + "Default responses update state and trigger reads", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + + local on_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.On.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, on_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + + local timed_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.OnWithTimedOff.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, timed_response }) + local read_msg = cluster_base.read_attribute( + mock_parent_device, + data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID) + ) + read_msg.tx_options = data_types.Uint16(0) + read_msg = read_msg:to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, read_msg }) + + local off_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.Off.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, off_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.off())) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Switch commands drive the correct Zigbee commands", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local timed_on = OnOff.server.commands.OnWithTimedOff( + mock_parent_device, + data_types.Uint8(0), + data_types.Uint16(on1), + data_types.Uint16(off1) + ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local timed_off = OnOff.server.commands.OnWithTimedOff( + mock_parent_device, + data_types.Uint8(0), + data_types.Uint16(on1), + data_types.Uint16(off1) + ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_off }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local direct_on = OnOff.server.commands.On(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local direct_off = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output1", command = "on", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output2", command = "off", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Child preference changes send manufacturer writes", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + queue_child_info_changed(mock_output_child_1, { configOnTime = 12, configOffWaitTime = 13 }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, to_deciseconds(12)), + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, to_deciseconds(13)), + }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Parent preference changes manage polarity and binds", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = true, + controlOutput21 = true, + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = false, + controlOutput21 = true, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = true, + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = false, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + + test.wait_for_events() + end +) + +test.run_registered_tests() From d878c344af8d09852a058b39801d332573c41171 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 2 Dec 2025 14:39:32 +0100 Subject: [PATCH 10/14] fixed test --- .../zigbee-switch/src/test/test_frient_IO_module.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua index baeb38a20a..4feb061f74 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -537,6 +537,7 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() queue_parent_info_changed({ reversePolarity1 = true, @@ -544,6 +545,7 @@ test.register_coroutine_test( controlOutput21 = true, }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + test.wait_for_events() queue_parent_info_changed({ reversePolarity3 = true, @@ -554,13 +556,13 @@ test.register_coroutine_test( build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, true), }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() queue_parent_info_changed({ reversePolarity3 = true, controlOutput23 = false, }) test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) - test.wait_for_events() end ) From 8c50601b23869a9d0088852fd7e07fc3108cebc5 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 8 Dec 2025 09:32:13 +0100 Subject: [PATCH 11/14] capabilities fix --- .../zigbee-switch/profiles/frient-io-output-switch.yml | 2 ++ .../zigbee-switch/profiles/switch-4inputs-2outputs.yml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml index f3074fa581..551061d7b5 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml @@ -4,6 +4,8 @@ components: capabilities: - id: switch version: 1 + - id: refresh + version: 1 preferences: - title: "Output: On Time" name: configOnTime diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml index c9272cc3c2..c6120b2561 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml @@ -2,6 +2,8 @@ name: switch-4inputs-2outputs components: - id: main capabilities: + - id: firmwareUpdate + version: 1 - id: refresh version: 1 categories: @@ -11,8 +13,6 @@ components: capabilities: - id: switch version: 1 - - id: firmwareUpdate - version: 1 - id: input2 label: "Input 2" capabilities: From 3f16e4d3ff02e27b20e98a52035e767a963a28c7 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 13 Jan 2026 14:45:00 +0100 Subject: [PATCH 12/14] Changes according to pr comments --- .../zigbee-switch/src/frient-IO/init.lua | 846 ++++++++---------- .../src/test/test_frient_IO_module.lua | 146 ++- 2 files changed, 523 insertions(+), 469 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua index 4f77ef9035..1b209cf8b8 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -30,567 +30,503 @@ local OnOff = clusters.OnOff local capabilities = require "st.capabilities" local Switch = capabilities.switch local CHILD_OUTPUT_PROFILE = "frient-io-output-switch" +local utils = require "st.utils" local configurationMap = require "configurations" local COMPONENTS = { - INPUT_1 = "input1", - INPUT_2 = "input2", - INPUT_3 = "input3", - INPUT_4 = "input4", - OUTPUT_1 = "output1", - OUTPUT_2 = "output2" + INPUT_1 = "input1", + INPUT_2 = "input2", + INPUT_3 = "input3", + INPUT_4 = "input4", + OUTPUT_1 = "output1", + OUTPUT_2 = "output2" } local ZIGBEE_ENDPOINTS = { - INPUT_1 = 0x70, - INPUT_2 = 0x71, - INPUT_3 = 0x72, - INPUT_4 = 0x73, - OUTPUT_1 = 0x74, - OUTPUT_2 = 0x75 + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75 +} + +local INPUT_CONFIGS = { + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_1, + reverse_pref = "reversePolarity1", + binds = { + { pref = "controlOutput11", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput21", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_2, + reverse_pref = "reversePolarity2", + binds = { + { pref = "controlOutput12", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput22", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_3, + reverse_pref = "reversePolarity3", + binds = { + { pref = "controlOutput13", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput23", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_4, + reverse_pref = "reversePolarity4", + binds = { + { pref = "controlOutput14", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput24", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + } } local OUTPUT_INFO = { - ["1"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1, key = "frient-io-output-1", label_suffix = "Output 1" }, - ["2"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2, key = "frient-io-output-2", label_suffix = "Output 2" } + ["1"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1, key = "frient-io-output-1", label_suffix = "Output 1" }, + ["2"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2, key = "frient-io-output-2", label_suffix = "Output 2" } } local OUTPUT_BY_ENDPOINT, OUTPUT_BY_KEY = {}, {} for suffix, info in pairs(OUTPUT_INFO) do - info.suffix = suffix - OUTPUT_BY_ENDPOINT[info.endpoint] = info - OUTPUT_BY_KEY[info.key] = info + info.suffix = suffix + OUTPUT_BY_ENDPOINT[info.endpoint] = info + OUTPUT_BY_KEY[info.key] = info end local ZIGBEE_MFG_CODES = { - Develco = 0x1015 + Develco = 0x1015 } local ZIGBEE_MFG_ATTRIBUTES = { - client = { - OnWithTimeOff_OnTime = { - ID = 0x8000, - data_type = data_types.Uint16 - }, - OnWithTimeOff_OffWaitTime = { - ID = 0x8001, - data_type = data_types.Uint16 - } + client = { + OnWithTimeOff_OnTime = { + ID = 0x8000, + data_type = data_types.Uint16 }, - server = { IASActivation = { - ID = 0x8000, - data_type = data_types.Uint16 - } } + OnWithTimeOff_OffWaitTime = { + ID = 0x8001, + data_type = data_types.Uint16 + } + }, + server = { IASActivation = { + ID = 0x8000, + data_type = data_types.Uint16 + } } } local function write_client_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, data_type, payload) - local message = cluster_base.write_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, - data_type, payload) + local message = cluster_base.write_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, + data_type, payload) - message.body.zcl_header.frame_ctrl:set_direction_client() - return message + message.body.zcl_header.frame_ctrl:set_direction_client() + return message end local function write_basic_input_polarity_attr(device, ep_id, payload) - local value = data_types.validate_or_build_type(payload and 1 or 0, - BasicInput.attributes.Polarity.base_type, - "payload") - device:send(cluster_base.write_attribute(device, data_types.ClusterId(BasicInput.ID), - data_types.AttributeId(BasicInput.attributes.Polarity.ID), - value):to_endpoint(ep_id)) + local value = data_types.validate_or_build_type(payload and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload") + device:send(cluster_base.write_attribute(device, data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + value):to_endpoint(ep_id)) end local function ensure_child_devices(driver, device) - if device.parent_assigned_child_key ~= nil then - return - end + if device.parent_assigned_child_key ~= nil then + return + end - for _, info in pairs(OUTPUT_INFO) do - local child = device:get_child_by_parent_assigned_key(info.key) - if child == nil then - driver:try_create_device({ - type = "EDGE_CHILD", - parent_device_id = device.id, - parent_assigned_child_key = info.key, - profile = CHILD_OUTPUT_PROFILE, - label = string.format("%s %s", device.label, info.label_suffix), - vendor_provided_label = info.label_suffix - }) - child = device:get_child_by_parent_assigned_key(info.key) - end - if child then - child:set_field("endpoint", info.endpoint, { persist = true }) - end + for _, info in pairs(OUTPUT_INFO) do + local child = device:get_child_by_parent_assigned_key(info.key) + if child == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + parent_device_id = device.id, + parent_assigned_child_key = info.key, + profile = CHILD_OUTPUT_PROFILE, + label = string.format("%s %s", device.label, info.label_suffix), + vendor_provided_label = info.label_suffix + }) + child = device:get_child_by_parent_assigned_key(info.key) end -end - -local function to_integer(value) - if value == nil then return nil end - if type(value) == "number" then return math.tointeger(value) end - local num = tonumber(value) - return num and math.tointeger(num) or nil + if child then + child:set_field("endpoint", info.endpoint, { persist = true }) + end + end end local function sanitize_timing(value) - local int = to_integer(value) or 0 - if int < 0 then - int = 0 - elseif int > 0xFFFF then - int = 0xFFFF - end - return int + local int = math.tointeger(value) or 0 + return utils.clamp_value(int, 0, 0xFFFF) end local function get_output_timing(device, suffix) - local info = OUTPUT_INFO[suffix] - if not info then return 0, 0 end - local child = device:get_child_by_parent_assigned_key(info.key) - if child then - local on_time = math.floor((sanitize_timing(child.preferences.configOnTime)) * 10) - local off_wait = math.floor((sanitize_timing(child.preferences.configOffWaitTime)) * 10) - return on_time, off_wait - end - local on_time = math.floor((sanitize_timing(device.preferences["configOnTime" .. suffix]))*10) - local off_wait = math.floor((sanitize_timing(device.preferences["configOffWaitTime" .. suffix]))*10) + local info = OUTPUT_INFO[suffix] + if not info then return 0, 0 end + local child = device:get_child_by_parent_assigned_key(info.key) + if child then + local on_time = math.floor((sanitize_timing(child.preferences.configOnTime)) * 10) + local off_wait = math.floor((sanitize_timing(child.preferences.configOffWaitTime)) * 10) return on_time, off_wait + end + local on_time = math.floor((sanitize_timing(device.preferences["configOnTime" .. suffix]))*10) + local off_wait = math.floor((sanitize_timing(device.preferences["configOffWaitTime" .. suffix]))*10) + return on_time, off_wait end local function handle_output_command(device, suffix, command_name) - local info = OUTPUT_INFO[suffix] - if info == nil then return end - local config_on_time, config_off_wait_time = get_output_timing(device, suffix) - local endpoint = info.endpoint - - if command_name == "on" then - if config_on_time == 0 then - device:send(OnOff.server.commands.On(device):to_endpoint(endpoint)) - else - device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), - data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) - end + local info = OUTPUT_INFO[suffix] + if info == nil then return end + local config_on_time, config_off_wait_time = get_output_timing(device, suffix) + local endpoint = info.endpoint + + if command_name == "on" then + if config_on_time == 0 then + device:send(OnOff.server.commands.On(device):to_endpoint(endpoint)) else - if config_on_time == 0 then - device:send(OnOff.server.commands.Off(device):to_endpoint(endpoint)) - else - device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), - data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) - end + device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), + data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) end + else + if config_on_time == 0 then + device:send(OnOff.server.commands.Off(device):to_endpoint(endpoint)) + else + device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), + data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) + end + end end local function emit_switch_event_for_endpoint(device, endpoint, event) - local info = OUTPUT_BY_ENDPOINT[endpoint] - if info ~= nil then - local child = device:get_child_by_parent_assigned_key(info.key) - if child then - child:emit_event(event) - return - end + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child then + child:emit_event(event) + return end - device:emit_event_for_endpoint(endpoint, event) + end + device:emit_event_for_endpoint(endpoint, event) end local function register_native_switch_handler(device, endpoint) - local field_key = string.format("frient_io_native_%02X", endpoint) - local info = OUTPUT_BY_ENDPOINT[endpoint] - if info ~= nil then - local child = device:get_child_by_parent_assigned_key(info.key) - if child and not child:get_field(field_key) then - child:register_native_capability_attr_handler("switch", "switch") - child:set_field(field_key, true) - end - return + local field_key = string.format("frient_io_native_%02X", endpoint) + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child and not child:get_field(field_key) then + child:register_native_capability_attr_handler("switch", "switch") + child:set_field(field_key, true) end + return + end - if not device:get_field(field_key) then - device:register_native_capability_attr_handler("switch", "switch") - device:set_field(field_key, true) - end + if not device:get_field(field_key) then + device:register_native_capability_attr_handler("switch", "switch") + device:set_field(field_key, true) + end end local function on_off_attr_handler(driver, device, value, zb_message) - local endpoint = zb_message.address_header.src_endpoint.value - register_native_switch_handler(device, endpoint) - emit_switch_event_for_endpoint(device, endpoint, value.value and Switch.switch.on() or Switch.switch.off()) + local endpoint = zb_message.address_header.src_endpoint.value + register_native_switch_handler(device, endpoint) + emit_switch_event_for_endpoint(device, endpoint, value.value and Switch.switch.on() or Switch.switch.off()) end local function build_bind_request(device, src_cluster, src_ep_id, dest_ep_id) - local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), - device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, bind_request.BindRequest.ID) - - local bind_req = bind_request.BindRequest(device.zigbee_eui, src_ep_id, - src_cluster, - bind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) - local message_body = zdo_messages.ZdoMessageBody({ - zdo_body = bind_req - }) - local bind_cmd = messages.ZigbeeMessageTx({ - address_header = addr_header, - body = message_body - }) - return bind_cmd + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, bind_request.BindRequest.ID) + + local bind_req = bind_request.BindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + bind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = bind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd end local function build_unbind_request(device, src_cluster, src_ep_id, dest_ep_id) - local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), - device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, unbind_request.UNBIND_REQUEST_CLUSTER_ID) - - local unbind_req = unbind_request.UnbindRequest(device.zigbee_eui, src_ep_id, - src_cluster, - unbind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) - local message_body = zdo_messages.ZdoMessageBody({ - zdo_body = unbind_req - }) - local bind_cmd = messages.ZigbeeMessageTx({ - address_header = addr_header, - body = message_body - }) - return bind_cmd + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, unbind_request.UNBIND_REQUEST_CLUSTER_ID) + + local unbind_req = unbind_request.UnbindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + unbind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = unbind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd end -local function component_to_endpoint(device, component_id) - if component_id == COMPONENTS.INPUT_1 then - return ZIGBEE_ENDPOINTS.INPUT_1 - elseif component_id == COMPONENTS.INPUT_2 then - return ZIGBEE_ENDPOINTS.INPUT_2 - elseif component_id == COMPONENTS.INPUT_3 then - return ZIGBEE_ENDPOINTS.INPUT_3 - elseif component_id == COMPONENTS.INPUT_4 then - return ZIGBEE_ENDPOINTS.INPUT_4 - elseif component_id == COMPONENTS.OUTPUT_1 then - return ZIGBEE_ENDPOINTS.OUTPUT_1 - elseif component_id == COMPONENTS.OUTPUT_2 then - return ZIGBEE_ENDPOINTS.OUTPUT_2 - else - return device.fingerprinted_endpoint_id +local function apply_input_preference_changes(device, old_prefs, config) + if old_prefs[config.reverse_pref] ~= device.preferences[config.reverse_pref] then + write_basic_input_polarity_attr(device, config.endpoint, device.preferences[config.reverse_pref]) + end + + for _, bind_cfg in ipairs(config.binds) do + if old_prefs[bind_cfg.pref] ~= device.preferences[bind_cfg.pref] then + device:send(device.preferences[bind_cfg.pref] + and build_bind_request(device, BasicInput.ID, config.endpoint, bind_cfg.endpoint) + or build_unbind_request(device, BasicInput.ID, config.endpoint, bind_cfg.endpoint)) end + end +end + +local function component_to_endpoint(device, component_id) + if component_id == COMPONENTS.INPUT_1 then + return ZIGBEE_ENDPOINTS.INPUT_1 + elseif component_id == COMPONENTS.INPUT_2 then + return ZIGBEE_ENDPOINTS.INPUT_2 + elseif component_id == COMPONENTS.INPUT_3 then + return ZIGBEE_ENDPOINTS.INPUT_3 + elseif component_id == COMPONENTS.INPUT_4 then + return ZIGBEE_ENDPOINTS.INPUT_4 + elseif component_id == COMPONENTS.OUTPUT_1 then + return ZIGBEE_ENDPOINTS.OUTPUT_1 + elseif component_id == COMPONENTS.OUTPUT_2 then + return ZIGBEE_ENDPOINTS.OUTPUT_2 + else + return device.fingerprinted_endpoint_id + end end local function endpoint_to_component(device, ep) - local ep_id = type(ep) == "table" and ep.value or ep - if ep_id == ZIGBEE_ENDPOINTS.INPUT_1 then - return COMPONENTS.INPUT_1 - elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_2 then - return COMPONENTS.INPUT_2 - elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_3 then - return COMPONENTS.INPUT_3 - elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_4 then - return COMPONENTS.INPUT_4 - elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_1 then - return COMPONENTS.OUTPUT_1 - elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_2 then - return COMPONENTS.OUTPUT_2 - else - return "main" - end + local ep_id = type(ep) == "table" and ep.value or ep + if ep_id == ZIGBEE_ENDPOINTS.INPUT_1 then + return COMPONENTS.INPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_2 then + return COMPONENTS.INPUT_2 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_3 then + return COMPONENTS.INPUT_3 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_4 then + return COMPONENTS.INPUT_4 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_1 then + return COMPONENTS.OUTPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_2 then + return COMPONENTS.OUTPUT_2 + else + return "main" + end end local function init_handler(self, device) - device:set_component_to_endpoint_fn(component_to_endpoint) - device:set_endpoint_to_component_fn(endpoint_to_component) - - if device.parent_assigned_child_key ~= nil then - return - end - - ensure_child_devices(self, device) - - local on1, off1 = get_output_timing(device, "1") - device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, - on1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) - device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, - off1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) - - local on2, off2 = get_output_timing(device, "2") - device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, - on2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) - device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, - off2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) - - -- Input 1 - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_1, device.preferences.reversePolarity1) - - device:send(device.preferences.controlOutput11 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1)) - - device:send(device.preferences.controlOutput21 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2)) - - -- Input 2 - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_2, device.preferences.reversePolarity2) - - device:send(device.preferences.controlOutput12 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1)) - - device:send(device.preferences.controlOutput22 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2)) - - -- Input 3 - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_3, device.preferences.reversePolarity3) - - device:send(device.preferences.controlOutput13 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1)) - - device:send(device.preferences.controlOutput23 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2)) - - -- Input 4 - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_4, device.preferences.reversePolarity4) - - device:send(device.preferences.controlOutput14 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1)) - - device:send(device.preferences.controlOutput24 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2)) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) end local function added_handler(self, device) - ensure_child_devices(self, device) + ensure_child_devices(self, device) end local function configure_handler(self, device) - local configuration = configurationMap.get_device_configuration(device) - if configuration ~= nil then - for _, attribute in ipairs(configuration) do - if attribute.configurable ~= false then - device:add_configured_attribute(attribute) - end - end + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + if attribute.configurable ~= false then + device:add_configured_attribute(attribute) + end end - device:configure() + end + device:configure() + if device.parent_assigned_child_key ~= nil then + return + end + + ensure_child_devices(self, device) + + local on1, off1 = get_output_timing(device, "1") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + + local on2, off2 = get_output_timing(device, "2") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + + -- Input 1 + local default_old_prefs = {} + for _, config in ipairs(INPUT_CONFIGS) do + apply_input_preference_changes(device, default_old_prefs, config) + end end local function info_changed_handler(self, device, event, args) - if device.parent_assigned_child_key ~= nil then - -- This is a child device - local parent = device:get_parent_device() - if not parent then return end - - local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] - if not info then return end - - -- Child devices have simple preference names without suffix - local on_time = math.floor(sanitize_timing(device.preferences.configOnTime) * 10) - local off_wait = math.floor(sanitize_timing(device.preferences.configOffWaitTime) * 10) - - parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, - on_time):to_endpoint(info.endpoint)) - - parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, - ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, - off_wait):to_endpoint(info.endpoint)) - return - else - -- Input 1 - if args.old_st_store.preferences.reversePolarity1 ~= device.preferences.reversePolarity1 then - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_1, device.preferences.reversePolarity1) - end - - if args.old_st_store.preferences.controlOutput11 ~= device.preferences.controlOutput11 then - device:send(device.preferences.controlOutput11 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1)) - end - - if args.old_st_store.preferences.controlOutput21 ~= device.preferences.controlOutput21 then - device:send(device.preferences.controlOutput21 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2)) - end - - -- Input 2 - if args.old_st_store.preferences.reversePolarity2 ~= device.preferences.reversePolarity2 then - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_2, device.preferences.reversePolarity2) - end - - if args.old_st_store.preferences.controlOutput12 ~= device.preferences.controlOutput12 then - device:send(device.preferences.controlOutput12 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_1)) - end - - if args.old_st_store.preferences.controlOutput22 ~= device.preferences.controlOutput22 then - device:send(device.preferences.controlOutput22 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_2, ZIGBEE_ENDPOINTS.OUTPUT_2)) - end - - -- Input 3 - if args.old_st_store.preferences.reversePolarity3 ~= device.preferences.reversePolarity3 then - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_3, device.preferences.reversePolarity3) - end - - if args.old_st_store.preferences.controlOutput13 ~= device.preferences.controlOutput13 then - device:send(device.preferences.controlOutput13 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_1)) - end - - if args.old_st_store.preferences.controlOutput23 ~= device.preferences.controlOutput23 then - device:send(device.preferences.controlOutput23 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2)) - end - - -- Input 4 - if args.old_st_store.preferences.reversePolarity4 ~= device.preferences.reversePolarity4 then - write_basic_input_polarity_attr(device, ZIGBEE_ENDPOINTS.INPUT_4, device.preferences.reversePolarity4) - end - - if args.old_st_store.preferences.controlOutput14 ~= device.preferences.controlOutput14 then - device:send(device.preferences.controlOutput14 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_1)) - end - - if args.old_st_store.preferences.controlOutput24 ~= device.preferences.controlOutput24 then - device:send(device.preferences.controlOutput24 - and build_bind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2) - or build_unbind_request(device, BasicInput.ID, ZIGBEE_ENDPOINTS.INPUT_4, ZIGBEE_ENDPOINTS.OUTPUT_2)) - end + if device.parent_assigned_child_key ~= nil then + -- This is a child device + local parent = device:get_parent_device() + if not parent then return end + + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if not info then return end + + -- Child devices have simple preference names without suffix + local on_time = math.floor(sanitize_timing(device.preferences.configOnTime) * 10) + local off_wait = math.floor(sanitize_timing(device.preferences.configOffWaitTime) * 10) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on_time):to_endpoint(info.endpoint)) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off_wait):to_endpoint(info.endpoint)) + return + else + local old_prefs = (args.old_st_store and args.old_st_store.preferences) or {} + for _, config in ipairs(INPUT_CONFIGS) do + apply_input_preference_changes(device, old_prefs, config) end + end end local function present_value_attr_handler(driver, device, value, zb_message) - local ep_id = zb_message.address_header.src_endpoint - register_native_switch_handler(device, ep_id.value) - device:emit_event_for_endpoint(ep_id, value.value and Switch.switch.on() or Switch.switch.off()) + local ep_id = zb_message.address_header.src_endpoint + register_native_switch_handler(device, ep_id.value) + device:emit_event_for_endpoint(ep_id, value.value and Switch.switch.on() or Switch.switch.off()) end local function on_off_default_response_handler(driver, device, zb_rx) - local status = zb_rx.body.zcl_body.status.value - local endpoint = zb_rx.address_header.src_endpoint.value - - if status == Status.SUCCESS then - local cmd = zb_rx.body.zcl_body.cmd.value - local event = nil - - if cmd == OnOff.server.commands.On.ID then - event = Switch.switch.on() - elseif cmd == OnOff.server.commands.OnWithTimedOff.ID then - device:send(cluster_base.read_attribute(device, data_types.ClusterId(OnOff.ID), - data_types.AttributeId(OnOff.attributes.OnOff.ID)):to_endpoint(endpoint)) - elseif cmd == OnOff.server.commands.Off.ID then - event = Switch.switch.off() - end - - if event ~= nil then - emit_switch_event_for_endpoint(device, endpoint, event) - end + local status = zb_rx.body.zcl_body.status.value + local endpoint = zb_rx.address_header.src_endpoint.value + + if status == Status.SUCCESS then + local cmd = zb_rx.body.zcl_body.cmd.value + local event = nil + + if cmd == OnOff.server.commands.On.ID then + event = Switch.switch.on() + elseif cmd == OnOff.server.commands.OnWithTimedOff.ID then + device:send(cluster_base.read_attribute(device, data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID)):to_endpoint(endpoint)) + elseif cmd == OnOff.server.commands.Off.ID then + event = Switch.switch.off() end -end -local function switch_on_handler(driver, device, command) - local parent = device:get_parent_device() - if parent then - local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] - if info then - handle_output_command(parent, info.suffix, "on") - return - end + if event ~= nil then + emit_switch_event_for_endpoint(device, endpoint, event) end + end +end - local num = command.component and command.component:match("output(%d)") - if num then - handle_output_command(device, num, "on") - return +local function switch_on_handler(driver, device, command) + local parent = device:get_parent_device() + if parent then + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if info then + handle_output_command(parent, info.suffix, "on") + return end - num = command.component:match("input(%d)") - if num then - local component = device.profile.components[command.component] - local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) - if value == "on" then - device:emit_component_event(component, - Switch.switch.on({ state_change = true, visibility = { displayed = false } })) - elseif value == "off" then - device:emit_component_event(component, - Switch.switch.off({ state_change = true, visibility = { displayed = false } })) - end + end + + local num = command.component and command.component:match("output(%d)") + if num then + handle_output_command(device, num, "on") + return + end + num = command.component:match("input(%d)") + if num then + local component = device.profile.components[command.component] + local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) + if value == "on" then + device:emit_component_event(component, + Switch.switch.on({ state_change = true, visibility = { displayed = false } })) + elseif value == "off" then + device:emit_component_event(component, + Switch.switch.off({ state_change = true, visibility = { displayed = false } })) end + end end local function switch_off_handler(driver, device, command) - local parent = device:get_parent_device() - if parent then - local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] - if info then - handle_output_command(parent, info.suffix, "off") - return - end + local parent = device:get_parent_device() + if parent then + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if info then + handle_output_command(parent, info.suffix, "off") + return end - - local num = command.component and command.component:match("output(%d)") - if num then - handle_output_command(device, num, "off") - return - end - num = command.component:match("input(%d)") - if num then - local component = device.profile.components[command.component] - local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) - if value == "on" then - device:emit_component_event(component, - Switch.switch.on({ state_change = true, visibility = { displayed = false } })) - elseif value == "off" then - device:emit_component_event(component, - Switch.switch.off({ state_change = true, visibility = { displayed = false } })) - end + end + + local num = command.component and command.component:match("output(%d)") + if num then + handle_output_command(device, num, "off") + return + end + num = command.component:match("input(%d)") + if num then + local component = device.profile.components[command.component] + local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) + if value == "on" then + device:emit_component_event(component, + Switch.switch.on({ state_change = true, visibility = { displayed = false } })) + elseif value == "off" then + device:emit_component_event(component, + Switch.switch.off({ state_change = true, visibility = { displayed = false } })) end + end end local frient_bridge_handler = { - NAME = "frient bridge handler", - zigbee_handlers = { - global = { - [OnOff.ID] = { - [zcl_global_commands.DEFAULT_RESPONSE_ID] = on_off_default_response_handler - } - }, - cluster = {}, - attr = { - [BasicInput.ID] = { - [BasicInput.attributes.PresentValue.ID] = present_value_attr_handler - }, - [OnOff.ID] = { - [OnOff.attributes.OnOff.ID] = on_off_attr_handler - } - }, - zdo = {} - }, - capability_handlers = { - [Switch.ID] = { - [Switch.commands.on.NAME] = switch_on_handler, - [Switch.commands.off.NAME] = switch_off_handler - } + NAME = "frient bridge handler", + zigbee_handlers = { + global = { + [OnOff.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = on_off_default_response_handler + } }, - lifecycle_handlers = { - added = added_handler, - init = init_handler, - doConfigure = configure_handler, - infoChanged = info_changed_handler + cluster = {}, + attr = { + [BasicInput.ID] = { + [BasicInput.attributes.PresentValue.ID] = present_value_attr_handler + }, + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = on_off_attr_handler + } }, - can_handle = require("frient-IO.can_handle"), + zdo = {} + }, + capability_handlers = { + [Switch.ID] = { + [Switch.commands.on.NAME] = switch_on_handler, + [Switch.commands.off.NAME] = switch_off_handler + } + }, + lifecycle_handlers = { + added = added_handler, + init = init_handler, + doConfigure = configure_handler, + infoChanged = info_changed_handler + }, + can_handle = require("frient-IO.can_handle"), } return frient_bridge_handler diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua index 4feb061f74..6966a7b30f 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -17,6 +17,10 @@ local unbind_request = require "frient-IO.unbind_request" local default_response = require "st.zigbee.zcl.global_commands.default_response" local zcl_messages = require "st.zigbee.zcl" local Status = require "st.zigbee.generated.types.ZclStatus" +local device_management = require "st.zigbee.device_management" +local configuration_map = require "configurations" +local switch_defaults = require "st.zigbee.defaults.switch_defaults" +local mock_devices_api = require "integration_test.mock_devices_api" local BasicInput = clusters.BasicInput local OnOff = clusters.OnOff @@ -31,15 +35,35 @@ local ZIGBEE_ENDPOINTS = { OUTPUT_2 = 0x75, } -local INPUT_ENDPOINTS = { - ZIGBEE_ENDPOINTS.INPUT_1, - ZIGBEE_ENDPOINTS.INPUT_2, - ZIGBEE_ENDPOINTS.INPUT_3, - ZIGBEE_ENDPOINTS.INPUT_4, -} -local OUTPUT_ENDPOINTS = { - ZIGBEE_ENDPOINTS.OUTPUT_1, - ZIGBEE_ENDPOINTS.OUTPUT_2, +local INPUT_CONFIGS = { + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_1, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_2, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_3, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_4, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, } local DEVELCO_MFG_CODE = 0x1015 @@ -223,6 +247,37 @@ local mock_parent_device = test.mock_device.build_test_zigbee_device({ }, }) + function mock_parent_device:get_model() + return "IOMZB-110" + end + + function mock_parent_device:get_manufacturer() + return "frient A/S" + end + + function mock_parent_device:supports_server_cluster(cluster_id, endpoint_id) + local function endpoint_supports(ep) + if not ep or not ep.server_clusters then return false end + for _, server_cluster in ipairs(ep.server_clusters) do + if server_cluster == cluster_id then + return true + end + end + return false + end + + if endpoint_id ~= nil then + return endpoint_supports(self.zigbee_endpoints[endpoint_id]) + end + + for _, endpoint in pairs(self.zigbee_endpoints) do + if endpoint_supports(endpoint) then + return true + end + end + return false + end + local mock_output_child_1 = test.mock_device.build_test_child_device({ profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), parent_device_id = mock_parent_device.id, @@ -320,6 +375,65 @@ local function register_initial_config_expectations() test.socket.devices:__set_channel_ordering("relaxed") end + local function register_device_configure_expectations() + local configuration = configuration_map.get_device_configuration(mock_parent_device) or {} + local configs_by_cluster = {} + local function add_attribute_config(attribute) + if attribute.configurable ~= false then + configs_by_cluster[attribute.cluster] = configs_by_cluster[attribute.cluster] or {} + table.insert(configs_by_cluster[attribute.cluster], attribute) + end + end + + for _, attribute in ipairs(configuration) do + add_attribute_config(attribute) + end + + local default_configs = switch_defaults.attribute_configurations or {} + for _, attribute in ipairs(default_configs) do + add_attribute_config(attribute) + end + + local cluster_ids = {} + for cluster_id in pairs(configs_by_cluster) do + cluster_ids[#cluster_ids + 1] = cluster_id + end + table.sort(cluster_ids) + + local endpoint_ids = {} + for endpoint_id in pairs(mock_parent_device.zigbee_endpoints) do + endpoint_ids[#endpoint_ids + 1] = endpoint_id + end + table.sort(endpoint_ids) + + for _, cluster_id in ipairs(cluster_ids) do + local attr_configs = configs_by_cluster[cluster_id] + table.sort(attr_configs, function(a, b) + return a.attribute < b.attribute + end) + for _, endpoint_id in ipairs(endpoint_ids) do + local endpoint = mock_parent_device.zigbee_endpoints[endpoint_id] + if endpoint and mock_parent_device:supports_server_cluster(cluster_id, endpoint.id) then + local bind_cmd = device_management.build_bind_request( + mock_parent_device, + cluster_id, + zigbee_test_utils.mock_hub_eui, + endpoint.id + ):to_endpoint(endpoint.id) + bind_cmd.tx_options = data_types.Uint16(0) + test.socket.zigbee:__expect_send({ mock_parent_device.id, bind_cmd }) + for _, attr_config in ipairs(attr_configs) do + local config_cmd = device_management.attr_config(mock_parent_device, attr_config):to_endpoint(endpoint.id) + config_cmd.tx_options = data_types.Uint16(0) + test.socket.zigbee:__expect_send({ mock_parent_device.id, config_cmd }) + end + end + end + end + end + + register_device_configure_expectations() + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") local on2, off2 = build_output_timing(mock_parent_device, mock_output_child_2, "2") @@ -333,16 +447,20 @@ local function register_initial_config_expectations() -- Device init issues one set of manufacturer-specific writes per output during startup enqueue_output_timing_writes() - for _, endpoint in ipairs(INPUT_ENDPOINTS) do - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, endpoint, false) }) - for _, output_ep in ipairs(OUTPUT_ENDPOINTS) do - test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, endpoint, output_ep) }) + for _, config in ipairs(INPUT_CONFIGS) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, config.endpoint, false) }) + for _, output_ep in ipairs(config.binds) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, config.endpoint, output_ep) }) end end end local function expect_init_sequence() - -- Initialization expectations are registered during test setup; lifecycle events fire as part of driver startup. + mock_devices_api.__expect_update_device( + mock_parent_device.id, + { deviceId = mock_parent_device.id, provisioningState = "PROVISIONED" } + ) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "doConfigure" }) end local function expect_switch_registration(device) From b2eb3f014254fcee1c1b713d2e36b490e94cae67 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 19 Jan 2026 11:23:08 +0100 Subject: [PATCH 13/14] changes according to pr comments --- .../src/configurations/devices.lua | 112 +++++++++--------- .../zigbee-switch/src/frient-IO/init.lua | 93 +++++---------- .../src/frient-IO/unbind_request.lua | 15 +-- .../src/test/test_frient_IO_module.lua | 9 +- 4 files changed, 90 insertions(+), 139 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua index 01635ccf69..d49d10e9c5 100644 --- a/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua +++ b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua @@ -114,63 +114,63 @@ local devices = { } }, FRIENT_IO_MODULE = { - FINGERPRINTS = { - { mfr = "frient A/S", model = "IOMZB-110" } - }, - CONFIGURATION = { - { - cluster = OnOff.ID, - attribute = OnOff.attributes.OnTime.ID, - minimum_interval = 10, - maximum_interval = 600, - reportable_change = 1, - data_type = OnOff.attributes.OnOff.base_type, - configurable = true, - monitored = true - }, - { - cluster = OnOff.ID, - attribute = OnOff.attributes.OffWaitTime.ID, - minimum_interval = 10, - maximum_interval = 600, - reportable_change = 1, - data_type = OnOff.attributes.OffWaitTime.base_type, - configurable = true, - monitored = true - }, - { - cluster = BasicInput.ID, - attribute = BasicInput.attributes.PresentValue.ID, - minimum_interval = 10, - maximum_interval = 600, - reportable_change = 0, - data_type = BasicInput.attributes.PresentValue.base_type, - configurable = true, - monitored = true - }, - { - cluster = BasicInput.ID, - attribute = BasicInput.attributes.Polarity.ID, - minimum_interval = 10, - maximum_interval = 600, - reportable_change = 0, - data_type = BasicInput.attributes.Polarity.base_type, - configurable = true, - monitored = true - }, - { - cluster = BasicInput.ID, - attribute = 0x8000, -- IASActivation - minimum_interval = 10, - maximum_interval = 600, - reportable_change = 0, - data_type = data_types.Uint16, - mfg_code = 0x1015, - configurable = true, - monitored = true - } - } + FINGERPRINTS = { + { mfr = "frient A/S", model = "IOMZB-110" } + }, + CONFIGURATION = { + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OnOff.base_type, + configurable = true, + monitored = true + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OffWaitTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OffWaitTime.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.PresentValue.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.PresentValue.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.Polarity.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.Polarity.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = 0x8000, -- IASActivation + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = data_types.Uint16, + mfg_code = 0x1015, + configurable = true, + monitored = true + } } + } } return devices \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua index 1b209cf8b8..a4aa3d639b 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -171,13 +171,12 @@ local function get_output_timing(device, suffix) local info = OUTPUT_INFO[suffix] if not info then return 0, 0 end local child = device:get_child_by_parent_assigned_key(info.key) - if child then - local on_time = math.floor((sanitize_timing(child.preferences.configOnTime)) * 10) - local off_wait = math.floor((sanitize_timing(child.preferences.configOffWaitTime)) * 10) - return on_time, off_wait - end local on_time = math.floor((sanitize_timing(device.preferences["configOnTime" .. suffix]))*10) local off_wait = math.floor((sanitize_timing(device.preferences["configOffWaitTime" .. suffix]))*10) + if child then + on_time = math.floor((sanitize_timing(child.preferences.configOnTime)) * 10) + off_wait = math.floor((sanitize_timing(child.preferences.configOffWaitTime)) * 10) + end return on_time, off_wait end @@ -195,12 +194,7 @@ local function handle_output_command(device, suffix, command_name) data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) end else - if config_on_time == 0 then - device:send(OnOff.server.commands.Off(device):to_endpoint(endpoint)) - else - device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), - data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) - end + device:send(OnOff.server.commands.Off(device):to_endpoint(endpoint)) end end @@ -437,60 +431,33 @@ local function on_off_default_response_handler(driver, device, zb_rx) end end -local function switch_on_handler(driver, device, command) - local parent = device:get_parent_device() - if parent then - local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] - if info then - handle_output_command(parent, info.suffix, "on") - return - end - end - - local num = command.component and command.component:match("output(%d)") - if num then - handle_output_command(device, num, "on") - return - end - num = command.component:match("input(%d)") - if num then - local component = device.profile.components[command.component] - local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) - if value == "on" then - device:emit_component_event(component, - Switch.switch.on({ state_change = true, visibility = { displayed = false } })) - elseif value == "off" then - device:emit_component_event(component, - Switch.switch.off({ state_change = true, visibility = { displayed = false } })) +local function make_switch_handler(command_name) + return function(driver, device, command) + local parent = device:get_parent_device() + if parent then + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if info then + handle_output_command(parent, info.suffix, command_name) + return + end end - end -end -local function switch_off_handler(driver, device, command) - local parent = device:get_parent_device() - if parent then - local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] - if info then - handle_output_command(parent, info.suffix, "off") + local num = command.component and command.component:match("output(%d)") + if num then + handle_output_command(device, num, command_name) return end - end - - local num = command.component and command.component:match("output(%d)") - if num then - handle_output_command(device, num, "off") - return - end - num = command.component:match("input(%d)") - if num then - local component = device.profile.components[command.component] - local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) - if value == "on" then - device:emit_component_event(component, - Switch.switch.on({ state_change = true, visibility = { displayed = false } })) - elseif value == "off" then - device:emit_component_event(component, - Switch.switch.off({ state_change = true, visibility = { displayed = false } })) + num = command.component:match("input(%d)") + if num then + local component = device.profile.components[command.component] + local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) + if value == "on" then + device:emit_component_event(component, + Switch.switch.on({ state_change = true, visibility = { displayed = false } })) + elseif value == "off" then + device:emit_component_event(component, + Switch.switch.off({ state_change = true, visibility = { displayed = false } })) + end end end end @@ -516,8 +483,8 @@ local frient_bridge_handler = { }, capability_handlers = { [Switch.ID] = { - [Switch.commands.on.NAME] = switch_on_handler, - [Switch.commands.off.NAME] = switch_off_handler + [Switch.commands.on.NAME] = make_switch_handler("on"), + [Switch.commands.off.NAME] = make_switch_handler("off") } }, lifecycle_handlers = { diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua index cecaf696b2..64c4b05464 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua @@ -1,16 +1,5 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local data_types = require "st.zigbee.data_types" local utils = require "st.zigbee.utils" diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua index 6966a7b30f..c9d5f9b75e 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -578,13 +578,8 @@ test.register_coroutine_test( mock_output_child_1.id, { capability = "switch", component = "main", command = "off", args = {} }, }) - local timed_off = OnOff.server.commands.OnWithTimedOff( - mock_parent_device, - data_types.Uint8(0), - data_types.Uint16(on1), - data_types.Uint16(off1) - ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) - test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_off }) + local direct_off_output1 = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off_output1 }) test.socket.capability:__queue_receive({ mock_output_child_2.id, From 1f77863a7ae84f2707452b7a07f9bc31761bfb90 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Mon, 26 Jan 2026 13:04:39 +0100 Subject: [PATCH 14/14] Shortened Copyright statement --- .../zigbee-switch/src/frient-IO/init.lua | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua index a4aa3d639b..293583f442 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -1,16 +1,5 @@ --- Copyright 2025 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Zigbee Spec Utils local constants = require "st.zigbee.constants"