diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 592d62b436..0bba3063a9 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -884,6 +884,26 @@ matterManufacturer: vendorId: 0x1285 productId: 0x0009 deviceProfileName: 4-button-batteryLevel + - id: "4741/5" + deviceLabel: Hager Switch 1G + vendorId: 0x1285 + productId: 0x0005 + deviceProfileName: matter-bridge + - id: "4741/6" + deviceLabel: Hager Switch 2G + vendorId: 0x1285 + productId: 0x0006 + deviceProfileName: matter-bridge + - id: "4741/7" + deviceLabel: Hager PIR 1.1M + vendorId: 0x1285 + productId: 0x0007 + deviceProfileName: matter-bridge + - id: "4741/10" + deviceLabel: Hager PIR 2.2M + vendorId: 0x1285 + productId: 0x000A + deviceProfileName: matter-bridge # HAOJAI - id: "5530/4113" deviceLabel: HAOJAI Smart Switch 3-key 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..dfe052043c --- /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 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 e2a04e103a..3a8f32f345 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -1,4 +1,4 @@ --- Copyright © 2025 SmartThings, Inc. +-- Copyright © 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 local MatterDriver = require "st.matter.driver" @@ -374,6 +374,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_if_possible("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..3ac75a5805 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local device_lib = require "st.device" + local get_product_override_field = require "switch_utils.utils".get_product_override_field + + local checked_device = device.network_type == device_lib.NETWORK_TYPE_MATTER and device or device:get_parent_device() + if get_product_override_field(checked_device, "needs_hager_subdriver") then + return true, require("sub_drivers.hager") + end + return false +end 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..afc2735386 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -0,0 +1,508 @@ +-- 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 buttonCfg = require "switch_utils.device_configuration".ButtonCfg +local create_child = require "switch_utils.device_configuration".ChildCfg.create_or_update_child_devices --new +local switch_utils = require "switch_utils.utils" +local fields = require "switch_utils.fields" + +local ACTIVE_EPS = "__active_EPS" +local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" +local PARENT_ID = "PARENT_ID" +local CURRENT_LIFT = "__current_lift" +local REVERSE_POLARITY = "__reverse_polarity" +local PRESET_LEVEL_KEY = "__preset_level_key" +local BUTTON_EPS = "__button_eps" +local MAIN_WC_EP = "__main_wc_ep" +local MAIN_ONOFF_EP = "FIELD_MAIN_ONOFF_EP" + +local function subscribe (device, endpoint_id, cluster_id, attr_id, event_id) + device:send(cluster_base.subscribe(device, endpoint_id, cluster_id, attr_id, event_id)) +end + +local function get_parent (driver, device) + return driver:get_device_info(device:get_field(PARENT_ID) or nil) +end + +local function get_matter_device(device) + local matter_device_id = device:get_field(MATTER_DEVICE_ID) + if matter_device_id then + local driver = device.driver + if driver then + local matter_device = driver:get_device_info(matter_device_id) + if matter_device then + return matter_device + end + end + end + return nil +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 diff (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 assign_profile_for_endpoint(device_type_id) + if device_type_id == fields.DEVICE_TYPE_ID.LIGHT.ON_OFF then + return "light-binary" + elseif device_type_id == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then + return "light-level" + elseif device_type_id == fields.DEVICE_TYPE_ID.WINDOW_COVERING then + return "window-covering" + end + return "switch-binary", nil +end + +local function create_assign_profile_wrapper(device_type_id) + local profile_name = assign_profile_for_endpoint(device_type_id) + return function(device, ep_id, is_child_device) + return profile_name, nil + end +end + +local function link_matter_device_and_parent(matter_device) + local parent = matter_device:get_parent_device() + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function device_init (driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then + return + end + + device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, { persist = true }) + + local wc_eps = device:get_endpoints(clusters.WindowCovering.ID) + local oc_eps = device:get_endpoints(clusters.OccupancySensing.ID) + local bt_eps = device:get_endpoints(clusters.Switch.ID) + local lvl_eps = device:get_endpoints(clusters.LevelControl.ID) + local product_id = device.manufacturer_info.product_id + + table.sort(wc_eps) + table.sort(oc_eps) + table.sort(lvl_eps) + + if device:get_parent_device() ~= nil then + link_matter_device_and_parent(device) + local parent = device:get_parent_device() + device:extend_device("send", function(self, message) + return parent:send(message) + end) + + local main_onOff_at_join = device:get_field(MAIN_ONOFF_EP) + if main_onOff_at_join and (product_id == 0x0005 or product_id == 0x0006) then + device:set_field(MAIN_ONOFF_EP, 3, { persist = true }) + device.thread:call_with_delay(6, function() + if device:supports_capability(capabilities.switchLevel) then + device:set_field(MAIN_ONOFF_EP, 4, { persist = true }) + end + end) + end + + if #oc_eps > 0 then + device:try_update_metadata({ profile = "motion-illuminance" }) + elseif #wc_eps > 0 and product_id == 0x0005 then + device:try_update_metadata({ profile = "window-covering" }) + device:set_field(MAIN_WC_EP, wc_eps[1]) + elseif #bt_eps == 4 then + device:try_update_metadata({ profile = "4-button" }) + elseif #bt_eps == 2 then + device:try_update_metadata({ profile = "2-button" }) + elseif #lvl_eps > 0 and product_id == 0x0005 then + device:try_update_metadata({ profile = "light-level" }) + end + else + device.thread:call_with_delay(5, function() + subscribe(device, 2, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID) + local matter_device = get_matter_device(device) + device:extend_device("emit_event_for_endpoint", function(self, ep_info, event) + local endpoint_id = type(ep_info) == "number" and ep_info or ep_info.endpoint_id + local child = self:get_child_by_parent_assigned_key(string.format("%d", endpoint_id)) + + if child then + return child:emit_event(event) + end + if matter_device then + return matter_device:emit_event_for_endpoint(ep_info, event) + end + end) + end) + end + device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) +end + +local function handle_descriptor_report(driver, device, ib, response) + if ib.endpoint_id ~= 2 then + return + end + local parent = get_parent(driver, device) + local matter_device = get_matter_device(device) + + if not parent then + return + end + + local new_eps = extract(ib) or {} + table.sort(new_eps) + local removed, added = diff(device, new_eps) + + device:set_field(ACTIVE_EPS, new_eps, { persist = true }) + + local stored_eps = device:get_field(ACTIVE_EPS) + + for _, ep in ipairs(added or {}) do + parent:send(clusters.Descriptor.attributes.DeviceTypeList:read(parent, ep)) + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + local product_id = device.manufacturer_info and device.manufacturer_info.product_id + if product_id == 0x0005 and ep == 3 then + matter_device:try_update_metadata({ profile = "light-binary" }) + elseif product_id == 0x0006 then + if ep == 3 then + local profile = switch_utils.tbl_contains(stored_eps, 4) and "light-binary" or "2-button" + matter_device:try_update_metadata({ profile = profile }) + elseif ep == 4 then + local profile = switch_utils.tbl_contains(stored_eps, 3) and "light-binary" or "2-button" + matter_device:try_update_metadata({ profile = profile }) + end + end + end + end + + for _, ep in ipairs(removed) do + local button_eps = parent: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) + parent:set_field(BUTTON_EPS, clean_eps, { persist = true }) + + local child = parent:get_child_by_parent_assigned_key(tostring(ep)) + if child then + driver:try_delete_device(child.id) + end + + if ep == 3 then + if device.manufacturer_info.product_id == 0x0005 then + matter_device:try_update_metadata({ profile = "2-button" }) + elseif device.manufacturer_info.product_id == 0x0006 then + local has_ep4 = switch_utils.tbl_contains(stored_eps, 4) + matter_device:try_update_metadata({ profile = has_ep4 and "2-button" or "4-button" }) + end + elseif ep == 4 then + if device.manufacturer_info.product_id == 0x0006 then + local button_comb = switch_utils.tbl_contains(stored_eps, 3) + if button_comb then + matter_device:try_update_metadata({ profile = "2-button" }) + create_child(driver, parent, { 3 }, 1, create_assign_profile_wrapper(fields.DEVICE_TYPE_ID.LIGHT.ON_OFF)) + else + matter_device:try_update_metadata({ profile = "4-button" }) + end + end + + end + end +end + +local function handle_set_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local lift_value = device:get_field(PRESET_LEVEL_KEY) or 50 + local hundredths_lift_percent = (100 - tonumber(lift_value)) * 100 + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage(device, endpoint_id, hundredths_lift_percent)) +end + +local function handle_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + endpoint_id = tonumber(endpoint_id) + local req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + if device:get_field(REVERSE_POLARITY) then + req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + end + device:send(req) +end + +local function handle_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + endpoint_id = tonumber(endpoint_id) + local req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + if device:get_field(REVERSE_POLARITY) then + req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + end + device:send(req) +end + +local function handle_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + endpoint_id = tonumber(endpoint_id) + local req = clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id) + device:send(req) +end + +local function handle_shade_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + endpoint_id = tonumber(endpoint_id) + local lift_percentage_value = 100 - cmd.args.shadeLevel + local hundredths_lift_percentage = lift_percentage_value * 100 + local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percentage + ) + device:send(req) +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) + device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) + if attribute == capabilities.windowShadeLevel.shadeLevel then + device:set_field(CURRENT_LIFT, position) + end + local lift_position = device:get_field(CURRENT_LIFT) + if lift_position == 100 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) + elseif lift_position > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) + elseif lift_position == 0 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) + 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 + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closing() or windowShade.opening()) + elseif state == 2 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) + elseif state ~= 0 then + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) + end +end + +local function occupancy_measured_value_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and + capabilities.motionSensor.motion.active() or + capabilities.motionSensor.motion.inactive()) + end +end + +local function info_changed(driver, device, event, args) + local parent = get_parent(driver, device) + local map = {} + if device.network_type == device_lib.NETWORK_TYPE_MATTER and device.profile.id ~= args.old_st_store.profile.id then + device.thread:call_with_delay(5, function() + if device:supports_capability(capabilities.button) then + local button_eps = parent:get_field(BUTTON_EPS) + local clean_eps = {} + for _, v in ipairs(button_eps or {}) do + table.insert(clean_eps, v) + end + buttonCfg.update_button_component_map(device, clean_eps[1], clean_eps) + for _, ep in ipairs(clean_eps) do + subscribe(parent, ep, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID) + subscribe(parent, ep, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID) + device:emit_event_for_endpoint(ep, capabilities.button.supportedButtonValues({ "pushed", "double", "held" })) + end + return + elseif device:supports_capability(capabilities.switch) then + map = {main = 3} + parent:send(clusters.OnOff.attributes.OnOff:read(parent)) + if device:supports_capability(capabilities.switchLevel) then + map = {main = 4} + parent:send(clusters.LevelControl.attributes.CurrentLevel:read(parent)) + end + elseif device:supports_capability(capabilities.motionSensor) then + parent:send(clusters.OccupancySensing.attributes.Occupancy:read(parent)) + parent:send(clusters.IlluminanceMeasurement.attributes.MeasuredValue:read(parent)) + elseif device:supports_capability(capabilities.windowShadeLevel) then + map = {main = 5} + parent:send(clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:read(parent)) + end + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map) + parent:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map) + 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 + device:set_field(PRESET_LEVEL_KEY, new_preset_value, { persist = true }) + end +end + +local function device_type_handler (driver, device, ib) + local matter_device = get_matter_device(device) + local parent = get_parent(driver, device) + local stored = parent:get_field(BUTTON_EPS) or {} + local button_eps = {} + local ep = ib.endpoint_id + local value = ib.data.elements + + for _, v in ipairs(stored) do + table.insert(button_eps, v) + 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 == fields.DEVICE_TYPE_ID.GENERIC_SWITCH then + if not switch_utils.tbl_contains(button_eps, ep) then + switch_utils.set_field_for_endpoint(parent, fields.SUPPORTS_MULTI_PRESS, ep) + switch_utils.set_field_for_endpoint(parent, fields.IGNORE_NEXT_MPC, ep) + table.insert(button_eps, ep) + table.sort(button_eps) + parent:set_field(BUTTON_EPS, button_eps, { persist = true }) + end + end + + if device_type_id == fields.DEVICE_TYPE_ID.LIGHT.ON_OFF then + local active_eps = device:get_field(ACTIVE_EPS) + device.thread:call_with_delay(6, function() + subscribe(parent, ep, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID) + end) + + if ep == 3 and device.manufacturer_info.product_id == 0x0005 then + return + elseif ep == 4 and device.manufacturer_info.product_id == 0x0006 then + if switch_utils.tbl_contains(active_eps, 3) then + local ep3 = parent:get_child_by_parent_assigned_key("3") or nil + if ep3 then + driver:try_delete_device(ep3.id) + end + else + create_child(driver, parent, {4}, 1, create_assign_profile_wrapper(device_type_id)) + return + end + elseif ep == 3 and device.manufacturer_info.product_id == 0x0006 then + if not switch_utils.tbl_contains(active_eps, 4) then + create_child(driver, parent, {3}, 1, create_assign_profile_wrapper(device_type_id)) + end + return + end + create_child(driver, parent, { ep }, 1, create_assign_profile_wrapper(device_type_id)) + elseif device_type_id == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then + subscribe(parent, ep, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID) + subscribe(parent, ep, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID) + subscribe(parent, ep, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID) + subscribe(parent, ep, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID) + if ep == 4 and device.manufacturer_info.product_id == 0x0005 then + return + end + create_child(driver, parent, { ep }, 1, create_assign_profile_wrapper(device_type_id)) + elseif device_type_id == fields.DEVICE_TYPE_ID.WINDOW_COVERING then + subscribe(parent, ep, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID) + subscribe(parent, ep, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID) + if matter_device:get_field(MAIN_WC_EP) == nil then + create_child(driver, parent, { ep }, 1, create_assign_profile_wrapper(device_type_id)) + end + elseif device_type_id == fields.DEVICE_TYPE_ID.MOTION_SENSOR then + subscribe(parent, ep, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID) + elseif device_type_id == fields.DEVICE_TYPE_ID.ILLUMINATION_SENSOR then + subscribe(parent, ep, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID) + end + end +end + +local function do_configure (driver, device) end +local function added (driver, device) end +local function driver_switched(driver, device) end + +local Hager_switch = { + NAME = "Hager matter switch handler", + lifecycle_handlers = { + added = added, + init = device_init, + infoChanged = info_changed, + doConfigure = do_configure, + driverSwitched = driver_switched + }, + matter_handlers = { + attr = { + [clusters.Descriptor.ID] = { + [clusters.Descriptor.attributes.PartsList.ID] = handle_descriptor_report, + [clusters.Descriptor.attributes.DeviceTypeList.ID] = device_type_handler + }, + [clusters.OccupancySensing.ID] = { + [clusters.OccupancySensing.attributes.Occupancy.ID] = occupancy_measured_value_handler, + }, + [clusters.WindowCovering.ID] = { + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler(capabilities.windowShadeLevel.shadeLevel), + [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, + }, + }, + }, + capability_handlers = { + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = handle_set_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, + }, + }, + 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 f757a41871..24ba24bb19 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -156,7 +156,6 @@ function ButtonDeviceConfiguration.update_button_component_map(device, default_e device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) end - function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) local msr_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE}) local msl_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index f70a7e2160..9f3b1c3140 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -34,7 +34,9 @@ SwitchFields.DEVICE_TYPE_ID = { ELECTRICAL_SENSOR = 0x0510, FAN = 0x002B, GENERIC_SWITCH = 0x000F, + ILLUMINATION_SENSOR = 0x0106, IRRIGATION_SYSTEM = 0x0040, + MOTION_SENSOR = 0x0107, MOUNTED_ON_OFF_CONTROL = 0x010F, MOUNTED_DIMMABLE_LOAD_CONTROL = 0x0110, ON_OFF_PLUG_IN_UNIT = 0x010A, @@ -50,6 +52,7 @@ SwitchFields.DEVICE_TYPE_ID = { COLOR_DIMMER = 0x0105, }, WATER_VALVE = 0x0042, + WINDOW_COVERING = 0x0202 } SwitchFields.device_type_profile_map = { @@ -116,6 +119,12 @@ SwitchFields.vendor_overrides = { [0x1189] = { -- LEDVANCE_MANUFACTURER_ID [0x0891] = { target_profile = "switch-binary", initial_profile = "light-binary" }, }, + [0x1285] = { -- HAGER_MANUFACTURER_ID + [0x0005] = { needs_hager_subdriver = true }, -- Hager WAASYS 1g switch + [0x0006] = { needs_hager_subdriver = true }, -- Hager WAASYS 2g switch + [0x0007] = { needs_hager_subdriver = true }, -- Hager WAASYS PIR 1.1M + [0x000A] = { needs_hager_subdriver = true }, -- Hager WAASYS PIR 2.2M + }, [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/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua new file mode 100644 index 0000000000..8426e1aede --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -0,0 +1,1848 @@ +-- Copyright © 2026 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 MATTER_DEVICE_ID = "MATTER_DEVICE_ID" +local PARENT_ID = "PARENT_ID" +local BUTTON_EPS = "__button_eps" + + +local function create_parent_device(product_id) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 4x Button", + 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 create_matter_device(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 4x Button", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.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) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 2G Relay", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.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) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.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 } } + }, + } + }) +end + +local function create_hager_dimmer_device_2g(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.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 create_matter_device_with_window(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 matter device with Window Covering", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.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 + +local function create_hager_pir_device(profile_name, parent) + 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"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0007, + }, + parent_device_id = parent.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 + +local function add_parent_device(parent) + test.mock_device.add_test_device(parent) + test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) + test.mock_time.advance_time(5) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 2, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + +local function subscribe_switch_events(parent, button_eps) + for _, ep in ipairs(button_eps) do + test.socket.matter:__expect_send({ + parent.id, + clusters.Switch.events.MultiPressComplete:subscribe(parent, ep) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Switch.events.LongPress:subscribe(parent, ep) + }) + end +end + +local function add_matter_device(matter_device, parent) + test.mock_device.add_test_device(matter_device) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "init" }) + + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function button_supported_values (matter_device) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) +end + +local function configure_parent(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function configure_matter_device(matter_device, expected_profile_change) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "doConfigure" }) + matter_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +-- Global parent device for tests +local parent = create_parent_device(0x0006) -- 2G button product +local parent_1g = create_parent_device(0x0005) -- 1G button product +local parent_pir = create_parent_device(0x0007) -- PIR product + +local function test_init() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") +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") + add_parent_device(parent) + local matter_device = create_matter_device("matter-bridge", parent) + + -- Initialize HOST device + add_matter_device(matter_device, parent) + + matter_device:expect_metadata_update({ profile = "4-button" }) + + test.wait_for_events() + + local matter_device_parent_id = matter_device:get_field(PARENT_ID) + local matter_device_id = matter_device:get_field(MATTER_DEVICE_ID) + local parent_id = parent:get_field(PARENT_ID) + local parent_matter_device_id = parent:get_field(MATTER_DEVICE_ID) + + assert(matter_device_parent_id == parent.id, "link_host_and_subhub 1/4") + assert(matter_device_id == matter_device.id, "link_host_and_subhub 2/4") + assert(parent_id == parent.id, "link_host_and_subhub 3/4") + assert(parent_matter_device_id == matter_device.id, "link_host_and_subhub 4/4") + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x08) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x09) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x0A) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x0B) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 10, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 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") + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + add_parent_device(parent) + local matter_device = create_matter_device("4-button", parent) + + add_matter_device(matter_device, parent) + matter_device:expect_metadata_update({ profile = "4-button" }) + + -- Configure both devices + configure_parent(parent) + configure_matter_device(matter_device, "4-button") + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x08) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x09) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x0A) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 0x0B) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 10, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 11, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.wait_for_events() + local button_eps = parent:get_field(BUTTON_EPS) + assert(#button_eps ~= 4, "Expected 4 button endpoints") + assert(button_eps[1] == 8, "Expected button endpoint 1 to be 8") + assert(button_eps[2] == 9, "Expected button endpoint 2 to be 9") + assert(button_eps[3] == 10, "Expected button endpoint 3 to be 10") + assert(button_eps[4] == 11, "Expected button endpoint 4 to be 11") + + test.socket.device_lifecycle:__queue_receive(matter_device:generate_info_changed({ profile = { id = "matter-bridge" } })) + test.socket.device_lifecycle:__queue_receive(matter_device:generate_info_changed({ profile = { id = "4-button" } })) + + test.mock_time.advance_time(3) + subscribe_switch_events(parent, button_eps) + button_supported_values(matter_device) + + + -- Test single press (pushed) on endpoint 8 (button1) + test.wait_for_events() + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 -- Single press + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) + -- + --Test double press on endpoint 8 (button1) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 9 (button2) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 10 (button3) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 11 (button4) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.held({ state_change = true }))) + +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 matter_device = create_matter_device("4-button", parent) + add_parent_device(parent) + + add_matter_device(matter_device, parent) + matter_device:expect_metadata_update({ profile = "4-button" }) + + -- Configure both devices + configure_parent(parent) + configure_matter_device(matter_device, "4-button") + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(9), + data_types.Uint16(8), + data_types.Uint16(6), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 6) + }) + + test.wait_for_events() + + -- Receive DeviceTypeList report with device type 15 (button) for endpoint 8 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- Receive DeviceTypeList report with device type 256 (OnOff) for endpoint 6 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 6, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "6" + }) + +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") + add_parent_device(parent) + + local relay = create_hager_2g_relay("matter-bridge", parent) + add_matter_device(relay, parent) + configure_parent(parent) + configure_matter_device(relay, "light-binary") + relay:expect_metadata_update({ profile = "light-binary" }) + test.wait_for_events() + -- Scenario 1: EP3 + EP4 present → light-binary profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 3) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 4) + }) + relay:expect_metadata_update({ profile = "light-binary" }) + test.wait_for_events() + ---- Both endpoints are device type 256 (OnOff) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.attributes.OnOff:subscribe(parent, 3) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + + --Scenario 2: EP4 removed → profile changes to 2-button, child created for EP3 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 8) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + + relay:expect_metadata_update({ profile = "2-button" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + + -- Scenario 3: EP4 reappears → profile changes back to light-binary + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 4) + }) + relay:expect_metadata_update({ profile = "light-binary" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + + -- Scenario 4: EP3 removed → profile changes to 2-button, child created for EP4 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(4), + data_types.Uint16(12), + data_types.Uint16(13), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 12) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 13) + }) + relay:expect_metadata_update({ profile = "2-button" }) + + -- Scenario 5: EP3 and EP4 removed → 4-button profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 10) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 11) + }) + relay:expect_metadata_update({ profile = "4-button" }) + + -- Scenario 6: Only EP4 present → 2-button profile, child created + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(4), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 4) + }) + relay:expect_metadata_update({ profile = "2-button" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) +end) + +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", parent) + add_parent_device(parent) + + add_matter_device(dimmer, parent) + dimmer:expect_metadata_update({ profile = "2-button" }) + configure_parent(parent) + configure_matter_device(dimmer, "2-button") + test.wait_for_events() + + -- Scenario 1: 2 button endpoints (8, 9) detected → 2-button profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(3) + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 3) + }) + dimmer:expect_metadata_update({ profile = "2-button" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 3, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 3, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-level", + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + + -- 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", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } })-- + + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 20 } } }) + +end) + +test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Update to light-level", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_1g) + + local dimmer = create_hager_dimmer_device_1g("matter-bridge", parent_1g) + add_matter_device(dimmer, parent_1g) + dimmer:expect_metadata_update({ profile = "light-level" }) + configure_parent(parent_1g) + configure_matter_device(dimmer, "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(3, "oneshot") + add_parent_device(parent_1g) + local dimmer = create_hager_dimmer_device_1g("light-level", parent_1g) + add_matter_device(dimmer, parent_1g) + dimmer:expect_metadata_update({ profile = "light-level" }) + configure_parent(parent_1g) + configure_matter_device(dimmer, "light-level") + test.wait_for_events() + test.wait_for_events() + + -- Send dimmable endpoint 4 detection (device type 257) to trigger profile change and subscriptions + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(4), + })) + }) + + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent_1g, 4) + }) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_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({ + parent_1g.id, + cluster_base.subscribe(parent_1g, 4, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, 4, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, 4, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, 4, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + test.socket.device_lifecycle:__queue_receive(dimmer:generate_info_changed({ profile = { id = "matter-bridge" } })) + test.socket.device_lifecycle:__queue_receive(dimmer:generate_info_changed({ profile = { id = "light-level" } })) + + -- Trigger 3-second delay in device_init to change FIELD_MAIN_ONOFF_EP from 3 to 4 + test.mock_time.advance_time(3) + test.socket.matter:__expect_send({ parent_1g.id, clusters.OnOff.attributes.OnOff:read(parent_1g) }) + test.socket.matter:__expect_send({ parent_1g.id, clusters.LevelControl.attributes.CurrentLevel:read(parent_1g) }) + +end) + +test.register_coroutine_test("Test: PIR Device - Initialization with Motion and Illuminance Capabilities", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_pir) + local pir_device = create_hager_pir_device("matter-bridge", parent_pir) + + add_matter_device(pir_device, parent_pir) + pir_device:expect_metadata_update({ profile = "motion-illuminance" }) + configure_parent(parent_pir) + configure_matter_device(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") + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + add_parent_device(parent_pir) + local pir_device = create_hager_pir_device("motion-illuminance", parent_pir) + add_matter_device(pir_device, parent_pir) + pir_device:expect_metadata_update({ profile = "motion-illuminance" }) + + test.wait_for_events() + configure_parent(parent_pir) + configure_matter_device(pir_device, nil) + test.mock_time.advance_time(5) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_pir, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + data_types.Uint16(5) + + })) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent_pir, 3) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent_pir, 4) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent_pir, 5) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 4, data_types.Array { + { + device_type = data_types.Uint32(263), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 5, data_types.Array { + { + device_type = data_types.Uint32(262), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 3, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, 4, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, 5, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, 3, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, 3, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + test.socket.device_lifecycle:__queue_receive(pir_device:generate_info_changed({ profile = { id = "matter-bridge" } })) + test.socket.device_lifecycle:__queue_receive(pir_device:generate_info_changed({ profile = { id = "motion-illuminance" } })) + + test.mock_time.advance_time(3) + + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.OccupancySensing.attributes.Occupancy:read(parent_pir) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.OccupancySensing.attributes.Occupancy:read(parent_pir) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:read(parent_pir) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:read(parent_pir) + }) + + parent_pir:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-level", + parent_device_id = parent_pir.id, + parent_assigned_child_key = "3" + }) + local child_dimmer = test.mock_device.build_test_child_device({ + label = "Hager G2 4x Button 1", + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:3", parent_pir.id), + parent_device_id = parent_pir.id, + parent_assigned_child_key = "3" + + }) + test.mock_device.add_test_device(child_dimmer) + test.wait_for_events() + -- Verify motion detected event + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(parent_pir, 4, 1) + }) + + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + + -- Verify illuminance measurement event + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(parent_pir, 5, 21370) + }) + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(137))) + + -- Send on command to OnOff endpoint (dimmer) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switch", "on") + + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.OnOff.commands.On(parent_pir, 3) + }) + -- Verify on state via attribute report + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(parent_pir, 3, true) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) + parent_pir.expect_native_attr_handler_registration(parent_pir, "switch", "switch") + + -- 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({ + parent_pir.id, + clusters.LevelControl.commands.MoveToLevelWithOnOff(parent_pir, 3, 127, nil, 0, 0) + }) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switchLevel", "setLevel") + + -- Verify level via attribute report + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(parent_pir, 3, 127) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(50))) + parent_pir.expect_native_attr_handler_registration(parent_pir, "switchLevel", "level") + +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") + add_parent_device(parent) + test.wait_for_events() + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent) + window_covering_device:expect_metadata_update({ profile = "2-button" }) + configure_parent(parent) + configure_matter_device(window_covering_device, "2-button") + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 12) + }) + + -- DeviceTypeList report for window covering endpoint (type 514 = Window Covering Device) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + -- + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = parent.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", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + test.wait_for_events() + + -- Open command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "open", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.UpOrOpen(parent, 12) }) + test.wait_for_events() + + -- Verify open state via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x01) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.opening())) + + + -- Close command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "close", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.DownOrClose(parent, 12) }) + + -- Verify close state via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x02) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closing())) + + -- Pause command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "pause", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.StopMotion(parent, 12) }) + test.wait_for_events() + + -- 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({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 5000, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 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())) + + + -- 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({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 0, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 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())) + + -- 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({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 10000, nil, 0, 0) }) + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 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") + add_parent_device(parent) + test.wait_for_events() + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent) + window_covering_device:expect_metadata_update({ profile = "2-button" }) + configure_parent(parent) + configure_matter_device(window_covering_device, "2-button") + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + -- + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 12) + }) + test.wait_for_events() + + -- DeviceTypeList reports for button endpoints (type 15 = Generic Switch) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 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({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = parent.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", parent.id), + parent_device_id = parent.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({ parent.id, clusters.WindowCovering.commands.DownOrClose(parent, 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({ parent.id, clusters.WindowCovering.commands.UpOrOpen(parent, 12) }) + + -- Send Pause command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "pause", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.StopMotion(parent, 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( + { parent.id, clusters.WindowCovering.server.commands.GoToLiftPercentage(parent, 12, 8000) } + ) + + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { presetPosition = "20" } })) + + test.wait_for_events() + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + test.wait_for_events() + + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.mock_time.advance_time(5) + local BUTTON_EPS_FIELD = parent:get_field(BUTTON_EPS) + assert(BUTTON_EPS_FIELD[1] == 8) + assert(BUTTON_EPS_FIELD[2] == 9) + + test.wait_for_events() + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 12) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 12, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + child_wc:expect_metadata_update({ profile = "window-covering" }) +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") + add_parent_device(parent) + test.wait_for_events() + local matter_device = create_matter_device("4-button", parent) + add_matter_device(matter_device, parent) + matter_device:expect_metadata_update({ profile = "4-button" }) + configure_parent(parent) + configure_matter_device(matter_device, "4-button") + + test.wait_for_events() + + local device_info_copy = st_utils.deep_copy(matter_device.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({ matter_device.id, "infoChanged", device_info_json }) + + -- Scenario 1: EP3 (onoff) + EP8, EP9 (buttons) present → 2-button profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 3) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:read(parent, 9) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + matter_device:expect_metadata_update({ profile = "2-button" }) + parent: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({ matter_device.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(matter_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + + -- Expect Switch event subscriptions for button endpoints (8, 9) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 8, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 8, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 9, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 9, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID) + }) +end) + +test.run_registered_tests()