From 0c1bb36a2c4407bc3448e255bf1e53b0e3cdd867 Mon Sep 17 00:00:00 2001 From: Marcin Tyminski Date: Tue, 25 Nov 2025 08:51:25 +0100 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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: