From f4b878cfa207c400298769c84fef1132b006f563 Mon Sep 17 00:00:00 2001 From: "Jan Jakubiszyn/SmartThings Integrations (BJ.jakubiszynS) /SRPOL/Engineer/Samsung Electronics" Date: Mon, 30 Mar 2026 18:30:39 +0200 Subject: [PATCH] Adding support for Hager WAASYS devices --- .../matter-switch/fingerprints.yml | 17 +- .../profiles/motion-illuminance.yml | 14 + .../profiles/window-covering.yml | 21 + .../SmartThings/matter-switch/src/init.lua | 1 + .../src/sub_drivers/Hager/can_handle.lua | 27 + .../src/sub_drivers/Hager/init.lua | 831 ++++++++ .../src/switch_utils/device_configuration.lua | 36 +- .../matter-switch/src/switch_utils/fields.lua | 5 + .../matter-switch/src/switch_utils/utils.lua | 8 + .../src/test/test_hager_waasys.lua | 1882 +++++++++++++++++ 10 files changed, 2836 insertions(+), 6 deletions(-) create mode 100644 drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering.yml create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/Hager/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/Hager/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 852a67432d..6b8ed6392f 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -3928,7 +3928,22 @@ matterManufacturer: vendorId: 0x141E productId: 0x0001 deviceProfileName: "button-battery" - +#HAGER + - id: "4741/6" + deviceLabel: "Hager Switch 2G" + vendorId: 0x1285 + productId: 0x0006 + deviceProfileName: "matter-bridge" + - id: "4741/5" + deviceLabel: "Hager Switch 1G" + vendorId: 0x1285 + productId: 0x0005 + deviceProfileName: "matter-bridge" + - id: "4741/7" + deviceLabel: "Hager PIR" + vendorId: 0x1285 + productId: 0x0007 + deviceProfileName: "matter-bridge" #Bridge devices need manufacturer specific fingerprints until #bridge support is released to all hubs. This is because of the way generic diff --git a/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml b/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml new file mode 100644 index 0000000000..346b079a15 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml @@ -0,0 +1,14 @@ +name: motion-illuminance +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering.yml b/drivers/SmartThings/matter-switch/profiles/window-covering.yml new file mode 100644 index 0000000000..b588b41ec9 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering.yml @@ -0,0 +1,21 @@ +name: window-covering +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: presetPosition + explicit: true + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index b1c6a8df8b..741e637344 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -343,6 +343,7 @@ local matter_driver_template = { switch_utils.lazy_load_if_possible("sub_drivers.aqara_cube"), switch_utils.lazy_load("sub_drivers.camera"), switch_utils.lazy_load_if_possible("sub_drivers.eve_energy"), + switch_utils.lazy_load("sub_drivers.Hager"), switch_utils.lazy_load_if_possible("sub_drivers.ikea_scroll"), switch_utils.lazy_load_if_possible("sub_drivers.third_reality_mk1") } diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/Hager/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/Hager/can_handle.lua new file mode 100644 index 0000000000..841e33fd6e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/Hager/can_handle.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +return function(opts, driver, device, ...) + local device_lib = require "st.device" + local fields = require "switch_utils.fields" + local vendor_overrides = fields.vendor_overrides + + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + local parent = device:get_parent_device() + if parent + and parent.network_type == device_lib.NETWORK_TYPE_MATTER + and vendor_overrides[0x1285][parent.manufacturer_info.product_id] + then + return true, require("sub_drivers.Hager") + end + return false + end + + if device.network_type == device_lib.NETWORK_TYPE_MATTER + and device.manufacturer_info.vendor_id == 0x1285 + and vendor_overrides[0x1285][device.manufacturer_info.product_id] + then + return true, require("sub_drivers.Hager") + end + + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/Hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/Hager/init.lua new file mode 100644 index 0000000000..2f2a73f73f --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/Hager/init.lua @@ -0,0 +1,831 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local device_lib = require "st.device" +local utils = require "st.utils" +local ButtonCfg = require "switch_utils.device_configuration" +local switch_utils = require "switch_utils.utils" +local fields = require "switch_utils.fields" + +local IGNORE_NEXT_MPC = "__ignore_next_mpc" +local SUPPORTS_MULTI_PRESS = "__multi_button" + +local ACTIVE_EPS = "__active_EPS" +local FIELD_MAIN_ONOFF_EP = "FIELD_MAIN_ONOFF_EP" +local FIELD_MOTION_HOST = "FIELD_MOTION_HOST" +local FIELD_LUX_TO_MOTION = "__lux_to_motion" +local HOST_ID = "HOST_ID" +local SUBHUB_ID = "SUBHUB_ID" +local CURRENT_LIFT = "__current_lift" +local CURRENT_TILT = "__current_tilt" +local REVERSE_POLARITY = "__reverse_polarity" +local PRESET_LEVEL_KEY = "__preset_level_key" +local BUTTON_EPS = "__button_eps" + +local DEFAULT_PRESET_LEVEL = 50 +local descriptor_cluster_id = 0x001D +local part_list_attr_id = 0x0003 + +local function subscribe_descriptor (device, endpoint_id) + local req = cluster_base.subscribe(device, endpoint_id, descriptor_cluster_id, part_list_attr_id, nil) + device:send(req) +end + +local function set_field_for_endpoint(device, field, endpoint, value, persist) + device:set_field(string.format("%s_%d", field, endpoint), value, { persist = persist }) +end +local function get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +local function get_subhub (driver, device) + local id = device:get_field(SUBHUB_ID) + if not id then + return nil + end + return driver:get_device_info(id) +end + +local function get_host (driver, device) + local id = device:get_field(HOST_ID) + return driver:get_device_info(id) +end + +local function build_lux_to_motion_map(occ_eps, lux_eps) + local map = {} + + if #occ_eps == 0 or #lux_eps == 0 then + return map + end + + table.sort(occ_eps) + table.sort(lux_eps) + + if #occ_eps == #lux_eps then + for i, lux_ep in ipairs(lux_eps) do + map[lux_ep] = occ_eps[i] + end + else + for _, lux_ep in ipairs(lux_eps) do + local best_ep = nil + local best_dist = nil + + for _, occ_ep in ipairs(occ_eps) do + local d = math.abs(lux_ep - occ_ep) + if not best_dist or d < best_dist then + best_dist = d + best_ep = occ_ep + end + end + + map[lux_ep] = best_ep + end + end + + return map +end + +local function extract(ib) + local eps = {} + if ib.data and ib.data.elements then + for _, el in ipairs(ib.data.elements) do + local ep = el.value + if type(ep) == "number" and ep ~= 1 and ep ~= 2 then + table.insert(eps, ep) + end + end + end + return eps +end + +local function emit_for_ep(driver, device, ep, event) + local host = get_host(driver, device) + local subhub = get_subhub(driver, device) + local mapped_ep = ep + local lux_map = device:get_field(FIELD_LUX_TO_MOTION) + + if lux_map and lux_map[ep] then + mapped_ep = lux_map[ep] + end + + local child = nil + if subhub then + child = subhub:get_child_by_parent_assigned_key(tostring(mapped_ep)) + end + + local target = child or host + target:emit_event(event) +end + +local function create_child_for_ep(driver, device, ep_id, profile) + local subhub = get_subhub(driver, device) + + if not subhub then + return nil + end + + local key = string.format("%d", ep_id) + + local device_num = (subhub:get_field("CHILD_COUNTER") or 0) + 1 + subhub:set_field("CHILD_COUNTER", device_num, { persist = false }) + + local name = string.format("%s %d", subhub.label, device_num) + driver:try_create_device({ + type = "EDGE_CHILD", + label = name, + profile = profile, + parent_device_id = subhub.id, + parent_assigned_child_key = key, + vendor_provided_label = name, + }) + + return nil +end + +local function diff (driver, device, ib_elements) + local stored_eps = device:get_field(ACTIVE_EPS) or {} + ib_elements = ib_elements or {} + + local old_set, new_set = {}, {} + for _, ep in ipairs(stored_eps) do + old_set[ep] = true + end + for _, ep in ipairs(ib_elements) do + new_set[ep] = true + end + + local removed, added = {}, {} + + for ep in pairs(old_set) do + if not new_set[ep] then + table.insert(removed, ep) + end + end + + for ep in pairs(new_set) do + if not old_set[ep] then + table.insert(added, ep) + end + end + return removed, added +end + +local function resolve_host_and_ep(driver, device) + local parent = get_subhub(driver, device) + local host = get_host(driver, device) + + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + local wc_eps = device:get_endpoints(clusters.WindowCovering.ID) or {} + local wc_main = wc_eps[1] + + local onOff_eps = host and host:get_field(FIELD_MAIN_ONOFF_EP) + + if wc_main then + return parent, wc_main + elseif onOff_eps then + return parent, onOff_eps + else + return nil, nil + end + end + + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + local ep = tonumber(device.parent_assigned_child_key) + if not ep then + return nil, nil + end + return device, ep + end + + return nil, nil +end + +local function link_host_and_subhub(host) + local parent = host:get_parent_device() + + host:set_field(SUBHUB_ID, parent.id, { persist = true }) + host:set_field(HOST_ID, host.id, { persist = true }) + + parent:set_field(SUBHUB_ID, parent.id, { persist = true }) + parent:set_field(HOST_ID, host.id, { persist = true }) +end + +local function device_init(driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then + return + end + + local wc_eps = device:get_endpoints(clusters.WindowCovering.ID) + local oc_eps = device:get_endpoints(clusters.OccupancySensing.ID) + local product_id = device.manufacturer_info.product_id + + subscribe_descriptor(device, 2) + subscribe_descriptor(device, 0) + + if device:get_parent_device() ~= nil then + link_host_and_subhub(device) + end + + table.sort(wc_eps) + table.sort(oc_eps) + + local subhub = get_subhub(driver, device) + local host = get_host(driver, device) + local main_onOff_at_join = device:get_field(FIELD_MAIN_ONOFF_EP) + + if host and not main_onOff_at_join and (product_id == 0x0005 or product_id == 0x0006) then + host:set_field(FIELD_MAIN_ONOFF_EP, 3, { persist = true }) + device.thread:call_with_delay(6, function() + if host:supports_capability(capabilities.switchLevel) then + host:set_field(FIELD_MAIN_ONOFF_EP, 4, { persist = true }) + end + end) + end + + if #oc_eps > 0 then + device:try_update_metadata({ profile = "motion-illuminance" }) + device:set_field(FIELD_MOTION_HOST, oc_eps[1]) + elseif #wc_eps > 0 and product_id == 0x0005 then + device:try_update_metadata({ profile = "window-covering" }) + elseif #wc_eps > 0 and product_id == 0x0006 then + host:try_update_metadata({ profile = "2-button" }) + end + if subhub then + subhub:subscribe() + end + +end + +local function handle_descriptor_report(driver, device, ib, response) + if ib.endpoint_id ~= 0 and ib.endpoint_id ~= 2 then + return + end + + local subhub = get_subhub(driver, device) + local host = get_host(driver, device) + + if not subhub then + return + end + + local new_eps = extract(ib) or {} + table.sort(new_eps) + + local removed, added = diff(driver, device, new_eps) + + device:set_field(ACTIVE_EPS, new_eps, { persist = true }) + + local occ_eps = device:get_endpoints(clusters.OccupancySensing.ID) + local lux_eps = device:get_endpoints(clusters.IlluminanceMeasurement.ID) + local lux_to_motion = build_lux_to_motion_map(occ_eps, lux_eps) + + if next(lux_to_motion) ~= nil then + device:set_field(FIELD_LUX_TO_MOTION, lux_to_motion) + end + + local stored_eps = device:get_field(ACTIVE_EPS) + + for _, ep in ipairs(added or {}) do + + local button_comb + + if subhub.id == device.id then + subhub:send(clusters.Descriptor.attributes.DeviceTypeList:read(subhub, ep)) + + if ep == 3 and device.network_type == device_lib.NETWORK_TYPE_MATTER and device.manufacturer_info.product_id == 0x0005 then + host:try_update_metadata({ profile = "light-binary" }) + elseif ep == 4 and device.network_type == device_lib.NETWORK_TYPE_MATTER and device.manufacturer_info.product_id == 0x0006 then + for _, ep_match in ipairs(stored_eps) do + if ep_match == 3 then + button_comb = true + break + end + button_comb = false + end + + if button_comb then + host:try_update_metadata({ profile = "light-binary" }) + else + host:try_update_metadata({ profile = "2-button" }) + end + elseif ep == 3 and device.network_type == device_lib.NETWORK_TYPE_MATTER and device.manufacturer_info.product_id == 0x0006 then + for _, ep_match in ipairs(stored_eps) do + if ep_match == 4 then + button_comb = true + break + end + button_comb = false + end + if button_comb then + host:try_update_metadata({ profile = "light-binary" }) + else + host:try_update_metadata({ profile = "2-button" }) + end + end + end + end + + for _, ep in ipairs(removed) do + + local button_eps = subhub:get_field(BUTTON_EPS) or {} + local clean_eps = {} + for _, value in ipairs(button_eps) do + if value ~= ep then + table.insert(clean_eps, value) + end + end + + table.sort(clean_eps) + subhub:set_field(BUTTON_EPS, clean_eps, { persist = true }) + + device.thread:call_with_delay(5, function() + if subhub.id == device.id then + subhub:send(clusters.Descriptor.attributes.DeviceTypeList:read(subhub, ep)) + end + end) + + local key = tostring(ep) + + if not subhub then + end + local child = subhub:get_child_by_parent_assigned_key(key) + if child and child.network_type == device_lib.NETWORK_TYPE_CHILD then + driver:try_delete_device(child.id) + end + + local button_comb + if ep == 3 then + if device:get_parent_device() ~= nil then + if device.manufacturer_info.product_id == 0x0005 then + device:try_update_metadata({ profile = "2-button" }) + elseif device.manufacturer_info.product_id == 0x0006 then + for _, eps in ipairs(stored_eps) do + if eps == 4 then + button_comb = true + break + end + button_comb = false + end + if not button_comb then + host:try_update_metadata({ profile = "4-button" }) + else + host:try_update_metadata({ profile = "2-button" }) + end + + end + end + elseif ep == 4 then + if device.manufacturer_info.product_id == 0x0006 then + for _, eps in ipairs(stored_eps) do + if eps == 3 then + button_comb = true + break + end + button_comb = false + end + if not button_comb then + host:try_update_metadata({ profile = "4-button" }) + else + host:try_update_metadata({ profile = "2-button" }) + create_child_for_ep(driver, subhub, 3, "light-binary") + end + end + end + end + +end + +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + emit_for_ep(driver, device, ib.endpoint_id, capabilities.switch.switch.on()) + else + emit_for_ep(driver, device, ib.endpoint_id, capabilities.switch.switch.off()) + end +end + +local function handle_preset(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + local lift_value = device:get_field(PRESET_LEVEL_KEY) or DEFAULT_PRESET_LEVEL + local hundredths_lift_percent = (100 - tonumber(lift_value)) * 100 + subhub:send(clusters.WindowCovering.server.commands.GoToLiftPercentage(subhub, ep, hundredths_lift_percent)) +end + +local function handle_close(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + local req = clusters.WindowCovering.commands.DownOrClose(subhub, ep) + if device:get_field(REVERSE_POLARITY) then + req = clusters.WindowCovering.server.commands.UpOrOpen(subhub, ep) + end + subhub:send(req) +end + +local function handle_open(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + local req = clusters.WindowCovering.commands.UpOrOpen(subhub, ep) + if device:get_field(REVERSE_POLARITY) then + req = clusters.WindowCovering.server.commands.DownOrClose(subhub, ep) + end + subhub:send(req) +end + +local function handle_pause(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + subhub:send(clusters.WindowCovering.commands.StopMotion(subhub, ep)) +end + +local function handle_shade_level(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + local lift_percentage_value = 100 - cmd.args.shadeLevel + local hundredths_lift_percentage = lift_percentage_value * 100 + subhub:send(clusters.WindowCovering.commands.GoToLiftPercentage(subhub, ep, hundredths_lift_percentage)) +end + +local current_pos_handler = function(attribute) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local windowShade = capabilities.windowShade.windowShade + local position = 100 - math.floor(ib.data.value / 100) + local reverse = device:get_field(REVERSE_POLARITY) + emit_for_ep(driver, device, ib.endpoint_id, attribute(position)) + if attribute == capabilities.windowShadeLevel.shadeLevel then + device:set_field(CURRENT_LIFT, position) + else + device:set_field(CURRENT_TILT, position) + end + local lift_position = device:get_field(CURRENT_LIFT) + local tilt_position = device:get_field(CURRENT_TILT) + if lift_position == nil then + if tilt_position == 0 then + emit_for_ep(driver, device, ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) + elseif tilt_position == 100 then + emit_for_ep(driver, device, ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) + else + emit_for_ep(driver, device, ib.endpoint_id, windowShade.partially_open()) + end + elseif lift_position == 100 then + emit_for_ep(driver, device, ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) + elseif lift_position > 0 then + emit_for_ep(driver, device, ib.endpoint_id, windowShade.partially_open()) + elseif lift_position == 0 then + if tilt_position == nil or tilt_position == 0 then + emit_for_ep(driver, device, ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) + elseif tilt_position > 0 then + emit_for_ep(driver, device, ib.endpoint_id, windowShade.partially_open()) + end + end + end +end + +local function current_status_handler(driver, device, ib, response) + local windowShade = capabilities.windowShade.windowShade + local reverse = device:get_field(REVERSE_POLARITY) + local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL + if state == 1 then + emit_for_ep(driver, device, ib.endpoint_id, reverse and windowShade.closing() or windowShade.opening()) + elseif state == 2 then + emit_for_ep(driver, device, ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) + elseif state ~= 0 then + emit_for_ep(driver, device, ib.endpoint_id, windowShade.unknown()) + end +end + +local function occupancy_measured_value_handler(driver, device, ib, response) + local host = get_host(driver, device) + if ib.data.value ~= nil then + emit_for_ep(driver, host, ib.endpoint_id, ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) + else + return + end +end + +local function illuminance_measured_value_handler(driver, device, ib, response) + local host = get_host(driver, device) + if ib.data.value ~= nil then + local lux = math.floor(10 ^ ((ib.data.value - 1) / 10000)) + emit_for_ep(driver, host, ib.endpoint_id, capabilities.illuminanceMeasurement.illuminance(lux)) + else + return + end +end + +local function device_removed(driver, device) + local subhub = get_subhub(driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then + return + end + + if subhub == nil then + return + end + + local eps = device:get_field(ACTIVE_EPS) or {} + + for _, ep in ipairs(eps) do + local key = tostring(ep) + local child = subhub:get_child_by_parent_assigned_key(key) or nil + + if child then + driver:try_delete_device(child.id) + else + end + end +end + +local function handle_switch_on(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + subhub:send(clusters.OnOff.commands.On(subhub, ep)) +end + +local function handle_switch_off(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + subhub:send(clusters.OnOff.commands.Off(subhub, ep)) +end + +local function handle_switch_set_levels(driver, device, cmd) + local subhub, ep = resolve_host_and_ep(driver, device) + if not subhub or not ep then + return + end + local level = math.floor(cmd.args.level / 100.0 * 254) + subhub:send(clusters.LevelControl.server.commands.MoveToLevelWithOnOff(subhub, ep, level, cmd.args.rate, 0, 0)) +end + +local function level_control_current_level_handler(driver, device, ib, response) + if ib.data.value ~= nil then + local level = ib.data.value + if level > 0 then + level = math.max(1, utils.round(level / 254.0 * 100)) + end + emit_for_ep(driver, device, ib.endpoint_id, capabilities.switchLevel.level(level)) + end +end + +local function long_press_event_handler(driver, device, ib, response) + local host = get_host(driver, device) + host:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.held({ state_change = true })) + if get_field_for_endpoint(device, SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + set_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id, true) + end +end + +local function multi_press_complete_handler(driver, device, ib, response) + local host = get_host(driver, device) + if ib.data and not switch_utils.get_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id) then + local press_value = ib.data.elements.total_number_of_presses_counted.value + if press_value < 7 then + local button_event = capabilities.button.button.pushed({ state_change = true }) + if press_value == 2 then + button_event = capabilities.button.button.double({ state_change = true }) + elseif press_value > 2 then + + button_event = capabilities.button.button(string.format("pushed_%dx", press_value), { state_change = true }) + end + host:emit_event_for_endpoint(ib.endpoint_id, button_event) + else + end + end + switch_utils.set_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id, nil) +end + +local function info_changed(driver, device, event, args) + local host = get_host(driver, device) + local subhub = get_subhub(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and device.profile.id ~= args.old_st_store.profile.id then + + host:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + host.thread:call_with_delay(5, function() + if host:supports_capability(capabilities.button) then + local button_eps = subhub:get_field(BUTTON_EPS) + local clean_eps = {} + for _, v in ipairs(button_eps or {}) do + table.insert(clean_eps, v) + end + ButtonCfg.ButtonCfg.update_button_component_map(host, 1, clean_eps) + ButtonCfg.ButtonCfg.configure_buttons(host) + for _, value in ipairs(clean_eps) do + subhub:send(cluster_base.subscribe(subhub, value, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID)) + subhub:send(cluster_base.subscribe(subhub, value, clusters.Switch.ID, nil, clusters.Switch.events.ShortRelease.ID)) + subhub:send(cluster_base.subscribe(subhub, value, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID)) + host:emit_event_for_endpoint(value, capabilities.button.supportedButtonValues({ "pushed", "double", "held" })) + end + + end + end) + elseif args.old_st_store.preferences.reverse ~= device.preferences.reverse then + if device.preferences.reverse then + device:set_field(REVERSE_POLARITY, true, { persist = true }) + else + device:set_field(REVERSE_POLARITY, false, { persist = true }) + end + elseif args.old_st_store.preferences.presetPosition ~= device.preferences.presetPosition then + local new_preset_value = device.preferences.presetPosition + log.info(new_preset_value, " To new preset vlaue ") + device:set_field(PRESET_LEVEL_KEY, new_preset_value, { persist = true }) + end +end + +local function contains_ep (list, ep) + for _, v in ipairs(list) do + if v == ep then + return true + end + end + return false +end + +local function device_type_handler (driver, device, ib) + local host = get_host(driver, device) + local subhub = get_subhub(driver, device) + + if not subhub then + return + end + + local stored = subhub:get_field(BUTTON_EPS) or {} + local button_endpoints = {} + + local ep = ib.endpoint_id + local value = ib.data.elements + if stored then + for _, v in ipairs(stored) do + table.insert(button_endpoints, v) + end + end + + for _, element in ipairs(value) do + local device_type_field = element.elements.device_type + local device_type_id = device_type_field and device_type_field.value + + if device_type_id == 15 then + if not contains_ep(button_endpoints, ep) then + set_field_for_endpoint(subhub, SUPPORTS_MULTI_PRESS, ep, true, true) + table.insert(button_endpoints, ep) + table.sort(button_endpoints) + subhub:set_field(BUTTON_EPS, button_endpoints, { persist = true }) + end + end + + if device_type_id == 256 then + local active_eps = device:get_field(ACTIVE_EPS) + device.thread:call_with_delay(6, function() + local subscribe = cluster_base.subscribe(subhub, ep, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + subhub:send(subscribe) + end) + + if ep == 3 and device.manufacturer_info.product_id == 0x0005 then + return + elseif ep == 4 and device.manufacturer_info.product_id == 0x0006 then + local flag = false + + for _, eps in ipairs(active_eps) do + if eps == 3 then + flag = true + local ep3 = subhub:get_child_by_parent_assigned_key("3") or nil + if ep3 then + driver:try_delete_device(ep3.id) + end + break + end + end + + if not flag then + create_child_for_ep(driver, subhub, 4, "light-binary") + return + end + elseif ep == 3 and device.manufacturer_info.product_id == 0x0006 then + host.thread:call_with_delay(4, function() + local latest_eps = host:get_field(ACTIVE_EPS) or {} + local has4 = false + for _, eps in ipairs(latest_eps) do + if eps == 4 then + has4 = true + break + end + end + + if has4 then + return + end + create_child_for_ep(driver, subhub, 3, "light-binary") + end) + return + end + create_child_for_ep(driver, subhub, ib.endpoint_id, "light-binary") + + elseif device_type_id == 257 then + subhub:send(cluster_base.subscribe(subhub, ep, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil)) + subhub:send(cluster_base.subscribe(subhub, ep, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil)) + subhub:send(cluster_base.subscribe(subhub, ep, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil)) + subhub:send(cluster_base.subscribe(subhub, ep, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil)) + if ep == 4 and device.manufacturer_info.product_id == 0x0005 then + return + end + create_child_for_ep(driver, device, ib.endpoint_id, "light-level") + + elseif device_type_id == 514 then + subhub:send(cluster_base.subscribe(subhub, ep, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil)) + subhub:send(cluster_base.subscribe(subhub, ep, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil)) + if device.manufacturer_info.product_id == 0x0005 then + return + else + create_child_for_ep(driver, device, ib.endpoint_id, "window-covering") + end + elseif device_type_id == 263 then + subhub:send(cluster_base.subscribe(subhub, ep, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil)) + + elseif device_type_id == 262 then + subhub:send(cluster_base.subscribe(subhub, ep, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil)) + + end + end +end + +local Hager_switch = { + NAME = "Hager matter switch handler", + lifecycle_handlers = { + init = device_init, + infoChanged = info_changed, + removed = device_removed, + }, + matter_handlers = { + attr = { + [clusters.Descriptor.ID] = { + [part_list_attr_id] = handle_descriptor_report, + [clusters.Descriptor.attributes.DeviceTypeList.ID] = device_type_handler + }, + [clusters.IlluminanceMeasurement.ID] = { + [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = illuminance_measured_value_handler + }, + [clusters.OccupancySensing.ID] = { + [clusters.OccupancySensing.attributes.Occupancy.ID] = occupancy_measured_value_handler, + }, + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler + }, + [clusters.LevelControl.ID] = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = level_control_current_level_handler + }, + [clusters.WindowCovering.ID] = { + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler(capabilities.windowShadeLevel.shadeLevel), + [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, + }, + + }, + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.LongPress.ID] = long_press_event_handler, + [clusters.Switch.events.MultiPressComplete.ID] = multi_press_complete_handler, + } + }, + + }, + capability_handlers = { + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = handle_preset, + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.close.NAME] = handle_close, + [capabilities.windowShade.commands.open.NAME] = handle_open, + [capabilities.windowShade.commands.pause.NAME] = handle_pause, + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = handle_shade_level, + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.off.NAME] = handle_switch_off, + [capabilities.switch.commands.on.NAME] = handle_switch_on, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = handle_switch_set_levels + }, + }, + can_handle = require("sub_drivers.Hager.can_handle") +} + +return Hager_switch diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua index ab2e075eb7..af34dd0505 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -143,13 +143,22 @@ function ButtonDeviceConfiguration.update_button_component_map(device, default_e table.sort(button_eps) local component_map = {} component_map["main"] = default_endpoint_id + + local is_hager_vendor = (device.manufacturer_info.vendor_id == 0x1285) + local first_button_ep = button_eps[1] + if is_hager_vendor and first_button_ep ~= default_endpoint_id then + component_map["main"] = first_button_ep + end + for component_num, ep in ipairs(button_eps) do if ep ~= default_endpoint_id then - local button_component = "button" - if #button_eps > 1 then - button_component = button_component .. component_num + if not (is_hager_vendor and ep == first_button_ep) then + local button_component = "button" + if #button_eps > 1 then + button_component = button_component .. component_num + end + component_map[button_component] = ep end - component_map[button_component] = ep end end device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) @@ -219,7 +228,24 @@ function DeviceConfiguration.match_profile(driver, device) local server_onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) -- get_endpoints defaults to return EPs supporting SERVER or BOTH if #server_onoff_ep_ids > 0 then - ChildConfiguration.create_or_update_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep) + if device.manufacturer_info.vendor_id == 0x1285 then + else + ChildConfiguration.create_or_update_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep) + end + end + + -- Hager vendor override checks + if device.manufacturer_info.vendor_id == 0x1285 then + local product_override = fields.vendor_overrides[0x1285][device.manufacturer_info.product_id] + if product_override then + if product_override and device:supports_server_cluster(clusters.OccupancySensing.ID) then + return + end + + if product_override and device:supports_server_cluster(clusters.WindowCovering.ID) then + return + end + end end if switch_utils.tbl_contains(server_onoff_ep_ids, default_endpoint_id) then diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index 1432b18c74..da25a90ec6 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -113,6 +113,11 @@ SwitchFields.vendor_overrides = { [0x1189] = { -- LEDVANCE_MANUFACTURER_ID [0x0891] = { target_profile = "switch-binary", initial_profile = "light-binary" }, }, + [0x1285] = { -- HAGER_MANUFACTURER_ID + [0x0005] = {}, -- Hager WAASYS 1g switch + [0x0006] = {}, -- Hager WAASYS Hager 2g switch + [0x0007] = {}, -- Hager WAASYS PIR sensor + }, [0x1321] = { -- SONOFF_MANUFACTURER_ID [0x000C] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, [0x000D] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 1afecb318f..ca2575f72d 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -165,6 +165,14 @@ function utils.find_default_endpoint(device) return nil end + -- Hager vendor special handling + if device.manufacturer_info.vendor_id == 0x1285 then + if #momentary_switch_ep_ids > 0 then + log.info("Hager device with buttons detected, using default endpoint") + return device.MATTER_DEFAULT_ENDPOINT + end + end + -- Return the first fan endpoint as the default endpoint if any is found if #fan_endpoint_ids > 0 then return get_first_non_zero_endpoint(fan_endpoint_ids) diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua new file mode 100644 index 0000000000..d29df47c9c --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -0,0 +1,1882 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local data_types = require "st.matter.data_types" +local st_utils = require "st.utils" +local dkjson = require "dkjson" + +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local descriptor = require "st.matter.generated.zap_clusters.Descriptor" + +local HOST_ID = "HOST_ID" +local SUBHUB_ID = "SUBHUB_ID" +local BUTTON_EPS = "__button_eps" + +test.disable_startup_messages() +test.socket.matter:__set_channel_ordering("relaxed") + +local function create_subhub_device(product_id) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 4x Button Subhub", + profile = t_utils.get_profile_definition("matter-bridge.yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = product_id, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } + }, + device_types = { { device_type_id = 0x000E, device_type_revision = 1 } } -- AggregateNode + }, + { + endpoint_id = 2, + clusters = { + { + cluster_id = clusters.Descriptor.ID, + cluster_type = "SERVER", + cluster_revision = 1, + } + }, + device_types = { { device_type_id = 0x0039, device_type_revision = 1 } } -- BridgedNode + } + } + }) +end + +local function add_subhub_device(subhub) + test.mock_device.add_test_device(subhub) + test.socket.device_lifecycle:__queue_receive({ subhub.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ subhub.id, "init" }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 2, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 0, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + +local function create_host_device(profile_name, parent_subhub) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 4x Button Host", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent_subhub.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + } + }) +end + +-- Create Hager 2G Relay device with endpoints 3 & 4 (OnOff clusters) +local function create_hager_2g_relay(profile_name, parent_subhub) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 2G Relay", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent_subhub.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + { + endpoint_id = 4, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + +-- Create Hager Dimmer device with ONLY dimmable endpoint (3) - no button endpoints +local function create_hager_dimmer_device_1g(profile_name, parent_subhub) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer Host Only", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent_subhub.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 4, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } -- Dimmable Light + }, + } + }) +end + +-- Create Hager Dimmer device with 2 button endpoints (8, 9) + 1 dimmable OnOff endpoint (3) +local function create_hager_dimmer_device_2g(profile_name, parent_subhub) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer Host Only", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent_subhub.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + } + }) +end + +local function subscribe_switch_events(host) + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(host) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then + subscribe_request:merge(clus:subscribe(host)) + end + end + + test.socket.matter:__expect_send({ host.id, subscribe_request }) +end + +local function subscribe_dimmer_attr(host) + local CLUSTER_SUBSCRIBE_LIST = { + + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(host) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then + subscribe_request:merge(clus:subscribe(host)) + end + end + + test.socket.matter:__expect_send({ host.id, subscribe_request }) +end + +-- Initialize HOST device (add to test, queue lifecycle events, link to SUBHUB) +local function four_button_2g_button_init(host) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button3", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button3", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button4", capabilities.button.button.pushed({ state_change = false }))) +end + +local function add_host_device(host, parent_subhub) + test.mock_device.add_test_device(host) + test.socket.device_lifecycle:__queue_receive({ host.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ host.id, "init" }) + + host:set_field(SUBHUB_ID, parent_subhub.id, { persist = true }) + host:set_field(HOST_ID, host.id, { persist = true }) + parent_subhub:set_field(SUBHUB_ID, parent_subhub.id, { persist = true }) + parent_subhub:set_field(HOST_ID, host.id, { persist = true }) +end + +local function button_supported_values (host) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button3", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button3", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button4", capabilities.button.button.pushed({ state_change = false }))) +end + +local function configure_subhub(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function configure_host(host, expected_profile_change) + test.socket.device_lifecycle:__queue_receive({ host.id, "doConfigure" }) + + if expected_profile_change then + host:expect_metadata_update({ profile = expected_profile_change }) + end + + host:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({ + host.id, + cluster_base.subscribe(host, 2, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) + test.socket.matter:__expect_send({ + host.id, + cluster_base.subscribe(host, 0, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + +-- Create Hager PIR device with 2 button endpoints + motion/illuminance/dimmer endpoint +local function create_hager_pir_device(profile_name, parent_subhub) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 PIR with Buttons and Motion/Illuminance/Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0007, + }, + parent_device_id = parent_subhub.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + { + endpoint_id = 4, + clusters = { + { + cluster_id = clusters.OccupancySensing.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OccupancySensing.attributes.Occupancy.ID] = 0 + } + } + }, + device_types = { { device_type_id = 0x0107, device_type_revision = 1 } } + }, + { + endpoint_id = 5, + clusters = { + { + cluster_id = clusters.IlluminanceMeasurement.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = 0 + } + } + }, + device_types = { { device_type_id = 0x0106, device_type_revision = 1 } } + }, + } + }) +end + +-- Global subhub for tests +local subhub = create_subhub_device(0x0006) -- 2G button product +local subhub_1g = create_subhub_device(0x0005) -- 1G button product +local subhub_pir = create_subhub_device(0x0007) -- PIR product + +local function test_init() + add_subhub_device(subhub) + add_subhub_device(subhub_1g) + add_subhub_device(subhub_pir) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test("Test: 4-Button Device Detection - Profile Changes from matter-bridge to 4-button When Four Button Endpoints Present", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local host = create_host_device("matter-bridge", subhub) + + -- Initialize HOST device + add_host_device(host, subhub) + + -- Configure both devices + configure_subhub(subhub) + configure_host(host, "4-button") + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 0x08) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 0x09) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 0x0A) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 0x0B) + }) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 8, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 9, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 10, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 11, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) +end) + +test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press, and Held Events on 4-Button Device", function() + -- Create HOST device with 4-button profile + test.socket.matter:__set_channel_ordering("relaxed") + + local host = create_host_device("4-button", subhub) + + -- Initialize HOST device + add_host_device(host, subhub) + + -- Configure both devices + configure_subhub(subhub) + configure_host(host, "4-button") + + subscribe_switch_events(host) + + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button3", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button3", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button4", capabilities.button.button.pushed({ state_change = false }))) + test.wait_for_events() + + -- Test single press (pushed) on endpoint 8 (button1) + test.socket.matter:__queue_receive({ + host.id, + clusters.Switch.events.InitialPress:build_test_event_report( + host, 8, { new_position = 1 } + ) + }) + + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) + + --Test double press on endpoint 8 (button1) + test.socket.matter:__queue_receive({ + host.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + host, 8, { new_position = 0, total_number_of_presses_counted = 2, previous_position = 1 } + ) + }) + + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + host.id, + clusters.Switch.events.LongPress:build_test_event_report( + host, 8, { new_position = 1 } + ) + }) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.button.held({ state_change = true }))) + + -- Test long press (held) on endpoint 9 (button2) + test.socket.matter:__queue_receive({ + host.id, + clusters.Switch.events.InitialPress:build_test_event_report( + host, 9, { new_position = 1 } + ) + }) + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + host.id, + clusters.Switch.events.LongPress:build_test_event_report( + host, 9, { new_position = 1 } + ) + }) + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.button.held({ state_change = true }))) + + test.wait_for_events() +end) + +test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 15) and OnOff (Type 256) Device Types with Child Creation", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local host = create_host_device("4-button", subhub) + add_host_device(host, subhub) + configure_subhub(subhub) + configure_host(host, "4-button") + subscribe_switch_events(host) + four_button_2g_button_init(host) + test.wait_for_events() + + -- Receive PartsList with endpoints 8 (button) and 6 (OnOff) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(6), + })) + }) + + -- Expect DeviceTypeList reads for both endpoints + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 8) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 6) + }) + + test.wait_for_events() + + -- Receive DeviceTypeList report with device type 15 (button) for endpoint 8 + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.wait_for_events() + + -- Receive DeviceTypeList report with device type 256 (OnOff) for endpoint 6 + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 6, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 1", + profile = "light-binary", + parent_device_id = subhub.id, + parent_assigned_child_key = "6" + }) + + test.wait_for_events() + + assert(subhub:get_field("__multi_button_8") == true, "Expected __multi_button_8 to be set to true") + + local button_eps = subhub:get_field("__button_eps") + assert(button_eps ~= nil, "Expected __button_eps field to be set") + assert(type(button_eps) == "table", "Expected __button_eps to be a table") + assert(button_eps[1] == 8, "Expected __button_eps to contain endpoint 8") +end) + +test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-binary and 2-button Based On Endpoint Availability", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + local relay = create_hager_2g_relay("matter-bridge", subhub) + add_host_device(relay, subhub) + configure_subhub(subhub) + configure_host(relay, "light-binary") + test.wait_for_events() + + -- Scenario 1: EP3 + EP4 present → light-binary profile + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 3) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 4) + }) + + -- Both endpoints are device type 256 (OnOff) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + relay:expect_metadata_update({ profile = "light-binary" }) + relay:expect_metadata_update({ profile = "light-binary" }) + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 1", + profile = "light-binary", + parent_device_id = subhub.id, + parent_assigned_child_key = "4" + }) + + -- Scenario 2: EP4 removed → profile changes to 2-button, child created for EP3 + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(3), + })) + }) + + relay:expect_metadata_update({ profile = "2-button" }) + + -- + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 2", + profile = "light-binary", + parent_device_id = subhub.id, + parent_assigned_child_key = "3" + }) + + -- + local child_3 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-binary.yml"), + device_network_id = string.format("%s:3", subhub.id), + parent_device_id = subhub.id, + parent_assigned_child_key = "3" + }) + test.mock_device.add_test_device(child_3) + + test.wait_for_events() + + -- Scenario 3: EP4 reappears → profile changes back to light-binary + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 4) + }) + relay:expect_metadata_update({ profile = "light-binary" }) + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 3", + profile = "light-binary", + parent_device_id = subhub.id, + parent_assigned_child_key = "4" + }) + test.wait_for_events() + -- Scenario 4: EP3 removed → profile changes to 2-button, child created for EP4 + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 2, data_types.Array({ + data_types.Uint16(4), + data_types.Uint16(12), + data_types.Uint16(13), + })) + }) + test.mock_time.advance_time(5) + + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 12) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 13) + }) + test.wait_for_events() + + ---- Scenario 5: EP4 removed without EP3, no button endpoints → 4-button profile + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + })) + }) + + relay:expect_metadata_update({ profile = "4-button" }) + + ---- Scenario 6: Only EP4 present → 2-button profile, child created + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(4), + })) + }) + relay:expect_metadata_update({ profile = "2-button" }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 4) + }) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 4", + profile = "light-binary", + parent_device_id = subhub.id, + parent_assigned_child_key = "4" + }) +end) + + +--Test 5.1: Dimmer child device creation and profile behavior +test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable Endpoint with Button Support", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local dimmer = create_hager_dimmer_device_2g("matter-bridge", subhub) + add_host_device(dimmer, subhub) + configure_subhub(subhub) + test.socket.matter:__expect_send({ + dimmer.id, + clusters.LevelControl.attributes.Options:write(dimmer, 3, 1) + }) + configure_host(dimmer, "2-button") + test.wait_for_events() + + -- Scenario 1: 2 button endpoints (8, 9) detected → 2-button profile + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 8) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 9) + }) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + dimmer:expect_metadata_update({ profile = "2-button" }) + + test.wait_for_events() + + -- Scenario 2: Dimmable endpoint 3 (device type 257/260) appears → child device created with light-level profile + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(3), + })) + }) + + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 3) + }) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 3, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 3, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 1", + profile = "light-level", + parent_device_id = subhub.id, + parent_assigned_child_key = "3" + }) + + test.wait_for_events() + + -- Create mock child device for EP3 + local child_dimmer = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:3", subhub.id), + parent_device_id = subhub.id, + parent_assigned_child_key = "3" + }) + test.mock_device.add_test_device(child_dimmer) + + -- Test 1: Turn on the dimmer (OnOff command) + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.matter:__expect_send({ subhub.id, clusters.OnOff.commands.On(subhub, 3) }) + + -- Test 2: Turn off the dimmer (OnOff command) + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "off", args = {} } }) + test.socket.matter:__expect_send({ subhub.id, clusters.OnOff.commands.Off(subhub, 3) }) + + -- Test 3: Set dimmer level to 50% (LevelControl command) + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 20 } } }) + test.socket.matter:__expect_send({ subhub.id, clusters.LevelControl.commands.MoveToLevelWithOnOff(subhub, 3, 50, nil, 0, 0) }) + + -- OnOff attribute changes - device reports on state + test.socket.matter:__queue_receive({ + subhub.id, + clusters.OnOff.server.attributes.OnOff:build_test_report_data(subhub, 3, true) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) + + -- OnOff attribute changes - device reports off state + test.socket.matter:__queue_receive({ + subhub.id, + clusters.OnOff.server.attributes.OnOff:build_test_report_data(subhub, 3, false) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.off())) + + -- LevelControl attribute changes - device reports value 8 + test.socket.matter:__queue_receive({ + subhub.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(subhub, 3, 20) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(8))) + + -- LevelControl attribute changes - device reports value 0 + test.socket.matter:__queue_receive({ + subhub.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(subhub, 3, 0) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(0))) + +end) + +test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Update to light-level", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local dimmer_host = create_hager_dimmer_device_1g("matter-bridge", subhub_1g) + add_host_device(dimmer_host, subhub_1g) + configure_subhub(subhub_1g) + test.socket.matter:__expect_send({ + dimmer_host.id, + clusters.LevelControl.attributes.Options:write(dimmer_host, 4, 1) + }) + configure_host(dimmer_host, "light-level") + test.wait_for_events() + +end) + +test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control Capabilities", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + + local dimmer_host = create_hager_dimmer_device_1g("light-level", subhub_1g) + add_host_device(dimmer_host, subhub_1g) + configure_subhub(subhub_1g) + test.socket.matter:__expect_send({ + dimmer_host.id, + clusters.LevelControl.attributes.Options:write(dimmer_host, 4, 1) + }) + subscribe_dimmer_attr(dimmer_host) + + configure_host(dimmer_host, "light-level") + test.wait_for_events() + + -- Send dimmable endpoint 4 detection (device type 257) to trigger profile change and subscriptions + test.socket.matter:__queue_receive({ + subhub_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub_1g, 2, data_types.Array({ + data_types.Uint16(4), + })) + }) + + test.socket.matter:__expect_send({ + subhub_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub_1g, 4) + }) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + subhub_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub_1g, 4, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + --subscriptions from device_type_handler + test.socket.matter:__expect_send({ + subhub_1g.id, + cluster_base.subscribe(subhub_1g, 4, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_1g.id, + cluster_base.subscribe(subhub_1g, 4, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_1g.id, + cluster_base.subscribe(subhub_1g, 4, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_1g.id, + cluster_base.subscribe(subhub_1g, 4, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + -- Trigger 6-second delay in device_init to change FIELD_MAIN_ONOFF_EP from 3 to 4 + test.wait_for_events() + test.mock_time.advance_time(6) + test.wait_for_events() + + assert(dimmer_host:get_field("FIELD_MAIN_ONOFF_EP") == 4, "Expected FIELD_MAIN_ONOFF_EP to be 4 after 6-second delay") + + -- Test 1: Turn on the dimmer (OnOff command on HOST device) + test.socket.capability:__queue_receive({ dimmer_host.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.matter:__expect_send({ subhub_1g.id, clusters.OnOff.commands.On(subhub_1g, 4) }) + + -- Verify on state via attribute report + test.socket.matter:__queue_receive({ + subhub_1g.id, + clusters.OnOff.server.attributes.OnOff:build_test_report_data(subhub_1g, 4, true) + }) + test.socket.capability:__expect_send(dimmer_host:generate_test_message("main", capabilities.switch.switch.on())) + + -- Test 2: Turn off the dimmer (OnOff command on HOST device) + test.socket.capability:__queue_receive({ dimmer_host.id, { capability = "switch", component = "main", command = "off", args = {} } }) + test.socket.matter:__expect_send({ subhub_1g.id, clusters.OnOff.commands.Off(subhub_1g, 4) }) + + ---- Verify off state via attribute report + test.socket.matter:__queue_receive({ + subhub_1g.id, + clusters.OnOff.server.attributes.OnOff:build_test_report_data(subhub_1g, 4, false) + }) + test.socket.capability:__expect_send(dimmer_host:generate_test_message("main", capabilities.switch.switch.off())) + + -- Test 3: Set dimmer level to 50% (LevelControl command on HOST device) + test.socket.capability:__queue_receive({ dimmer_host.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) + test.socket.matter:__expect_send({ subhub_1g.id, clusters.LevelControl.commands.MoveToLevelWithOnOff(subhub_1g, 4, 127, nil, 0, 0) }) + + test.wait_for_events() + -- Verify level via attribute report + test.socket.matter:__queue_receive({ + subhub_1g.id, + clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(subhub_1g, 4, 127) + }) + test.socket.capability:__expect_send(dimmer_host:generate_test_message("main", capabilities.switchLevel.level(50))) + + -- Test 4: Set dimmer level to 100% (LevelControl command on HOST device) + test.socket.capability:__queue_receive({ dimmer_host.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 100 } } }) + test.socket.matter:__expect_send({ subhub_1g.id, clusters.LevelControl.commands.MoveToLevelWithOnOff(subhub_1g, 4, 254, nil, 0, 0) }) + + -- Verify level via attribute report + test.socket.matter:__queue_receive({ + subhub_1g.id, + clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(subhub_1g, 4, 254) + }) + test.socket.capability:__expect_send(dimmer_host:generate_test_message("main", capabilities.switchLevel.level(100))) + + test.wait_for_events() +end) + +test.register_coroutine_test("Test: PIR Device - Initialization with Motion and Illuminance Capabilities", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local pir_device = create_hager_pir_device("matter-bridge", subhub_pir) + + add_host_device(pir_device, subhub_pir) + pir_device:expect_metadata_update({ profile = "motion-illuminance" }) + configure_subhub(subhub_pir) + + test.socket.matter:__expect_send({ + pir_device.id, + clusters.LevelControl.attributes.Options:write(pir_device, 3, 1) + }) + configure_host(pir_device, nil) + +end) + +test.register_coroutine_test("Test: PIR Device - Complete Functionality with Motion, Illuminance, and Dimmer Support", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local pir_device = create_hager_pir_device("motion-illuminance", subhub_pir) + + add_host_device(pir_device, subhub_pir) + pir_device:expect_metadata_update({ profile = "motion-illuminance" }) + configure_subhub(subhub_pir) + + test.socket.matter:__expect_send({ + pir_device.id, + clusters.LevelControl.attributes.Options:write(pir_device, 3, 1) + }) + configure_host(pir_device, nil) + + local OCC_ILUM_SUBSCRIBE_LIST = { + cluster_base.subscribe(pir_device, nil, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil), + cluster_base.subscribe(pir_device, nil, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) + } + local subscribe_request = OCC_ILUM_SUBSCRIBE_LIST[1] + for i, clus in ipairs(OCC_ILUM_SUBSCRIBE_LIST) do + if i > 1 then + subscribe_request:merge(clus) + end + end + test.socket.matter:__expect_send({ + pir_device.id, + subscribe_request + }) + test.wait_for_events() + + -- Test 1: Dimmer endpoint (3) detected with OnOff and LevelControl + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub_pir, 0, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + data_types.Uint16(5), + })) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub_pir, 3) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub_pir, 4) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub_pir, 5) + }) + + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub_pir, 4, data_types.Array { + { + device_type = data_types.Uint32(263), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub_pir, 5, data_types.Array { + { + device_type = data_types.Uint32(262), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub_pir, 3, data_types.Array { + { + device_type = data_types.Uint32(0x0101), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 3, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 4, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 5, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) + }) + subhub_pir:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 1", + profile = "light-level", + parent_device_id = subhub_pir.id, + parent_assigned_child_key = "3" + }) + + test.wait_for_events() + + -- Create mock child device for EP3 + local child_dimmer = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:3", subhub_pir.id), + parent_device_id = subhub_pir.id, + parent_assigned_child_key = "3" + }) + test.mock_device.add_test_device(child_dimmer) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub_pir, 4, data_types.Array { + { + device_type = data_types.Uint32(263), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub_pir, 5, data_types.Array { + { + device_type = data_types.Uint32(262), + revision = data_types.Uint16(1) + } + }) + }) + + + -- Test 5: Verify OccupancySensing subscription + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 4, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil) + }) + + -- Test 6: Verify IlluminanceMeasurement subscription + test.socket.matter:__expect_send({ + subhub_pir.id, + cluster_base.subscribe(subhub_pir, 5, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) + }) + + test.wait_for_events() + + ---- Test 7: Verify motion detected event + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(subhub_pir, 4, 1) + }) + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.wait_for_events() + + -- Test 8: Verify illuminance measurement event + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(subhub_pir, 5, 21370) + }) + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(137))) + + test.wait_for_events() + + -- Test 9: Send on command to OnOff endpoint (dimmer) + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.matter:__expect_send({ + subhub_pir.id, + clusters.OnOff.commands.On(subhub_pir, 3) + }) + + -- Test 10: Verify on state via attribute report + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(subhub_pir, 3, true) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) + + -- Test 11: Set dimmer level to 50% + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) + test.socket.matter:__expect_send({ + subhub_pir.id, + clusters.LevelControl.commands.MoveToLevelWithOnOff(subhub_pir, 3, 127, nil, 0, 0) + }) + + -- Test 12: Verify level via attribute report + test.socket.matter:__queue_receive({ + subhub_pir.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(subhub_pir, 3, 127) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(50))) + +end) + +local function create_host_device_with_window(profile_name, parent_subhub) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Host with Window Covering", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent_subhub.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.WindowCovering.types.Feature.LIFT | + clusters.WindowCovering.types.Feature.POSITION_AWARE_LIFT | + clusters.WindowCovering.types.Feature.ABSOLUTE_POSITION, + attributes = { + [clusters.WindowCovering.attributes.OperationalStatus.ID] = 0x00, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = 0x0000, + } + } + }, + device_types = { { device_type_id = 0x0202, device_type_revision = 1 } } + }, + } + }) +end + +-- Button configuration helper for 2-button profile +local function button_2g_configuration(host) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.button.pushed({ state_change = false }))) +end + +test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile with Window Covering Child Device", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local host = create_host_device_with_window("2-button", subhub) + add_host_device(host, subhub) + subscribe_switch_events(host) + + host:expect_metadata_update({ profile = "2-button" }) + configure_subhub(subhub) + + configure_host(host, nil) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + -- + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 8) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 9) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 12) + }) + test.wait_for_events() + + -- DeviceTypeList reports for button endpoints (type 15 = Generic Switch) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- DeviceTypeList report for window covering endpoint (type 514 = Window Covering Device) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 1", + profile = "window-covering", + parent_device_id = subhub.id, + parent_assigned_child_key = "12" + }) + + local child_wc = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:12", subhub.id), + parent_device_id = subhub.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + test.wait_for_events() + + ---- Test 1: Open command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "open", args = {} } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.UpOrOpen(subhub, 12) }) + test.wait_for_events() + + -- Verify open state via attribute report + test.socket.matter:__queue_receive({ + subhub.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(subhub, 12, 0x01) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.opening())) + + test.wait_for_events() + + ---- Test 2: Close command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "close", args = {} } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.DownOrClose(subhub, 12) }) + + -- Verify close state via attribute report + test.socket.matter:__queue_receive({ + subhub.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(subhub, 12, 0x02) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closing())) + + -- Test 3: Pause command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "pause", args = {} } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.StopMotion(subhub, 12) }) + test.wait_for_events() + + -- Test 4: Set shade level to 50% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 } } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.GoToLiftPercentage(subhub, 12, 5000, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + subhub.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(subhub, 12, 5000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) + + + -- Test 5: Set shade level to 100% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 } } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.GoToLiftPercentage(subhub, 12, 0, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + subhub.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(subhub, 12, 0) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.open())) + + -- Test 6: Set shade level to 0% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 0 } } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.GoToLiftPercentage(subhub, 12, 10000, nil, 0, 0) }) + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + subhub.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(subhub, 12, 10000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) + +end) + +test.register_coroutine_test("Test: Window Covering - Preference Changes for Reverse Polarity and Preset Position", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local host = create_host_device_with_window("2-button", subhub) + add_host_device(host, subhub) + subscribe_switch_events(host) + + host:expect_metadata_update({ profile = "2-button" }) + configure_subhub(subhub) + + configure_host(host, nil) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + -- + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 8) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 9) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 12) + }) + test.wait_for_events() + + -- DeviceTypeList reports for button endpoints (type 15 = Generic Switch) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- DeviceTypeList report for window covering endpoint (type 514 = Window Covering Device) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + + subhub:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button Subhub 1", + profile = "window-covering", + parent_device_id = subhub.id, + parent_assigned_child_key = "12" + }) + + local child_wc = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:12", subhub.id), + parent_device_id = subhub.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "false" } })) + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + local reverse_preference_set = child_wc:get_field("__reverse_polarity") + assert(reverse_preference_set == true, "reverse_preference_set is True") + + --Send open command - with reverse_polarity true, this should send DownOrClose + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "open", args = {} } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.DownOrClose(subhub, 12) }) + + -- Send close command - with reverse_polarity true, this should send UpOrOpen + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "close", args = {} } }) + test.socket.matter:__expect_send({ subhub.id, clusters.WindowCovering.commands.UpOrOpen(subhub, 12) }) + + -- Position preset testing + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { presetPosition = "50" } })) + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { presetPosition = "20" } })) + + test.wait_for_events() + + local PRESET_LEVEL_KEY = child_wc:get_field("__preset_level_key") + assert(PRESET_LEVEL_KEY == "20", " __preset_level_key is set to 20") + + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } }) + test.socket.matter:__expect_send( + { subhub.id, clusters.WindowCovering.server.commands.GoToLiftPercentage(subhub, 12, 8000) } + ) +end) + +test.register_coroutine_test("Test: info_changed - Profile Change from 4-button to 2-button Triggers Button Reconfiguration", function() + test.socket.matter:__set_channel_ordering("relaxed") + + local host = create_host_device("4-button", subhub) + add_host_device(host, subhub) + configure_subhub(subhub) + configure_host(host, "4-button") + subscribe_switch_events(host) + button_supported_values(host) + test.wait_for_events() + + local device_info_copy = st_utils.deep_copy(host.raw_st_data) + device_info_copy.profile.id = "4-button" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ host.id, "infoChanged", device_info_json }) + + -- Scenario 1: EP3 (onoff) + EP8, EP9 (buttons) present → 2-button profile + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(subhub, 0, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 3) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 8) + }) + test.socket.matter:__expect_send({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:read(subhub, 9) + }) + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + subhub.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(subhub, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + host:expect_metadata_update({ profile = "2-button" }) + subhub:set_field(BUTTON_EPS, { 8, 9 }, { persist = true }) + + test.wait_for_events() + device_info_copy.profile.id = "2-button" + device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ host.id, "infoChanged", device_info_json }) + + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.mock_time.advance_time(5) + test.socket.capability:__expect_send(host:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(host:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + + -- Expect Switch event subscriptions for button endpoints (8, 9) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 8, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 8, clusters.Switch.ID, nil, clusters.Switch.events.ShortRelease.ID) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 8, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID) + }) + + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 9, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 9, clusters.Switch.ID, nil, clusters.Switch.events.ShortRelease.ID) + }) + test.socket.matter:__expect_send({ + subhub.id, + cluster_base.subscribe(subhub, 9, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID) + }) + +end) + +test.run_registered_tests()