From 5a8dcc3c6e993fc223bd579e215c3e00b0a0d511 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 11:25:09 -0600 Subject: [PATCH 01/17] Matter Switch: Improve battery profiling This implements a few changes to the way that devices supporting batteries are profiled: * subscribe to PowerSource.AttributeList rather than reading this attribute, to help prevent failed reads from causing issues with device profiling * update the profile within match_profile rather than power_source_attribute_list_handler * use existing structure for handling waiting for profiling data before attempting a profile update --- .../SmartThings/matter-switch/src/init.lua | 14 +- .../switch_handlers/attribute_handlers.lua | 48 +- .../src/switch_utils/device_configuration.lua | 21 +- .../matter-switch/src/switch_utils/fields.lua | 9 +- .../test/test_aqara_climate_sensor_w100.lua | 17 +- .../src/test/test_aqara_cube.lua | 1 + .../src/test/test_matter_button.lua | 554 +++++++------ .../src/test/test_matter_multi_button.lua | 744 ++++++++---------- 8 files changed, 689 insertions(+), 719 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 6655f81e56..6b6d408e56 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -41,6 +41,16 @@ function SwitchLifecycleHandlers.device_added(driver, device) switch_utils.handle_electrical_sensor_info(device) end + -- For devices supporting BATTERY, add the PowerSource AttributeList to the list of subscribed + -- attributes in order to determine whether to use the battery or batteryLevel capability. Note + -- that this is only needed one time, since after the profile is updated the subscription will + -- be added if a battery capability is present. + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 then + device:add_subscribed_attribute(clusters.PowerSource.attributes.AttributeList) + else + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + end + -- call device init in case init is not called after added due to device caching SwitchLifecycleHandlers.device_init(driver, device) end @@ -94,7 +104,7 @@ function SwitchLifecycleHandlers.device_init(driver, device) device:extend_device("subscribe", switch_utils.subscribe) device:subscribe() - -- device energy reporting must be handled cumulatively, periodically, or by both simulatanously. + -- device energy reporting must be handled cumulatively, periodically, or by both simultaneously. -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. if #embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID, {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY}) > 0 then @@ -194,9 +204,11 @@ local matter_driver_template = { }, subscribed_attributes = { [capabilities.battery.ID] = { + clusters.PowerSource.attributes.AttributeList, clusters.PowerSource.attributes.BatPercentRemaining, }, [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.AttributeList, clusters.PowerSource.attributes.BatChargeLevel, }, [capabilities.colorControl.ID] = { diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index e8dfe3f120..61b62b285e 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -319,10 +319,10 @@ function AttributeHandlers.available_endpoints_handler(driver, device, ib, respo local set_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) for i, set_ep_info in pairs(set_topology_eps or {}) do if ib.endpoint_id == set_ep_info.endpoint_id then - -- since EP reponse is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table + -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table switch_utils.remove_field_index(device, fields.ELECTRICAL_SENSOR_EPS, i) local available_endpoints_ids = {} - for _, element in pairs(ib.data.elements) do + for _, element in pairs(ib.data.elements or {}) do table.insert(available_endpoints_ids, element.value) end -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. @@ -344,10 +344,10 @@ function AttributeHandlers.parts_list_handler(driver, device, ib, response) local tree_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) for i, tree_ep_info in pairs(tree_topology_eps or {}) do if ib.endpoint_id == tree_ep_info.endpoint_id then - -- since EP reponse is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table + -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table switch_utils.remove_field_index(device, fields.ELECTRICAL_SENSOR_EPS, i) local associated_endpoints_ids = {} - for _, element in pairs(ib.data.elements) do + for _, element in pairs(ib.data.elements or {}) do table.insert(associated_endpoints_ids, element.value) end -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. @@ -382,30 +382,32 @@ function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response end function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) - local profile_name = "" - - local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + local previous_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY for _, attr in ipairs(ib.data.elements) do - -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or - -- BatChargeLevel (Attribute ID 0x0E) is present. - if attr.value == 0x0C then - profile_name = "button-battery" - break - elseif attr.value == 0x0E then - profile_name = "button-batteryLevel" + if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist=true}) break + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY + if battery_support ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist=true}) + end end end - if profile_name ~= "" then - if #button_eps > 1 then - profile_name = string.format("%d-", #button_eps) .. profile_name - end - - if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then - profile_name = profile_name .. "-temperature-humidity" - end - device:try_update_metadata({ profile = profile_name }) + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + if #momentary_switch_ep_ids > 0 and switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then + local default_endpoint_id = switch_utils.find_default_endpoint(device) + local button_cfg = require("switch_utils.device_configuration").ButtonCfg + button_cfg.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) + button_cfg.configure_buttons(device, momentary_switch_ep_ids) + device:try_update_metadata({ profile = "3-button-battery-temperature-humidity" }, true) + return + end + device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY, {persist=true}) + if (device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY) == previous_battery_support then + return end + device_cfg.match_profile(driver, device) end 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 7ee4b6bf64..12be23cd8c 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -123,12 +123,13 @@ function ButtonDeviceConfiguration.update_button_profile(device, default_endpoin if #motion_eps > 0 and (num_button_eps == 3 or num_button_eps == 6) then -- only these two devices are handled profile_name = profile_name .. "-motion" end - local battery_supported = #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 - if battery_supported then -- battery profiles are configured later, in power_source_attribute_list_handler - device:send(clusters.PowerSource.attributes.AttributeList:read(device)) - else - device:try_update_metadata({profile = profile_name}) + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY + if battery_support == fields.battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + elseif battery_support == fields.battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" end + device:try_update_metadata({profile = profile_name}) end function ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_eps) @@ -238,12 +239,12 @@ function DeviceConfiguration.match_profile(driver, device) end -- initialize the main device card with buttons if applicable - local momemtary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momemtary_switch_ep_ids) then - ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momemtary_switch_ep_ids) + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then + ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) -- All button endpoints found will be added as additional components in the profile containing the default_endpoint_id. - ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momemtary_switch_ep_ids) - ButtonDeviceConfiguration.configure_buttons(device, momemtary_switch_ep_ids) + ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) + ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) return end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index f670b154c8..bf391d5070 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -135,13 +135,20 @@ SwitchFields.switch_category_vendor_overrides = { SwitchFields.ELECTRICAL_SENSOR_EPS = "__electrical_sensor_eps" --- used in tandem with an EP ID. Stores the required electrical tags "-power", "-energy-powerConsumption", etc. ---- for an Electrical Sensor EP with a "primary" endpoint, used during device profling. +--- for an Electrical Sensor EP with a "primary" endpoint, used during device profiling. SwitchFields.ELECTRICAL_TAGS = "__electrical_tags" SwitchFields.MODULAR_PROFILE_UPDATED = "__modular_profile_updated" SwitchFields.profiling_data = { POWER_TOPOLOGY = "__power_topology", + BATTERY_SUPPORT = "__battery_support", +} + +SwitchFields.battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE", } SwitchFields.ENERGY_METER_OFFSET = "__energy_meter_offset" diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua index 6371c6be44..46b7cf0c55 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua @@ -113,6 +113,7 @@ local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(aqara_mock_device) local cluster_subscribe_list = { + clusters.PowerSource.server.attributes.AttributeList, clusters.PowerSource.server.attributes.BatPercentRemaining, clusters.TemperatureMeasurement.attributes.MeasuredValue, clusters.TemperatureMeasurement.attributes.MinMeasuredValue, @@ -138,23 +139,16 @@ local function test_init() test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "doConfigure" }) - local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() - test.socket.matter:__expect_send({aqara_mock_device.id, read_attribute_list}) - configure_buttons() aqara_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) - device_info_copy.profile.id = "3-button-battery-temperature-humidity" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) - configure_buttons() end test.set_test_init_function(test_init) local function update_profile() - test.socket.matter:__queue_receive({aqara_mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data(aqara_mock_device, 6, {uint32(0x0C)})}) + test.socket.matter:__queue_receive({aqara_mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + aqara_mock_device, 6, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + configure_buttons() aqara_mock_device:expect_metadata_update({ profile = "3-button-battery-temperature-humidity" }) end @@ -466,4 +460,3 @@ test.register_coroutine_test( ) test.run_registered_tests() - diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua index 3c377255ab..5d5c0dbb3a 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua @@ -141,6 +141,7 @@ local mock_device_exhausted = test.mock_device.build_test_matter_device( -- add device for each mock device local CLUSTER_SUBSCRIBE_LIST ={ + clusters.PowerSource.server.attributes.AttributeList, clusters.PowerSource.server.attributes.BatPercentRemaining, clusters.Switch.server.events.InitialPress, clusters.Switch.server.events.LongPress, diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua index 58ba831074..e23fbe3dc8 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua @@ -4,14 +4,12 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" - local clusters = require "st.matter.clusters" local button_attr = capabilities.button.button local utils = require "st.utils" local dkjson = require "dkjson" local uint32 = require "st.matter.data_types.Uint32" ---mock the actual device local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("button-battery.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, @@ -47,8 +45,8 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) --- add device for each mock device -local CLUSTER_SUBSCRIBE_LIST ={ +local CLUSTER_SUBSCRIBE_LIST = { + clusters.PowerSource.server.attributes.AttributeList, clusters.PowerSource.server.attributes.BatPercentRemaining, clusters.Switch.server.events.InitialPress, clusters.Switch.server.events.LongPress, @@ -56,18 +54,27 @@ local CLUSTER_SUBSCRIBE_LIST ={ clusters.Switch.server.events.MultiPressComplete, } -local function configure_buttons() +local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) +for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end +end + +local function expect_configure_buttons() test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) end +local function update_profile() + test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device, 1, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + expect_configure_buttons() + mock_device:expect_metadata_update({ profile = "button-battery" }) +end + local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -76,339 +83,330 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() - test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) - configure_buttons() - - local device_info_copy = utils.deep_copy(mock_device.raw_st_data) - device_info_copy.profile.id = "buttons-battery" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) - configure_buttons() - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end test.set_test_init_function(test_init) -test.register_message_test( - "Handle single press sequence, no hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} --move to position 1? - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press - } -} +test.register_coroutine_test( + "Simulate the profile change update taking affect and the device info changing", + function() + update_profile() + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + device_info_copy.profile.id = "buttons-battery" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) + expect_configure_buttons() + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + end ) -test.register_message_test( - "Handle single press sequence, with hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) - } -} +test.register_coroutine_test( + "Handle single press sequence, no hold", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} --move to position 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + end ) -test.register_message_test( - "Handle release after short press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.ShortRelease:build_test_event_report( - mock_device, 1, {previous_position = 1} - ) - } - }, - { -- this is a double event because the test device in this test shouldn't support the above event +test.register_coroutine_test( + "Handle single press sequence, with hold", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.held({state_change = true})) + ) + end +) + +test.register_coroutine_test( + "Handle release after short press", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 1, {previous_position = 1} + ) + } + ) + -- this is a double event because the test device in this test shouldn't support the above event -- but we handle it anyway - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + end ) -test.register_message_test( - "Handle release after long press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongRelease:build_test_event_report( - mock_device, 1, {previous_position = 1} - ) - } - }, - } +test.register_coroutine_test( + "Handle release after long press", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.held({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 1, {previous_position = 1} + ) + } + ) + end ) -test.register_message_test( - "Receiving a max press attribute of 2 should emit correct event", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Receiving a max press attribute of 2 should emit correct event", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.attributes.MultiPressMax:build_test_report_data( mock_device, 1, 2 ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) - }, - } + ) + end ) -test.register_message_test( - "Receiving a max press attribute of 3 should emit correct event", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Receiving a max press attribute of 3 should emit correct event", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.attributes.MultiPressMax:build_test_report_data( mock_device, 1, 3 ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x"}, {visibility = {displayed = false}})) - }, - } + ) + end ) -test.register_message_test( - "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.attributes.MultiPressMax:build_test_report_data( mock_device, 1, 7 ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) - }, - } + ) + end ) -test.register_message_test( - "Handle double press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event +test.register_coroutine_test( + "Handle double press", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + ) + -- again, on a device that reports that it supports double press, this event -- will not be generated. See a multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 1, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) - }, - -} + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.double({state_change = true})) + ) + end ) -test.register_message_test( - "Handle multi press for 4 times", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1, total_number_of_presses_counted = 1, previous_position = 0} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event +test.register_coroutine_test( + "Handle multi press for 4 times", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 1, previous_position = 0} + ) + } + ) + -- again, on a device that reports that it supports double press, this event -- will not be generated. See the multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 1, {new_position = 1, total_number_of_presses_counted = 4, previous_position = 0} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) - }, - -} + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 4, previous_position = 0} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) + ) + end ) -test.register_message_test( - "Handle received BatPercentRemaining from device.", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Handle received BatPercentRemaining from device.", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( mock_device, 1, 150 - ), - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message( + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) - ), - }, - } + ) + ) + end ) +local function reset_battery_profiling_info() + local fields = require "switch_utils.fields" + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) +end + test.register_coroutine_test( - "Test profile change to button-battery when battery percent remaining attribute (attribute ID 12) is available", + "Test profile does not change to button-battery when battery percent remaining attribute (attribute ID 12) is not available", function() + reset_battery_profiling_info() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(12)}) + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(10)}) } ) - mock_device:expect_metadata_update({ profile = "button-battery" }) end ) test.register_coroutine_test( - "Test profile does not change to button-battery when battery percent remaining attribute (attribute ID 12) is not available", + "Test profile change to button-batteryLevel when battery percent remaining attribute (attribute ID 14) is available", function() + reset_battery_profiling_info() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(10)}) + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32( + clusters.PowerSource.attributes.BatChargeLevel.ID + )}) } ) + expect_configure_buttons() + mock_device:expect_metadata_update({ profile = "button-batteryLevel" }) + end +) + +test.register_coroutine_test( + "Test profile change to button-battery when battery percent remaining attribute (attribute ID 12) is available", + function() + reset_battery_profiling_info() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32( + clusters.PowerSource.attributes.BatPercentRemaining.ID + )}) + } + ) + expect_configure_buttons() + mock_device:expect_metadata_update({ profile = "button-battery" }) end ) --- run the tests test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua index 301fb36cf0..dbbb39ca14 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua @@ -6,16 +6,16 @@ local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local utils = require "st.utils" local dkjson = require "dkjson" - local clusters = require "st.matter.generated.zap_clusters" local button_attr = capabilities.button.button +local uint32 = require "st.matter.data_types.Uint32" -- Mock a 5-button device using endpoints non-consecutive endpoints local mock_device = test.mock_device.build_test_matter_device( { profile = t_utils.get_profile_definition("5-button-battery.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, - matter_version = {hardware = 1, sofrware = 1}, + matter_version = {hardware = 1, software = 1}, endpoints = { { endpoint_id = 0, @@ -96,7 +96,8 @@ local mock_device = test.mock_device.build_test_matter_device( }) -- add device for each mock device -local CLUSTER_SUBSCRIBE_LIST ={ +local CLUSTER_SUBSCRIBE_LIST = { + clusters.PowerSource.server.attributes.AttributeList, clusters.PowerSource.server.attributes.BatPercentRemaining, clusters.Switch.server.events.InitialPress, clusters.Switch.server.events.LongPress, @@ -104,6 +105,11 @@ local CLUSTER_SUBSCRIBE_LIST ={ clusters.Switch.server.events.MultiPressComplete, } +local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) +for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end +end + local function expect_configure_buttons() test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) @@ -121,6 +127,14 @@ local function expect_configure_buttons() test.socket.capability:__expect_send(mock_device:generate_test_message("button5", button_attr.pushed({state_change = false}))) end +local function update_profile() + test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + expect_configure_buttons() + mock_device:expect_metadata_update({ profile = "5-button-battery" }) +end + -- All messages queued and expectations set are done before the driver is actually run local function test_init() -- we dont want the integration test framework to generate init/doConfigure, we are doing that here @@ -129,10 +143,6 @@ local function test_init() test.mock_device.add_test_device(mock_device) -- make sure the cache is populated -- added sets a bunch of fields on the device, and calls init - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) @@ -141,74 +151,72 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) --doConfigure sets the provisioning state to provisioned - test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - expect_configure_buttons() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - - -- simulate the profile change update taking affect and the device info changing - local device_info_copy = utils.deep_copy(mock_device.raw_st_data) - device_info_copy.profile.id = "5-buttons-battery" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - expect_configure_buttons() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.set_test_init_function(test_init) -test.register_message_test( - "Handle single press sequence, no hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} --move to position 1? - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press - } -} +test.register_coroutine_test( + "Simulate the profile change update taking affect and the device info changing", + function() + update_profile() + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + device_info_copy.profile.id = "5-buttons-battery" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + expect_configure_buttons() + end ) -test.register_message_test( - "Handle single press sequence for short release-supported button", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 20, {new_position = 1} --move to position 1? - ), - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.ShortRelease:build_test_event_report( - mock_device, 20, {previous_position = 0} --move to position 1? - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press - } -} +test.register_coroutine_test( + "Handle single press sequence, no hold", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} --move to position 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + end +) + +test.register_coroutine_test( + "Handle single press sequence for short release-supported button", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 20, {new_position = 1} --move to position 1? + ) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 20, {previous_position = 0} --move to position 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) + ) + end ) test.register_coroutine_test( "Handle single press sequence for emulated hold on short-release-only button", function () + update_profile() test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive({ mock_device.id, @@ -231,6 +239,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Handle single press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event function () + update_profile() test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive({ mock_device.id, @@ -253,6 +262,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Handle single press sequence for a long hold on multi button", -- pushes should only be generated from multiPressComplete events function () + update_profile() test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive({ mock_device.id, @@ -274,6 +284,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Handle single press sequence for a multi press on multi button", function () + update_profile() test.socket.matter:__queue_receive({ mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( @@ -311,6 +322,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Handle long press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event function () + update_profile() test.socket.matter:__queue_receive({ mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( @@ -336,6 +348,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Handle long press sequence for a long hold on multi button", function () + update_profile() test.socket.matter:__queue_receive({ mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( @@ -358,397 +371,340 @@ test.register_coroutine_test( end ) -test.register_message_test( - "Handle single press sequence, with hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) - } -} +test.register_coroutine_test( + "Handle single press sequence, with hold", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.held({state_change = true})) + ) + end ) -test.register_message_test( - "Handle release after short press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.ShortRelease:build_test_event_report( - mock_device, 10, {previous_position = 1} - ) - } - }, - { -- this is a double event because the test device in this test shouldn't support the above event +test.register_coroutine_test( + "Handle release after short press", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + ) + -- this is a double event because the test device in this test shouldn't support the above event -- but we handle it anyway - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + end ) -test.register_message_test( - "Handle release after long press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongRelease:build_test_event_report( - mock_device, 10, {previous_position = 1} - ) - } - }, - } +test.register_coroutine_test( + "Handle release after long press", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.held({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + ) + end ) -test.register_message_test( - "Receiving a max press attribute of 2 should emit correct event", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Receiving a max press attribute of 2 should emit correct event", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.attributes.MultiPressMax:build_test_report_data( mock_device, 10, 2 ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) - }, - } + ) + end ) -test.register_message_test( - "Receiving a max press attribute of 3 should emit correct event", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Receiving a max press attribute of 3 should emit correct event", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.attributes.MultiPressMax:build_test_report_data( mock_device, 60, 3 ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button5", + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button5", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) - }, - } + ) + end ) -test.register_message_test( - "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.attributes.MultiPressMax:build_test_report_data( mock_device, 10, 7 ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) - }, - } + ) + end ) -test.register_message_test( - "Handle double press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event +test.register_coroutine_test( + "Handle double press", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + ) + -- again, on a device that reports that it supports double press, this event -- will not be generated. See a multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 10, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) - }, - -} + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.double({state_change = true})) + ) + end ) -test.register_message_test( - "Handle multi press for 4 times", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event +test.register_coroutine_test( + "Handle multi press for 4 times", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + ) + -- again, on a device that reports that it supports double press, this event -- will not be generated. See a multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 10, {new_position = 1, total_number_of_presses_counted = 4, previous_position=0} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) - }, - -} + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 4, previous_position=0} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) + ) + end ) -test.register_message_test( - "Receiving a max press attribute of 2 should emit correct event", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.attributes.MultiPressMax:build_test_report_data( - mock_device, 50, 2 - ) - }, - }, +test.register_coroutine_test( + "Receiving a max press attribute of 2 should emit correct event", + function() + update_profile() + test.socket.matter:__queue_receive( { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button4", - capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) - }, - } + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 50, 2 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button4", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) + ) + end ) -test.register_message_test( - "Handle received BatPercentRemaining from device.", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Handle received BatPercentRemaining from device.", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( mock_device, 10, 150 - ), - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message( + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) - ), - }, - } + ) + ) + end ) -test.register_message_test( - "Handle a long press including MultiPressComplete", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 60, {new_position = 1} - ) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 60, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button5", button_attr.held({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} - ) - } - } - -- no double event -} +test.register_coroutine_test( + "Handle a long press including MultiPressComplete", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button5", button_attr.held({state_change = true})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + ) + } + ) + -- no double event + end ) -test.register_message_test( - "Handle long press followed by single press", { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Handle long press followed by single press", + function() + update_profile() + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 60, {new_position = 1} + mock_device, 60, {new_position = 1} ) } - }, - { - channel = "matter", - direction = "receive", - message = { + ) + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 60, {new_position = 1} + mock_device, 60, {new_position = 1} ) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button5", button_attr.held({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button5", button_attr.held({state_change = true})) + ) + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 60, {new_position = 1} + mock_device, 60, {new_position = 1} ) } - }, - { - channel = "matter", - direction = "receive", - message = { + ) + test.socket.matter:__queue_receive( + { mock_device.id, clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} ) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button5", button_attr.pushed({state_change = true})) - } - } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button5", button_attr.pushed({state_change = true})) + ) + end ) --- run the tests + test.run_registered_tests() From ba61fcc27380918c1f96a35a823ae754ff118f0e Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 12:03:58 -0600 Subject: [PATCH 02/17] fix AttributeList subscription --- drivers/SmartThings/matter-switch/src/init.lua | 12 ------------ .../matter-switch/src/switch_utils/utils.lua | 9 +++++++++ .../matter-switch/src/test/test_aqara_cube.lua | 1 - 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 6b6d408e56..b9e93e23d0 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -41,16 +41,6 @@ function SwitchLifecycleHandlers.device_added(driver, device) switch_utils.handle_electrical_sensor_info(device) end - -- For devices supporting BATTERY, add the PowerSource AttributeList to the list of subscribed - -- attributes in order to determine whether to use the battery or batteryLevel capability. Note - -- that this is only needed one time, since after the profile is updated the subscription will - -- be added if a battery capability is present. - if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 then - device:add_subscribed_attribute(clusters.PowerSource.attributes.AttributeList) - else - device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) - end - -- call device init in case init is not called after added due to device caching SwitchLifecycleHandlers.device_init(driver, device) end @@ -204,11 +194,9 @@ local matter_driver_template = { }, subscribed_attributes = { [capabilities.battery.ID] = { - clusters.PowerSource.attributes.AttributeList, clusters.PowerSource.attributes.BatPercentRemaining, }, [capabilities.batteryLevel.ID] = { - clusters.PowerSource.attributes.AttributeList, clusters.PowerSource.attributes.BatChargeLevel, }, [capabilities.colorControl.ID] = { diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index c6299fba95..7f82a11431 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -487,6 +487,15 @@ function utils.subscribe(device) end end + -- For devices supporting BATTERY, add the PowerSource AttributeList to the list of subscribed + -- attributes in order to determine whether to use the battery or batteryLevel capability + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 then + local ib = im.InteractionInfoBlock(nil, clusters.PowerSource.ID, clusters.PowerSource.attributes.AttributeList.ID) + subscribe_request:with_info_block(ib) + else + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + end + if #subscribe_request.info_blocks > 0 then device:send(subscribe_request) end diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua index 5d5c0dbb3a..3c377255ab 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua @@ -141,7 +141,6 @@ local mock_device_exhausted = test.mock_device.build_test_matter_device( -- add device for each mock device local CLUSTER_SUBSCRIBE_LIST ={ - clusters.PowerSource.server.attributes.AttributeList, clusters.PowerSource.server.attributes.BatPercentRemaining, clusters.Switch.server.events.InitialPress, clusters.Switch.server.events.LongPress, From 5e0a04e92f27f6d8abcd9bafa6b72b89bae30b8c Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 12:16:04 -0600 Subject: [PATCH 03/17] simplify handling for aqara climate sensor --- .../src/switch_handlers/attribute_handlers.lua | 17 ++++------------- .../src/switch_utils/device_configuration.lua | 3 +++ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index 61b62b285e..8fe4b64f9d 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -382,29 +382,20 @@ function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response end function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) - local previous_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY + local previous_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) for _, attr in ipairs(ib.data.elements) do if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist=true}) break elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then - local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY - if battery_support ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected + if device:get_field(fields.profiling_data.BATTERY_SUPPORT) ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist=true}) end end end - local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - if #momentary_switch_ep_ids > 0 and switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then - local default_endpoint_id = switch_utils.find_default_endpoint(device) - local button_cfg = require("switch_utils.device_configuration").ButtonCfg - button_cfg.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) - button_cfg.configure_buttons(device, momentary_switch_ep_ids) - device:try_update_metadata({ profile = "3-button-battery-temperature-humidity" }, true) - return - end device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY, {persist=true}) - if (device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY) == previous_battery_support then + if previous_battery_support and previous_battery_support == device:get_field(fields.profiling_data.BATTERY_SUPPORT) then return end device_cfg.match_profile(driver, device) 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 12be23cd8c..f28f89785f 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -129,6 +129,9 @@ function ButtonDeviceConfiguration.update_button_profile(device, default_endpoin elseif battery_support == fields.battery_support.BATTERY_LEVEL then profile_name = profile_name .. "-batteryLevel" end + if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then + profile_name = "3-button-battery-temperature-humidity" + end device:try_update_metadata({profile = profile_name}) end From 3d003b3590f9d529ac21ea9d898b9707a8a0da0d Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 12:37:06 -0600 Subject: [PATCH 04/17] fix luacheck and test issues --- .../test/test_aqara_climate_sensor_w100.lua | 23 ++++++++----------- .../src/test/test_matter_button.lua | 2 ++ .../src/test/test_matter_multi_button.lua | 2 ++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua index 46b7cf0c55..d47c666166 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua @@ -1,14 +1,11 @@ -- Copyright © 2024 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local test = require "integration_test" -local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" -local utils = require "st.utils" -local dkjson = require "dkjson" -local uint32 = require "st.matter.data_types.Uint32" local clusters = require "st.matter.generated.zap_clusters" -local button_attr = capabilities.button.button +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local uint32 = require "st.matter.data_types.Uint32" -- Mock a 3-button device with temperature and humidity sensor local aqara_mock_device = test.mock_device.build_test_matter_device({ @@ -100,13 +97,13 @@ local aqara_mock_device = test.mock_device.build_test_matter_device({ local function configure_buttons() test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 3)}) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", button_attr.pushed({state_change = false}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", capabilities.button.button.pushed({state_change = false}))) test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 4)}) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", capabilities.button.button.pushed({state_change = false}))) test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 5)}) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", capabilities.button.button.pushed({state_change = false}))) end local function test_init() @@ -309,7 +306,7 @@ test.register_coroutine_test( clusters.Switch.events.MultiPressComplete:build_test_event_report(aqara_mock_device, 4, {new_position = 0, total_number_of_presses_counted = 2, previous_position = 1}) } ) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.double({state_change = true}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", capabilities.button.button.double({state_change = true}))) end ) @@ -329,7 +326,7 @@ test.register_coroutine_test( clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) } ) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", button_attr.held({state_change = true}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", capabilities.button.button.held({state_change = true}))) test.socket.matter:__queue_receive( { aqara_mock_device.id, @@ -355,7 +352,7 @@ test.register_coroutine_test( clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) } ) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.held({state_change = true}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", capabilities.button.button.held({state_change = true}))) test.socket.matter:__queue_receive( { aqara_mock_device.id, @@ -376,7 +373,7 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send( - aqara_mock_device:generate_test_message("button1", button_attr.double({state_change = true})) + aqara_mock_device:generate_test_message("button1", capabilities.button.button.double({state_change = true})) ) end ) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua index e23fbe3dc8..ff5fa6b38d 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua @@ -90,7 +90,9 @@ test.set_test_init_function(test_init) test.register_coroutine_test( "Simulate the profile change update taking affect and the device info changing", function() + test.socket.matter:__set_channel_ordering("relaxed") update_profile() + test.wait_for_events() local device_info_copy = utils.deep_copy(mock_device.raw_st_data) device_info_copy.profile.id = "buttons-battery" local device_info_json = dkjson.encode(device_info_copy) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua index dbbb39ca14..63d9fcc962 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua @@ -159,7 +159,9 @@ test.set_test_init_function(test_init) test.register_coroutine_test( "Simulate the profile change update taking affect and the device info changing", function() + test.socket.matter:__set_channel_ordering("relaxed") update_profile() + test.wait_for_events() local device_info_copy = utils.deep_copy(mock_device.raw_st_data) device_info_copy.profile.id = "5-buttons-battery" local device_info_json = dkjson.encode(device_info_copy) From 4b5122f6a0044c9cbb756528bfc3bd50df710933 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 13:01:08 -0600 Subject: [PATCH 05/17] addressing review feedback --- drivers/SmartThings/matter-switch/src/init.lua | 4 ++++ .../src/switch_handlers/attribute_handlers.lua | 8 +++----- .../SmartThings/matter-switch/src/switch_utils/utils.lua | 2 -- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index b9e93e23d0..cff03bec37 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -41,6 +41,10 @@ function SwitchLifecycleHandlers.device_added(driver, device) switch_utils.handle_electrical_sensor_info(device) end + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) == 0 then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + end + -- call device init in case init is not called after added due to device caching SwitchLifecycleHandlers.device_init(driver, device) end diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index 8fe4b64f9d..a96b06c1ef 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -384,7 +384,7 @@ end function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) local previous_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) - for _, attr in ipairs(ib.data.elements) do + for _, attr in ipairs(ib.data.elements or {}) do if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist=true}) break @@ -394,11 +394,9 @@ function AttributeHandlers.power_source_attribute_list_handler(driver, device, i end end end - device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY, {persist=true}) - if previous_battery_support and previous_battery_support == device:get_field(fields.profiling_data.BATTERY_SUPPORT) then - return + if not previous_battery_support or previous_battery_support ~= device:get_field(fields.profiling_data.BATTERY_SUPPORT) then + device_cfg.match_profile(driver, device) end - device_cfg.match_profile(driver, device) end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 7f82a11431..5918cd435f 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -492,8 +492,6 @@ function utils.subscribe(device) if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 then local ib = im.InteractionInfoBlock(nil, clusters.PowerSource.ID, clusters.PowerSource.attributes.AttributeList.ID) subscribe_request:with_info_block(ib) - else - device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) end if #subscribe_request.info_blocks > 0 then From ac6e8e609f1a08c7f6382c6a633c16cb525a1a6c Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 15:46:32 -0600 Subject: [PATCH 06/17] return profile name for buttons --- .../src/switch_handlers/attribute_handlers.lua | 7 +++---- .../src/switch_utils/device_configuration.lua | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index a96b06c1ef..ae76709be8 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -388,10 +388,9 @@ function AttributeHandlers.power_source_attribute_list_handler(driver, device, i if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist=true}) break - elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then - if device:get_field(fields.profiling_data.BATTERY_SUPPORT) ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected - device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist=true}) - end + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID and + device:get_field(fields.profiling_data.BATTERY_SUPPORT) ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist=true}) end end if not previous_battery_support or previous_battery_support ~= device:get_field(fields.profiling_data.BATTERY_SUPPORT) then 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 f28f89785f..6cb29875ff 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -123,7 +123,7 @@ function ButtonDeviceConfiguration.update_button_profile(device, default_endpoin if #motion_eps > 0 and (num_button_eps == 3 or num_button_eps == 6) then -- only these two devices are handled profile_name = profile_name .. "-motion" end - local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) if battery_support == fields.battery_support.BATTERY_PERCENTAGE then profile_name = profile_name .. "-battery" elseif battery_support == fields.battery_support.BATTERY_LEVEL then @@ -132,7 +132,7 @@ function ButtonDeviceConfiguration.update_button_profile(device, default_endpoin if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then profile_name = "3-button-battery-temperature-humidity" end - device:try_update_metadata({profile = profile_name}) + return profile_name end function ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_eps) @@ -244,11 +244,10 @@ function DeviceConfiguration.match_profile(driver, device) -- initialize the main device card with buttons if applicable local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then - ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) + updated_profile = ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) -- All button endpoints found will be added as additional components in the profile containing the default_endpoint_id. ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) - return end device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) @@ -258,4 +257,4 @@ return { DeviceCfg = DeviceConfiguration, SwitchCfg = SwitchDeviceConfiguration, ButtonCfg = ButtonDeviceConfiguration -} \ No newline at end of file +} From 6d4c9d66a69b51649b9f241d8eaf27640d69f1e1 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Tue, 20 Jan 2026 10:55:42 -0600 Subject: [PATCH 07/17] Migrate Window Covering handling to Matter Switch To support devices using the WindowCovering cluster in addition to light/switch/button endpoints, this moves the handling for window coverings into a subdriver within the switch driver. Note that the subdriver is called `closures`, since it will be expanded to cover more Matter 1.5 closure types. --- .../matter-switch/fingerprints.yml | 249 +++- .../profiles/window-covering-battery.yml | 22 + .../profiles/window-covering-batteryLevel.yml | 22 + .../profiles/window-covering-tilt-battery.yml | 24 + .../window-covering-tilt-only-battery.yml | 22 + .../profiles/window-covering-tilt-only.yml | 20 + .../profiles/window-covering-tilt.yml | 22 + .../profiles/window-covering.yml | 20 + .../SmartThings/matter-switch/src/init.lua | 13 +- .../src/sub_drivers/closures/can_handle.lua | 11 + .../closure_handlers/attribute_handlers.lua | 68 ++ .../closure_handlers/capability_handlers.lua | 70 ++ .../closures/closure_utils/fields.lua | 12 + .../src/sub_drivers/closures/init.lua | 90 ++ .../src/switch_utils/device_configuration.lua | 28 +- .../matter-switch/src/switch_utils/fields.lua | 1 + .../matter-switch/src/switch_utils/utils.lua | 12 +- .../src/test/test_matter_window_covering.lua | 1039 +++++++++++++++++ .../matter-window-covering/fingerprints.yml | 251 ---- 19 files changed, 1739 insertions(+), 257 deletions(-) create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering.yml create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/closures/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/attribute_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/capability_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_utils/fields.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua delete mode 100644 drivers/SmartThings/matter-window-covering/fingerprints.yml diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 32a4df6694..b531722741 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -278,6 +278,36 @@ matterManufacturer: vendorId: 0x130A productId: 0x0043 deviceProfileName: switch-binary + - id: "Eve MotionBlinds" + deviceLabel: Eve MotionBlinds + vendorId: 0x130A + productId: 0x55 + deviceProfileName: window-covering-battery + - id: "4874/109" + deviceLabel: Eve MotionBlinds for Roller Blinds + vendorId: 0x130A + productId: 0x006D + deviceProfileName: window-covering-battery + - id: "4874/98" + deviceLabel: Eve MotionBlinds for Honeycomb Blinds + vendorId: 0x130A + productId: 0x0062 + deviceProfileName: window-covering-battery + - id: "4874/99" + deviceLabel: Eve MotionBlinds for Venetian Blinds + vendorId: 0x130A + productId: 0x0063 + deviceProfileName: window-covering-battery + - id: "4874/100" + deviceLabel: Eve MotionBlinds for Curtains + vendorId: 0x130A + productId: 0x0064 + deviceProfileName: window-covering-battery + - id: "4874/96" + deviceLabel: Eve Shutter Switch + vendorId: 0x130A + productId: 0x0060 + deviceProfileName: window-covering #Ezviz - id: "5172/4096" @@ -754,13 +784,18 @@ matterManufacturer: vendorId: 0x100B productId: 0x0800 deviceProfileName: light-level-colorTemperature +# Griesser + - id: "5435/14337" + deviceLabel: MSM-1 + vendorId: 0x153B + productId: 0x3801 + deviceProfileName: window-covering-tilt # Intecular - id: "5226/32769" deviceLabel: InvisOutlet vendorId: 0x146A productId: 0x8001 deviceProfileName: plug-binary - #Ledvance - id: "4489/843" deviceLabel: Matter Filament RGBW @@ -1266,6 +1301,12 @@ matterManufacturer: vendorId: 0x102E productId: 0x2250 deviceProfileName: button-battery +# Mamaba + - id: "4965/4097" + deviceLabel: Wi-Fi Curtain + vendorId: 0x1365 + productId: 0x1001 + deviceProfileName: window-covering #Meross - id: "4933/40987" deviceLabel: Smart Wi-Fi Switch @@ -1282,6 +1323,11 @@ matterManufacturer: vendorId: 0x1345 productId: 0xB001 deviceProfileName: switch-binary + - id: "4933/61453" + deviceLabel: Smart Wi-Fi Roller Shutter Timer + vendorId: 0x1345 + productId: 0xF00D + deviceProfileName: window-covering #Nanoleaf - id: "Nanoleaf NL53" deviceLabel: Essentials BR30 @@ -1880,6 +1926,20 @@ matterManufacturer: productId: 0x0001 deviceProfileName: switch-binary +# SmartWave + - id: "5376/10001" + deviceLabel: SmartWave Motorized Roller Shades 100 Blackout Flex + vendorId: 0x1500 + productId: 0x2711 + deviceProfileName: window-covering-battery + +# SmartWings + - id: "5231/4097" + deviceLabel: SmartWings Window Covering + vendorId: 0x146F + productId: 0x1001 + deviceProfileName: window-covering-battery + #SONOFF - id: "SONOFF MINIR4M" deviceLabel: Smart Plug-in Unit @@ -1935,6 +1995,112 @@ matterManufacturer: vendorId: 0x147F productId: 0x0002 deviceProfileName: light-color-level-2700K-6500K +#WISTAR + - id: "5207/3" + deviceLabel: WISTAR WSERD16-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0003 + deviceProfileName: window-covering-battery + - id: "5207/4" + deviceLabel: WISTAR WSERD24 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0004 + deviceProfileName: window-covering + - id: "5207/5" + deviceLabel: WISTAR WSERD40-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0005 + deviceProfileName: window-covering-battery + - id: "5207/6" + deviceLabel: WISTAR WSERD40-L Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0006 + deviceProfileName: window-covering-battery + - id: "5207/7" + deviceLabel: WISTAR WSERD40-T Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0007 + deviceProfileName: window-covering + - id: "5207/8" + deviceLabel: WISTAR WSERD50-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0008 + deviceProfileName: window-covering-battery + - id: "5207/9" + deviceLabel: WISTAR WSERD50-L Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0009 + deviceProfileName: window-covering-battery + - id: "5207/16" + deviceLabel: WISTAR WSERD50-T Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0010 + deviceProfileName: window-covering + - id: "5207/19" + deviceLabel: WISTAR WSER60 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0013 + deviceProfileName: window-covering + - id: "5207/17" + deviceLabel: WISTAR WSER40 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0011 + deviceProfileName: window-covering + - id: "5207/18" + deviceLabel: WISTAR WSER50 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0012 + deviceProfileName: window-covering + - id: "5207/2" + deviceLabel: WISTAR WSERD30-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0002 + deviceProfileName: window-covering-battery + - id: "5207/22" + deviceLabel: WISTAR WSCMXH Smart Vertical Blind Motor + vendorId: 0x1457 + productId: 0x0016 + deviceProfileName: window-covering-tilt + - id: "5207/23" + deviceLabel: WISTAR WSCMXF Smart Vertical Blind Motor + vendorId: 0x1457 + productId: 0x0017 + deviceProfileName: window-covering-tilt + - id: "5207/24" + deviceLabel: WISTAR WSCMXF-LED Smart Vertical Blind Motor + vendorId: 0x1457 + productId: 0x0018 + deviceProfileName: window-covering-tilt + - id: "5207/20" + deviceLabel: WISTAR WSCMQ Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0014 + deviceProfileName: window-covering + - id: "5207/21" + deviceLabel: WISTAR WSCMXI Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0015 + deviceProfileName: window-covering + - id: "5207/32" + deviceLabel: WISTAR WSCMT Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0020 + deviceProfileName: window-covering + - id: "5207/34" + deviceLabel: WISTAR WSCMXB Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0022 + deviceProfileName: window-covering + - id: "5207/35" + deviceLabel: WISTAR WSCMXC Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0023 + deviceProfileName: window-covering + - id: "5207/38" + deviceLabel: WISTAR WSCMXJ Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0026 + deviceProfileName: window-covering #WiZ - id: "WiZ A19" deviceLabel: WiZ A19 @@ -3751,6 +3917,42 @@ matterManufacturer: vendorId: 0x100B productId: 0x22F4 deviceProfileName: light-color-level +#Yooksmart + - id: "5411/1052" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x041C + deviceProfileName: window-covering-battery + - id: "5411/2660" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A64 + deviceProfileName: window-covering-battery + - id: "5411/2661" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A65 + deviceProfileName: window-covering-battery + - id: "5411/2662" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A66 + deviceProfileName: window-covering-battery + - id: "5411/2663" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A67 + deviceProfileName: window-covering-battery + - id: "5411/2664" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A68 + deviceProfileName: window-covering-battery + - id: "5411/2665" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A69 + deviceProfileName: window-covering-battery #Zemismart - id: "5020/61154" deviceLabel: Zemismart Inline Module @@ -3822,6 +4024,46 @@ matterManufacturer: vendorId: 0x139C productId: 0x0387 deviceProfileName: matter-bridge + - id: "Zemismart MT01 Slide Curtain" + deviceLabel: Zemismart MT01 Slide Curtain + vendorId: 0x139C + productId: 0xFFFE + deviceProfileName: window-covering + - id: "5020/65376" + deviceLabel: Zemismart MT25B Roller Motor + vendorId: 0x139C + productId: 0xFF60 + deviceProfileName: window-covering + - id: "5020/65296" + deviceLabel: Zemismart MT82 Smart Curtain + vendorId: 0x139C + productId: 0xFF10 + deviceProfileName: window-covering + - id: "5020/65301" + deviceLabel: Zemismart MT25A Thread Roller Motor + vendorId: 0x139C + productId: 0xFF15 + deviceProfileName: window-covering + - id: "5020/64050" + deviceLabel: Zemismart ZM02 Smart Curtain + vendorId: 0x139C + productId: 0xFA32 + deviceProfileName: window-covering + - id: "5020/64017" + deviceLabel: Zemismart ZM25C Smart Curtain + vendorId: 0x139C + productId: 0xFA11 + deviceProfileName: window-covering + - id: "5020/64049" + deviceLabel: Zemismart ZM01 Smart Curtain + vendorId: 0x139C + productId: 0xFA31 + deviceProfileName: window-covering + - id: "5020/64023" + deviceLabel: Zemismart ZM24A Smart Curtain + vendorId: 0x139C + productId: 0xFA17 + deviceProfileName: window-covering #Zimi - id: "5410/3" deviceLabel: Zimi Matter Connect @@ -4052,6 +4294,11 @@ matterGeneric: - id: 0x002B # Fan - id: 0x0110 # Mounted Dimmable Load Control deviceProfileName: fan-modular + - id: "windowcovering" + deviceLabel: Matter Window Covering + deviceTypes: + - id: 0x0202 # Window Covering + deviceProfileName: window-covering matterThing: - id: SmartThings/MatterThing diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml new file mode 100644 index 0000000000..3aa013d500 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml @@ -0,0 +1,22 @@ +name: window-covering-battery +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true + diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml new file mode 100644 index 0000000000..ffbd772713 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml @@ -0,0 +1,22 @@ +name: window-covering-batteryLevel +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true + diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-battery.yml new file mode 100644 index 0000000000..564fca17b8 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-battery.yml @@ -0,0 +1,24 @@ +name: window-covering-tilt-battery +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true + diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml new file mode 100644 index 0000000000..08516b9058 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml @@ -0,0 +1,22 @@ +name: window-covering-tilt-only-battery +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true + diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml new file mode 100644 index 0000000000..b473dddb44 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml @@ -0,0 +1,20 @@ +name: window-covering-tilt-only +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true + diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml new file mode 100644 index 0000000000..55e5f8533e --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml @@ -0,0 +1,22 @@ +name: window-covering-tilt +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true + 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..9c6178c156 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering.yml @@ -0,0 +1,20 @@ +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: reverse + explicit: true + diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index cff03bec37..53617f3ee7 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -102,7 +102,7 @@ function SwitchLifecycleHandlers.device_init(driver, device) -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. if #embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID, {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY}) > 0 then - device:set_field(fields.CUMULATIVE_REPORTS_SUPPORTED, true, {persist = false}) + device:set_field(fields.CUMULATIVE_REPORTS_SUPPORTED, true, {persist = false}) end end end @@ -257,6 +257,16 @@ local matter_driver_template = { [capabilities.valve.ID] = { clusters.ValveConfigurationAndControl.attributes.CurrentState }, + [capabilities.windowShade.ID] = { + clusters.WindowCovering.attributes.OperationalStatus + }, + [capabilities.windowShadeLevel.ID] = { + clusters.LevelControl.attributes.CurrentLevel, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths, + }, + [capabilities.windowShadeTiltLevel.ID] = { + clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths, + }, }, subscribed_events = { [capabilities.button.ID] = { @@ -336,6 +346,7 @@ local matter_driver_template = { sub_drivers = { 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.closures"), switch_utils.lazy_load_if_possible("sub_drivers.eve_energy"), 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/closures/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/can_handle.lua new file mode 100644 index 0000000000..cd08a8a3d0 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local fields = require "switch_utils.fields" + local switch_utils = require "switch_utils.utils" + if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WINDOW_COVERING) > 0 then + return true, require("sub_drivers.closures") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..2356c0fc8e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/attribute_handlers.lua @@ -0,0 +1,68 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local closure_fields = require "sub_drivers.closures.closure_utils.fields" + +local ClosureAttributeHandlers = {} + +ClosureAttributeHandlers.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(closure_fields.REVERSE_POLARITY) + device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) + + if attribute == capabilities.windowShadeLevel.shadeLevel then + device:set_field(closure_fields.CURRENT_LIFT, position) + else + device:set_field(closure_fields.CURRENT_TILT, position) + end + + local lift_position = device:get_field(closure_fields.CURRENT_LIFT) + local tilt_position = device:get_field(closure_fields.CURRENT_TILT) + + if lift_position == nil then + if tilt_position == 0 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) + elseif tilt_position == 100 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) + else + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) + end + elseif 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 + if tilt_position == nil or tilt_position == 0 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) + elseif tilt_position > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) + end + end + end +end + +function ClosureAttributeHandlers.current_status_handler(driver, device, ib, response) + local windowShade = capabilities.windowShade.windowShade + local reverse = device:get_field(closure_fields.REVERSE_POLARITY) + local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL + if state == 1 then -- opening + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closing() or windowShade.opening()) + elseif state == 2 then -- closing + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) + elseif state ~= 0 then -- unknown + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) + end +end + +function ClosureAttributeHandlers.level_attr_handler(driver, device, ib, response) + if ib.data.value == nil then return end + local level = math.floor((ib.data.value / 254.0 * 100) + 0.5) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(level)) +end + +return ClosureAttributeHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/capability_handlers.lua new file mode 100644 index 0000000000..6c8d47a3a7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_handlers/capability_handlers.lua @@ -0,0 +1,70 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local closure_fields = require "sub_drivers.closures.closure_utils.fields" + +local ClosureCapabilityHandlers = {} + +function ClosureCapabilityHandlers.handle_preset(driver, device, cmd) + local lift_value = device:get_latest_state( + "main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME + ) or closure_fields.DEFAULT_PRESET_LEVEL + local hundredths_lift_percent = (100 - lift_value) * 100 + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percent + )) +end + +function ClosureCapabilityHandlers.handle_set_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:set_field(closure_fields.PRESET_LEVEL_KEY, cmd.args.position) + device:emit_event_for_endpoint(endpoint_id, capabilities.windowShadePreset.position(cmd.args.position)) +end + +function ClosureCapabilityHandlers.handle_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + if device:get_field(closure_fields.REVERSE_POLARITY) then + req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + end + device:send(req) +end + +function ClosureCapabilityHandlers.handle_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + if device:get_field(closure_fields.REVERSE_POLARITY) then + req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + end + device:send(req) +end + +function ClosureCapabilityHandlers.handle_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id)) +end + +function ClosureCapabilityHandlers.handle_shade_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + 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 + +function ClosureCapabilityHandlers.handle_shade_tilt_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local tilt_percentage_value = 100 - cmd.args.level + local hundredths_tilt_percentage = tilt_percentage_value * 100 + local req = clusters.WindowCovering.server.commands.GoToTiltPercentage( + device, endpoint_id, hundredths_tilt_percentage + ) + device:send(req) +end + +return ClosureCapabilityHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_utils/fields.lua new file mode 100644 index 0000000000..93e1b6f128 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/closure_utils/fields.lua @@ -0,0 +1,12 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ClosureFields = {} + +ClosureFields.CURRENT_LIFT = "__current_lift" +ClosureFields.CURRENT_TILT = "__current_tilt" +ClosureFields.REVERSE_POLARITY = "__reverse_polarity" +ClosureFields.PRESET_LEVEL_KEY = "__preset_level_key" +ClosureFields.DEFAULT_PRESET_LEVEL = 50 + +return ClosureFields diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua new file mode 100644 index 0000000000..b42f1e8166 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua @@ -0,0 +1,90 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +------------------------------------------------------------------------------------- +-- Matter Closures Sub Driver +------------------------------------------------------------------------------------- + +local attribute_handlers = require "sub_drivers.closures.closure_handlers.attribute_handlers" +local capabilities = require "st.capabilities" +local capability_handlers = require "sub_drivers.closures.closure_handlers.capability_handlers" +local closure_fields = require "sub_drivers.closures.closure_utils.fields" +local clusters = require "st.matter.clusters" +local switch_utils = require "switch_utils.utils" + +local ClosureLifecycleHandlers = {} + +function ClosureLifecycleHandlers.device_init(driver, device) + device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then + -- These should only ever be nil once (and at the same time) for already-installed devices + -- It can be removed after migration is complete + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}})) + local preset_position = device:get_field(closure_fields.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + closure_fields.DEFAULT_PRESET_LEVEL + device:emit_event(capabilities.windowShadePreset.position(preset_position, {visibility = {displayed = false}})) + device:set_field(closure_fields.PRESET_LEVEL_KEY, preset_position, {persist = true}) + end + device:subscribe() +end + +function ClosureLifecycleHandlers.device_added(driver, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, {visibility = {displayed = false}})) + device:set_field(closure_fields.REVERSE_POLARITY, false, { persist = true }) +end + +function ClosureLifecycleHandlers.info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() + elseif args.old_st_store.preferences.reverse ~= device.preferences.reverse then + if device.preferences.reverse then + device:set_field(closure_fields.REVERSE_POLARITY, true, { persist = true }) + else + device:set_field(closure_fields.REVERSE_POLARITY, false, { persist = true }) + end + end +end + +local closures_handler = { + NAME = "closures", + lifecycle_handlers = { + init = ClosureLifecycleHandlers.device_init, + added = ClosureLifecycleHandlers.device_added, + infoChanged = ClosureLifecycleHandlers.info_changed + }, + matter_handlers = { + attr = { + [clusters.LevelControl.ID] = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = attribute_handlers.level_attr_handler, + }, + [clusters.WindowCovering.ID] = { + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = attribute_handlers.current_pos_handler(capabilities.windowShadeLevel.shadeLevel), + [clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths.ID] = attribute_handlers.current_pos_handler(capabilities.windowShadeTiltLevel.shadeTiltLevel), + [clusters.WindowCovering.attributes.OperationalStatus.ID] = attribute_handlers.current_status_handler + }, + }, + }, + capability_handlers = { + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = capability_handlers.handle_preset, + [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = capability_handlers.handle_set_preset + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.close.NAME] = capability_handlers.handle_close, + [capabilities.windowShade.commands.open.NAME] = capability_handlers.handle_open, + [capabilities.windowShade.commands.pause.NAME] = capability_handlers.handle_pause + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = capability_handlers.handle_shade_level + }, + [capabilities.windowShadeTiltLevel.ID] = { + [capabilities.windowShadeTiltLevel.commands.setShadeTiltLevel.NAME] = capability_handlers.handle_shade_tilt_level + }, + }, + can_handle = require("sub_drivers.closures.can_handle") +} + +return closures_handler 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 6cb29875ff..f7352c0d68 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -20,6 +20,7 @@ local ChildConfiguration = {} local SwitchDeviceConfiguration = {} local ButtonDeviceConfiguration = {} local FanDeviceConfiguration = {} +local WindowCoveringDeviceConfiguration = {} function ChildConfiguration.create_or_update_child_devices(driver, device, server_cluster_ep_ids, default_endpoint_id, assign_profile_fn) if #server_cluster_ep_ids == 1 and server_cluster_ep_ids[1] == default_endpoint_id then -- no children will be created @@ -74,7 +75,6 @@ function FanDeviceConfiguration.assign_profile_for_fan_ep(device, server_fan_ep_ return "fan-modular", optional_supported_component_capabilities end - function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_onoff_ep_id, is_child_device) local ep_info = switch_utils.get_endpoint_info(device, server_onoff_ep_id) @@ -187,6 +187,23 @@ function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep end end +function WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device) + local profile_name = "window-covering" + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then + profile_name = profile_name .. "-tilt" + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) == 0 then + profile_name = profile_name .. "-only" + end + end + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY + if battery_support == fields.battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + elseif battery_support == fields.battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + end + device:try_update_metadata({profile = profile_name}) +end + -- [[ PROFILE MATCHING AND CONFIGURATIONS ]] -- @@ -241,6 +258,15 @@ function DeviceConfiguration.match_profile(driver, device) device:set_field(fields.MODULAR_PROFILE_UPDATED, true) end + -- initialize the main device card with window covering if applicable + local window_covering_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WINDOW_COVERING) + if switch_utils.tbl_contains(window_covering_ep_ids, default_endpoint_id) then + WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device) + return + elseif #window_covering_ep_ids > 0 then + ChildConfiguration.create_or_update_child_devices(driver, device, window_covering_ep_ids, default_endpoint_id, WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep) + end + -- initialize the main device card with buttons if applicable local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index bf391d5070..772dd9e250 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -44,6 +44,7 @@ SwitchFields.DEVICE_TYPE_ID = { DIMMER = 0x0104, COLOR_DIMMER = 0x0105, }, + WINDOW_COVERING = 0x0202, } SwitchFields.device_type_profile_map = { diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 5918cd435f..ca58e2af74 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -153,7 +153,8 @@ function utils.find_default_endpoint(device) local onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - local fan_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) + local fan_ep_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) + local window_covering_ep_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WINDOW_COVERING) local get_first_non_zero_endpoint = function(endpoints) table.sort(endpoints) @@ -166,8 +167,8 @@ function utils.find_default_endpoint(device) 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) + if #fan_ep_ids > 0 then + return get_first_non_zero_endpoint(fan_ep_ids) end -- Return the first onoff endpoint as the default endpoint if no momentary switch endpoints are present @@ -193,6 +194,11 @@ function utils.find_default_endpoint(device) end end + -- Return the first window covering endpoint as the default endpoint if any is found + if #window_covering_ep_ids > 0 then + return get_first_non_zero_endpoint(window_covering_ep_ids) + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) return device.MATTER_DEFAULT_ENDPOINT end diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua new file mode 100644 index 0000000000..9786b5a4ee --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua @@ -0,0 +1,1039 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" + +local WindowCovering = clusters.WindowCovering + +local mock_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + device_types = { + {device_type_id = 0x0202, device_type_revision = 1} -- WindowCovering + } + }, + }, + } +) + +local CLUSTER_SUBSCRIBE_LIST = { + WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, + WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, + WindowCovering.server.attributes.OperationalStatus, + clusters.PowerSource.server.attributes.BatPercentRemaining +} + +local function set_preset(device) + test.socket.capability:__expect_send( + device:generate_test_message( + "main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + device:generate_test_message( + "main", capabilities.windowShadePreset.position(50, {visibility = {displayed = false}}) + ) + ) +end + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + set_preset(mock_device) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state closed following lift position update", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state closed following tilt position update", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state closed before lift position 0", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state closed before tilt position 0", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state open following lift position update", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state open following tilt position update", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state open before lift position event", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus state open before tilt position event", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus partially open following lift position update", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus partially open following tilt position update", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 15) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus partially open before lift position event", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end +) + +test.register_coroutine_test( + "WindowCovering OperationalStatus partially open before tilt position event", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 65) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end +) + +test.register_coroutine_test("WindowCovering OperationalStatus opening", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 1), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.opening() + ) + ) +end) + +test.register_coroutine_test("WindowCovering OperationalStatus closing", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 2), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closing() + ) + ) +end) + +test.register_coroutine_test("WindowCovering OperationalStatus unknown", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 3), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.unknown() + ) + ) +end) + +test.register_coroutine_test( + "WindowShade open cmd handler", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 10)} + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "WindowShade close cmd handler", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "close", args = {}}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.DownOrClose(mock_device, 10)} + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "WindowShade pause cmd handler", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "pause", args = {}}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.StopMotion(mock_device, 10)} + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Refresh necessary attributes", function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + test.socket.capability:__expect_send( + { + mock_device.id, + { + capability_id = "windowShade", + component_id = "main", + attribute_id = "supportedWindowShadeCommands", + state = {value = {"open", "close", "pause"}}, + visibility = {displayed = false} + }, + } + ) + test.wait_for_events() + + test.socket.capability:__queue_receive( + {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + ) + local read_request = CLUSTER_SUBSCRIBE_LIST[1]:read(mock_device) + for i, attr in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then read_request:merge(attr:read(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, read_request}) + test.wait_for_events() + end +) + +test.register_coroutine_test("WindowShade setShadeLevel cmd handler", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 20 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 8000)} + ) +end) + +test.register_coroutine_test("WindowShade setShadeTiltLevel cmd handler", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 60 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 4000)} + ) +end) + +test.register_coroutine_test("LevelControl CurrentLevel handler", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 100), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(math.floor((100 / 254.0 * 100) + .5)) + ) + ) +end) + +--test battery +test.register_coroutine_test( + "Battery percent reports should generate correct messages", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device, 10, 150 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.battery.battery(math.floor(150/2.0+0.5)) + ) + ) + end +) + +test.register_coroutine_test("OperationalStatus report contains current position report", function() + test.socket.capability:__set_channel_ordering("relaxed") + local report = WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ) + table.insert(report.info_blocks, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0).info_blocks[1]) + test.socket.matter:__queue_receive({ mock_device.id, report}) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) +end) + +test.register_coroutine_test( + "Handle preset commands", + function() + local PRESET_LEVEL = 30 + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = { PRESET_LEVEL }}, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadePreset.position(PRESET_LEVEL) + ) + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadePreset", component = "main", command = "presetPosition", args = {}}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, (100 - PRESET_LEVEL) * 100)} + ) + end +) + +test.register_coroutine_test( + "Test profile change to window-covering-battery when battery percent remaining attribute (attribute ID 12) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(12)}) + } + ) + mock_device:expect_metadata_update({ profile = "window-covering-tilt-battery" }) + end +) + +test.register_coroutine_test( + "Test that profile is not changed to window-covering-battery when battery percent remaining attribute (attribute ID 12) is not available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(10)}) + } + ) + end +) + +test.register_coroutine_test( + "WindowCovering shade level adjusted by greater than 2%; status reflects Closing followed by Partially Open", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 19 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 8100)} + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 10), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closing() + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 23) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(23) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 21) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(21) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 19) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(19) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end +) + +test.register_coroutine_test( + "WindowCovering shade level adjusted by less than or equal to 2%; status reflects Closing followed by Partially Open", function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(25) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 23 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 7700)} + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 10), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closing() + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 23) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(23) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end +) + +test.register_coroutine_test( + "Check that preference updates to reverse polarity after being set to true and that the shade lift operates as expected when opening and closing", function() + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + local reverse_preference_set = mock_device:get_field("__reverse_polarity") + assert(reverse_preference_set == true, "reverse_preference_set is True") + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 100 * 100 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 1500)} + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 0)} + ) + end +) + +test.register_coroutine_test( + "Check that preference updates to reverse polarity after being set to true and that the shade tilt operates as expected when opening and closing", function() + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + local reverse_preference_set = mock_device:get_field("__reverse_polarity") + assert(reverse_preference_set == true, "reverse_preference_set is True") + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 100 * 100 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 15 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 8500)} + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 0 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 10000)} + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-window-covering/fingerprints.yml b/drivers/SmartThings/matter-window-covering/fingerprints.yml deleted file mode 100644 index 532ba1f304..0000000000 --- a/drivers/SmartThings/matter-window-covering/fingerprints.yml +++ /dev/null @@ -1,251 +0,0 @@ -matterManufacturer: -#Eve - - id: "Eve MotionBlinds" - deviceLabel: Eve MotionBlinds - vendorId: 0x130A - productId: 0x55 - deviceProfileName: window-covering-battery - - id: "4874/109" - deviceLabel: Eve MotionBlinds for Roller Blinds - vendorId: 0x130A - productId: 0x006D - deviceProfileName: window-covering-battery - - id: "4874/98" - deviceLabel: Eve MotionBlinds for Honeycomb Blinds - vendorId: 0x130A - productId: 0x0062 - deviceProfileName: window-covering-battery - - id: "4874/99" - deviceLabel: Eve MotionBlinds for Venetian Blinds - vendorId: 0x130A - productId: 0x0063 - deviceProfileName: window-covering-battery - - id: "4874/100" - deviceLabel: Eve MotionBlinds for Curtains - vendorId: 0x130A - productId: 0x0064 - deviceProfileName: window-covering-battery - - id: "4874/96" - deviceLabel: Eve Shutter Switch - vendorId: 0x130A - productId: 0x0060 - deviceProfileName: window-covering -# Griesser - - id: "5435/14337" - deviceLabel: MSM-1 - vendorId: 0x153B - productId: 0x3801 - deviceProfileName: window-covering-tilt -# Meross - - id: "4933/61453" - deviceLabel: Smart Wi-Fi Roller Shutter Timer - vendorId: 0x1345 - productId: 0xF00D - deviceProfileName: window-covering -# Mamaba - - id: "4965/4097" - deviceLabel: Wi-Fi Curtain - vendorId: 0x1365 - productId: 0x1001 - deviceProfileName: window-covering -# SmartWave - - id: "5376/10001" - deviceLabel: SmartWave Motorized Roller Shades 100 Blackout Flex - vendorId: 0x1500 - productId: 0x2711 - deviceProfileName: window-covering-battery -# SmartWings - - id: "5231/4097" - deviceLabel: SmartWings Window Covering - vendorId: 0x146F - productId: 0x1001 - deviceProfileName: window-covering-battery -#Zemismart - - id: "Zemismart MT01 Slide Curtain" - deviceLabel: Zemismart MT01 Slide Curtain - vendorId: 0x139C - productId: 0xFFFE - deviceProfileName: window-covering - - id: "5020/65376" - deviceLabel: Zemismart MT25B Roller Motor - vendorId: 0x139C - productId: 0xFF60 - deviceProfileName: window-covering - - id: "5020/65296" - deviceLabel: Zemismart MT82 Smart Curtain - vendorId: 0x139C - productId: 0xFF10 - deviceProfileName: window-covering - - id: "5020/65301" - deviceLabel: Zemismart MT25A Thread Roller Motor - vendorId: 0x139C - productId: 0xFF15 - deviceProfileName: window-covering - - id: "5020/64050" - deviceLabel: Zemismart ZM02 Smart Curtain - vendorId: 0x139C - productId: 0xFA32 - deviceProfileName: window-covering - - id: "5020/64017" - deviceLabel: Zemismart ZM25C Smart Curtain - vendorId: 0x139C - productId: 0xFA11 - deviceProfileName: window-covering - - id: "5020/64049" - deviceLabel: Zemismart ZM01 Smart Curtain - vendorId: 0x139C - productId: 0xFA31 - deviceProfileName: window-covering - - id: "5020/64023" - deviceLabel: Zemismart ZM24A Smart Curtain - vendorId: 0x139C - productId: 0xFA17 - deviceProfileName: window-covering -#WISTAR - - id: "5207/3" - deviceLabel: WISTAR WSERD16-B Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0003 - deviceProfileName: window-covering-battery - - id: "5207/4" - deviceLabel: WISTAR WSERD24 Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0004 - deviceProfileName: window-covering - - id: "5207/5" - deviceLabel: WISTAR WSERD40-B Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0005 - deviceProfileName: window-covering-battery - - id: "5207/6" - deviceLabel: WISTAR WSERD40-L Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0006 - deviceProfileName: window-covering-battery - - id: "5207/7" - deviceLabel: WISTAR WSERD40-T Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0007 - deviceProfileName: window-covering - - id: "5207/8" - deviceLabel: WISTAR WSERD50-B Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0008 - deviceProfileName: window-covering-battery - - id: "5207/9" - deviceLabel: WISTAR WSERD50-L Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0009 - deviceProfileName: window-covering-battery - - id: "5207/16" - deviceLabel: WISTAR WSERD50-T Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0010 - deviceProfileName: window-covering - - id: "5207/19" - deviceLabel: WISTAR WSER60 Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0013 - deviceProfileName: window-covering - - id: "5207/17" - deviceLabel: WISTAR WSER40 Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0011 - deviceProfileName: window-covering - - id: "5207/18" - deviceLabel: WISTAR WSER50 Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0012 - deviceProfileName: window-covering - - id: "5207/2" - deviceLabel: WISTAR WSERD30-B Smart Tubular Motor - vendorId: 0x1457 - productId: 0x0002 - deviceProfileName: window-covering-battery - - id: "5207/22" - deviceLabel: WISTAR WSCMXH Smart Vertical Blind Motor - vendorId: 0x1457 - productId: 0x0016 - deviceProfileName: window-covering-tilt - - id: "5207/23" - deviceLabel: WISTAR WSCMXF Smart Vertical Blind Motor - vendorId: 0x1457 - productId: 0x0017 - deviceProfileName: window-covering-tilt - - id: "5207/24" - deviceLabel: WISTAR WSCMXF-LED Smart Vertical Blind Motor - vendorId: 0x1457 - productId: 0x0018 - deviceProfileName: window-covering-tilt - - id: "5207/20" - deviceLabel: WISTAR WSCMQ Smart Curtain Motor - vendorId: 0x1457 - productId: 0x0014 - deviceProfileName: window-covering - - id: "5207/21" - deviceLabel: WISTAR WSCMXI Smart Curtain Motor - vendorId: 0x1457 - productId: 0x0015 - deviceProfileName: window-covering - - id: "5207/32" - deviceLabel: WISTAR WSCMT Smart Curtain Motor - vendorId: 0x1457 - productId: 0x0020 - deviceProfileName: window-covering - - id: "5207/34" - deviceLabel: WISTAR WSCMXB Smart Curtain Motor - vendorId: 0x1457 - productId: 0x0022 - deviceProfileName: window-covering - - id: "5207/35" - deviceLabel: WISTAR WSCMXC Smart Curtain Motor - vendorId: 0x1457 - productId: 0x0023 - deviceProfileName: window-covering - - id: "5207/38" - deviceLabel: WISTAR WSCMXJ Smart Curtain Motor - vendorId: 0x1457 - productId: 0x0026 - deviceProfileName: window-covering -#Yooksmart - - id: "5411/1052" - deviceLabel: Smart WindowCovering Series - vendorId: 0x1523 - productId: 0x041C - deviceProfileName: window-covering-battery - - id: "5411/2660" - deviceLabel: Smart WindowCovering Series - vendorId: 0x1523 - productId: 0x0A64 - deviceProfileName: window-covering-battery - - id: "5411/2661" - deviceLabel: Smart WindowCovering Series - vendorId: 0x1523 - productId: 0x0A65 - deviceProfileName: window-covering-battery - - id: "5411/2662" - deviceLabel: Smart WindowCovering Series - vendorId: 0x1523 - productId: 0x0A66 - deviceProfileName: window-covering-battery - - id: "5411/2663" - deviceLabel: Smart WindowCovering Series - vendorId: 0x1523 - productId: 0x0A67 - deviceProfileName: window-covering-battery - - id: "5411/2664" - deviceLabel: Smart WindowCovering Series - vendorId: 0x1523 - productId: 0x0A68 - deviceProfileName: window-covering-battery - - id: "5411/2665" - deviceLabel: Smart WindowCovering Series - vendorId: 0x1523 - productId: 0x0A69 - deviceProfileName: window-covering-battery -matterGeneric: - - id: "windowcovering" - deviceLabel: Matter Window Covering - deviceTypes: - - id: 0x0202 # Window Covering - deviceProfileName: window-covering From fefc7d93e530dc14b4b5d3cca0156f6446655e02 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 12:59:03 -0600 Subject: [PATCH 08/17] fix window covering tests --- .../matter-switch/src/test/test_matter_window_covering.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua index 9786b5a4ee..42416ffd9c 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua @@ -83,8 +83,6 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() - test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) end test.set_test_init_function(test_init) @@ -737,6 +735,9 @@ test.register_coroutine_test( test.register_coroutine_test( "Test that profile is not changed to window-covering-battery when battery percent remaining attribute (attribute ID 12) is not available", function() + local fields = require "switch_utils.fields" + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, From 2ca640d4f384ddc788688c4256201bbf7fe48b4b Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 16:06:52 -0600 Subject: [PATCH 09/17] improve profiling logic --- .../src/sub_drivers/closures/init.lua | 1 + .../src/switch_utils/device_configuration.lua | 19 +++++++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua index b42f1e8166..5f0201e49a 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua @@ -34,6 +34,7 @@ end function ClosureLifecycleHandlers.device_added(driver, device) device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, {visibility = {displayed = false}})) device:set_field(closure_fields.REVERSE_POLARITY, false, { persist = true }) + switch_utils.handle_electrical_sensor_info(device) end function ClosureLifecycleHandlers.info_changed(driver, device, event, args) 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 f7352c0d68..9c86c5bd44 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -201,7 +201,7 @@ function WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep elseif battery_support == fields.battery_support.BATTERY_LEVEL then profile_name = profile_name .. "-batteryLevel" end - device:try_update_metadata({profile = profile_name}) + return profile_name end @@ -258,15 +258,6 @@ function DeviceConfiguration.match_profile(driver, device) device:set_field(fields.MODULAR_PROFILE_UPDATED, true) end - -- initialize the main device card with window covering if applicable - local window_covering_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WINDOW_COVERING) - if switch_utils.tbl_contains(window_covering_ep_ids, default_endpoint_id) then - WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device) - return - elseif #window_covering_ep_ids > 0 then - ChildConfiguration.create_or_update_child_devices(driver, device, window_covering_ep_ids, default_endpoint_id, WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep) - end - -- initialize the main device card with buttons if applicable local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then @@ -276,6 +267,14 @@ function DeviceConfiguration.match_profile(driver, device) ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) end + -- initialize the main device card with window covering if applicable + local window_covering_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WINDOW_COVERING) + if switch_utils.tbl_contains(window_covering_ep_ids, default_endpoint_id) then + updated_profile = WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device) + elseif #window_covering_ep_ids > 0 then + ChildConfiguration.create_or_update_child_devices(driver, device, window_covering_ep_ids, default_endpoint_id, WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep) + end + device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) end From 13a371f6b86285bcbdedd7cbdbaf9674c98599aa Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 16:15:08 -0600 Subject: [PATCH 10/17] make window covering profiling more like handling for other device types --- .../matter-switch/src/switch_utils/device_configuration.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 9c86c5bd44..bdba536c9c 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -235,7 +235,6 @@ function DeviceConfiguration.match_profile(driver, device) 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) end - if switch_utils.tbl_contains(server_onoff_ep_ids, default_endpoint_id) then updated_profile = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, default_endpoint_id) local generic_profile = function(s) return string.find(updated_profile or "", s, 1, true) end @@ -269,10 +268,11 @@ function DeviceConfiguration.match_profile(driver, device) -- initialize the main device card with window covering if applicable local window_covering_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WINDOW_COVERING) + if #window_covering_ep_ids > 0 then + ChildConfiguration.create_or_update_child_devices(driver, device, window_covering_ep_ids, default_endpoint_id, WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep) + end if switch_utils.tbl_contains(window_covering_ep_ids, default_endpoint_id) then updated_profile = WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device) - elseif #window_covering_ep_ids > 0 then - ChildConfiguration.create_or_update_child_devices(driver, device, window_covering_ep_ids, default_endpoint_id, WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep) end device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) From 509df2c63ee970c21db05bb0079da8a0fa487a33 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 21 Jan 2026 16:46:39 -0600 Subject: [PATCH 11/17] Use modular profile for window coverings --- .../profiles/window-covering-battery.yml | 22 ---- .../profiles/window-covering-batteryLevel.yml | 22 ---- ...attery.yml => window-covering-modular.yml} | 9 +- .../window-covering-tilt-only-battery.yml | 22 ---- .../profiles/window-covering-tilt-only.yml | 20 --- .../profiles/window-covering-tilt.yml | 22 ---- .../profiles/window-covering.yml | 20 --- .../src/switch_utils/device_configuration.lua | 28 ++-- .../src/test/test_matter_window_covering.lua | 123 +++++++++++------- 9 files changed, 101 insertions(+), 187 deletions(-) delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml rename drivers/SmartThings/matter-switch/profiles/{window-covering-tilt-battery.yml => window-covering-modular.yml} (73%) delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering.yml diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml deleted file mode 100644 index 3aa013d500..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: window-covering-battery -components: -- id: main - capabilities: - - id: windowShade - version: 1 - - id: windowShadePreset - version: 1 - - id: windowShadeLevel - version: 1 - - id: battery - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Blind -preferences: - - preferenceId: reverse - explicit: true - diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml deleted file mode 100644 index ffbd772713..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-batteryLevel.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: window-covering-batteryLevel -components: -- id: main - capabilities: - - id: windowShade - version: 1 - - id: windowShadePreset - version: 1 - - id: windowShadeLevel - version: 1 - - id: batteryLevel - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Blind -preferences: - - preferenceId: reverse - explicit: true - diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-modular.yml similarity index 73% rename from drivers/SmartThings/matter-switch/profiles/window-covering-tilt-battery.yml rename to drivers/SmartThings/matter-switch/profiles/window-covering-modular.yml index 564fca17b8..79f46b9e2b 100644 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-battery.yml +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-modular.yml @@ -1,4 +1,4 @@ -name: window-covering-tilt-battery +name: window-covering-modular components: - id: main capabilities: @@ -8,10 +8,16 @@ components: version: 1 - id: windowShadeLevel version: 1 + optional: true - id: windowShadeTiltLevel version: 1 + optional: true - id: battery version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true - id: firmwareUpdate version: 1 - id: refresh @@ -21,4 +27,3 @@ components: preferences: - preferenceId: reverse explicit: true - diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml deleted file mode 100644 index 08516b9058..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only-battery.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: window-covering-tilt-only-battery -components: -- id: main - capabilities: - - id: windowShade - version: 1 - - id: windowShadePreset - version: 1 - - id: windowShadeTiltLevel - version: 1 - - id: battery - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Blind -preferences: - - preferenceId: reverse - explicit: true - diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml deleted file mode 100644 index b473dddb44..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt-only.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: window-covering-tilt-only -components: -- id: main - capabilities: - - id: windowShade - version: 1 - - id: windowShadePreset - version: 1 - - id: windowShadeTiltLevel - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Blind -preferences: - - preferenceId: reverse - explicit: true - diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml deleted file mode 100644 index 55e5f8533e..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: window-covering-tilt -components: -- id: main - capabilities: - - id: windowShade - version: 1 - - id: windowShadePreset - version: 1 - - id: windowShadeLevel - version: 1 - - id: windowShadeTiltLevel - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Blind -preferences: - - preferenceId: reverse - explicit: true - diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering.yml b/drivers/SmartThings/matter-switch/profiles/window-covering.yml deleted file mode 100644 index 9c6178c156..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering.yml +++ /dev/null @@ -1,20 +0,0 @@ -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: reverse - explicit: true - 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 bdba536c9c..85060e0f88 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -187,21 +187,27 @@ function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep end end -function WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device) - local profile_name = "window-covering" - if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then - profile_name = profile_name .. "-tilt" - if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) == 0 then - profile_name = profile_name .. "-only" - end +function WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device, server_window_covering_ep_id) + local ep_info = switch_utils.get_endpoint_info(device, server_window_covering_ep_id) + local window_covering_cluster_info = switch_utils.find_cluster_on_ep(ep_info, clusters.WindowCovering.ID) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + + if clusters.WindowCovering.are_features_supported(clusters.WindowCovering.types.Feature.LIFT, window_covering_cluster_info.feature_map) then + table.insert(main_component_capabilities, capabilities.windowShadeLevel.ID) + end + if clusters.WindowCovering.are_features_supported(clusters.WindowCovering.types.Feature.TILT, window_covering_cluster_info.feature_map) then + table.insert(main_component_capabilities, capabilities.windowShadeTiltLevel.ID) end local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY if battery_support == fields.battery_support.BATTERY_PERCENTAGE then - profile_name = profile_name .. "-battery" + table.insert(main_component_capabilities, capabilities.battery.ID) elseif battery_support == fields.battery_support.BATTERY_LEVEL then - profile_name = profile_name .. "-batteryLevel" + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) end - return profile_name + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + return "window-covering-modular", optional_supported_component_capabilities end @@ -272,7 +278,7 @@ function DeviceConfiguration.match_profile(driver, device) ChildConfiguration.create_or_update_child_devices(driver, device, window_covering_ep_ids, default_endpoint_id, WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep) end if switch_utils.tbl_contains(window_covering_ep_ids, default_endpoint_id) then - updated_profile = WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device) + updated_profile, optional_component_capabilities = WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep(device, default_endpoint_id) end device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua index 42416ffd9c..c31cd3de77 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua @@ -11,7 +11,7 @@ local WindowCovering = clusters.WindowCovering local mock_device = test.mock_device.build_test_matter_device( { - profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), + profile = t_utils.get_profile_definition("window-covering-modular.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, endpoints = { { @@ -42,13 +42,6 @@ local mock_device = test.mock_device.build_test_matter_device( } ) -local CLUSTER_SUBSCRIBE_LIST = { - WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, - WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, - WindowCovering.server.attributes.OperationalStatus, - clusters.PowerSource.server.attributes.BatPercentRemaining -} - local function set_preset(device) test.socket.capability:__expect_send( device:generate_test_message( @@ -75,10 +68,8 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) set_preset(mock_device) - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end + + local subscribe_request = WindowCovering.server.attributes.OperationalStatus:subscribe(mock_device) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) @@ -87,9 +78,35 @@ end test.set_test_init_function(test_init) +local CLUSTER_SUBSCRIBE_LIST = { + WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, + WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, + WindowCovering.server.attributes.OperationalStatus, + clusters.PowerSource.server.attributes.BatPercentRemaining +} + +local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) +for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end +end + +local function update_profile() + test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + mock_device:expect_metadata_update({ profile = "window-covering-modular", optional_component_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel", "battery"}}} }) + local updated_device_profile = t_utils.get_profile_definition("window-covering-modular.yml", + {enabled_optional_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel", "battery"}}}} + ) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) +end + test.register_coroutine_test( "WindowCovering OperationalStatus state closed following lift position update", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -120,6 +137,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed following tilt position update", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -150,6 +169,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed before lift position 0", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -180,6 +201,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed before tilt position 0", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -210,6 +233,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open following lift position update", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -240,6 +265,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open following tilt position update", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -270,6 +297,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open before lift position event", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -300,6 +329,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open before tilt position event", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -330,6 +361,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open following lift position update", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -360,6 +393,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open following tilt position update", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -390,6 +425,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open before lift position event", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -420,6 +457,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open before tilt position event", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -449,6 +488,8 @@ test.register_coroutine_test( test.register_coroutine_test("WindowCovering OperationalStatus opening", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -482,6 +523,8 @@ end) test.register_coroutine_test("WindowCovering OperationalStatus closing", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -515,6 +558,8 @@ end) test.register_coroutine_test("WindowCovering OperationalStatus unknown", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -593,6 +638,8 @@ test.register_coroutine_test( test.register_coroutine_test( "Refresh necessary attributes", function() + update_profile() + test.wait_for_events() test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) test.socket.capability:__expect_send( { @@ -621,6 +668,8 @@ test.register_coroutine_test( ) test.register_coroutine_test("WindowShade setShadeLevel cmd handler", function() + update_profile() + test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, @@ -633,6 +682,8 @@ test.register_coroutine_test("WindowShade setShadeLevel cmd handler", function() end) test.register_coroutine_test("WindowShade setShadeTiltLevel cmd handler", function() + update_profile() + test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, @@ -645,6 +696,8 @@ test.register_coroutine_test("WindowShade setShadeTiltLevel cmd handler", functi end) test.register_coroutine_test("LevelControl CurrentLevel handler", function() + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -661,6 +714,8 @@ end) --test battery test.register_coroutine_test( "Battery percent reports should generate correct messages", function() + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -679,6 +734,8 @@ test.register_coroutine_test( test.register_coroutine_test("OperationalStatus report contains current position report", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() local report = WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( mock_device, 10, ((100 - 25) *100) ) @@ -719,37 +776,11 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "Test profile change to window-covering-battery when battery percent remaining attribute (attribute ID 12) is available", - function() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(12)}) - } - ) - mock_device:expect_metadata_update({ profile = "window-covering-tilt-battery" }) - end -) - -test.register_coroutine_test( - "Test that profile is not changed to window-covering-battery when battery percent remaining attribute (attribute ID 12) is not available", - function() - local fields = require "switch_utils.fields" - mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) - test.wait_for_events() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(10)}) - } - ) - end -) - test.register_coroutine_test( "WindowCovering shade level adjusted by greater than 2%; status reflects Closing followed by Partially Open", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -859,6 +890,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering shade level adjusted by less than or equal to 2%; status reflects Closing followed by Partially Open", function() test.socket.capability:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -929,10 +962,9 @@ test.register_coroutine_test( test.register_coroutine_test( "Check that preference updates to reverse polarity after being set to true and that the shade lift operates as expected when opening and closing", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + update_profile() test.wait_for_events() - local reverse_preference_set = mock_device:get_field("__reverse_polarity") - assert(reverse_preference_set == true, "reverse_preference_set is True") + mock_device:set_field("__reverse_polarity", true) test.socket.matter:__queue_receive({ mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( @@ -984,10 +1016,9 @@ test.register_coroutine_test( test.register_coroutine_test( "Check that preference updates to reverse polarity after being set to true and that the shade tilt operates as expected when opening and closing", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + update_profile() test.wait_for_events() - local reverse_preference_set = mock_device:get_field("__reverse_polarity") - assert(reverse_preference_set == true, "reverse_preference_set is True") + mock_device:set_field("__reverse_polarity", true) test.socket.matter:__queue_receive({ mock_device.id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( From e8e4f6375f12daa82134a595ea4edf36c461fe56 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 22 Jan 2026 14:25:41 -0600 Subject: [PATCH 12/17] fix child device support use modular profile for child devices and fix onboarding flow --- .../profiles/window-covering-battery.yml | 21 ++++ .../profiles/window-covering-tilt.yml | 21 ++++ .../profiles/window-covering.yml | 19 +++ .../SmartThings/matter-switch/src/init.lua | 9 +- .../src/sub_drivers/closures/can_handle.lua | 4 + .../src/switch_utils/device_configuration.lua | 23 ++-- .../src/test/test_matter_window_covering.lua | 113 ++++++++++++++---- 7 files changed, 177 insertions(+), 33 deletions(-) create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering.yml diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml new file mode 100644 index 0000000000..a1896ca634 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml @@ -0,0 +1,21 @@ +name: window-covering-battery +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml new file mode 100644 index 0000000000..c6a759b610 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml @@ -0,0 +1,21 @@ +name: window-covering-tilt +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true 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..fbd40ed08d --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering.yml @@ -0,0 +1,19 @@ +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: reverse + explicit: true diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 53617f3ee7..9cd013260c 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -8,9 +8,11 @@ local clusters = require "st.matter.clusters" local log = require "log" local version = require "version" local cfg = require "switch_utils.device_configuration" +local button_cfg = cfg.ButtonCfg +local child_cfg = cfg.ChildCfg local device_cfg = cfg.DeviceCfg local switch_cfg = cfg.SwitchCfg -local button_cfg = cfg.ButtonCfg +local window_covering_cfg = cfg.WindowCoveringCfg local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" local attribute_handlers = require "switch_handlers.attribute_handlers" @@ -54,6 +56,11 @@ function SwitchLifecycleHandlers.do_configure(driver, device) switch_cfg.set_device_control_options(device) device_cfg.match_profile(driver, device) elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then + local window_covering_ep_ids = switch_utils.get_endpoints_by_device_type(device:get_parent_device(), fields.DEVICE_TYPE_ID.WINDOW_COVERING) + if #window_covering_ep_ids > 0 then + local default_endpoint_id = switch_utils.find_default_endpoint(device:get_parent_device()) + child_cfg.create_or_update_child_devices(driver, device:get_parent_device(), window_covering_ep_ids, default_endpoint_id, window_covering_cfg.assign_profile_for_window_covering_ep) + end -- because get_parent_device() may cause race conditions if used in init, an initial child subscribe is handled in doConfigure. -- all future calls to subscribe will be handled by the parent device in init device:subscribe() diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/can_handle.lua index cd08a8a3d0..f5d069ac6e 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/can_handle.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/can_handle.lua @@ -2,8 +2,12 @@ -- 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 switch_utils = require "switch_utils.utils" + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + device = device:get_parent_device() + end if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WINDOW_COVERING) > 0 then return true, require("sub_drivers.closures") end 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 85060e0f88..61a138692e 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -31,7 +31,7 @@ function ChildConfiguration.create_or_update_child_devices(driver, device, serve for device_num, ep_id in ipairs(server_cluster_ep_ids) do if ep_id ~= default_endpoint_id then -- don't create a child device that maps to the main endpoint local label_and_name = string.format("%s %d", device.label, device_num) - local child_profile, _ = assign_profile_fn(device, ep_id, true) + local child_profile, optional_component_capabilities = assign_profile_fn(device, ep_id, true) local existing_child_device = device:get_field(fields.IS_PARENT_CHILD_DEVICE) and switch_utils.find_child(device, ep_id) if not existing_child_device then driver:try_create_device({ @@ -44,7 +44,8 @@ function ChildConfiguration.create_or_update_child_devices(driver, device, serve }) else existing_child_device:try_update_metadata({ - profile = child_profile + profile = child_profile, + optional_component_capabilities = optional_component_capabilities }) end end @@ -199,11 +200,15 @@ function WindowCoveringDeviceConfiguration.assign_profile_for_window_covering_ep if clusters.WindowCovering.are_features_supported(clusters.WindowCovering.types.Feature.TILT, window_covering_cluster_info.feature_map) then table.insert(main_component_capabilities, capabilities.windowShadeTiltLevel.ID) end - local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY - if battery_support == fields.battery_support.BATTERY_PERCENTAGE then - table.insert(main_component_capabilities, capabilities.battery.ID) - elseif battery_support == fields.battery_support.BATTERY_LEVEL then - table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + + local power_source_cluster_info = switch_utils.find_cluster_on_ep(ep_info, clusters.PowerSource.ID) + if power_source_cluster_info then + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) or fields.battery_support.NO_BATTERY + if battery_support == fields.battery_support.BATTERY_PERCENTAGE then + table.insert(main_component_capabilities, capabilities.battery.ID) + elseif battery_support == fields.battery_support.BATTERY_LEVEL then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + end end table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) @@ -285,7 +290,9 @@ function DeviceConfiguration.match_profile(driver, device) end return { + ButtonCfg = ButtonDeviceConfiguration, + ChildCfg = ChildConfiguration, DeviceCfg = DeviceConfiguration, SwitchCfg = SwitchDeviceConfiguration, - ButtonCfg = ButtonDeviceConfiguration + WindowCoveringCfg = WindowCoveringDeviceConfiguration, } diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua index c31cd3de77..68f2652a81 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua @@ -11,6 +11,7 @@ local WindowCovering = clusters.WindowCovering local mock_device = test.mock_device.build_test_matter_device( { + label = "Matter Window Covering", profile = t_utils.get_profile_definition("window-covering-modular.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, endpoints = { @@ -38,10 +39,31 @@ local mock_device = test.mock_device.build_test_matter_device( {device_type_id = 0x0202, device_type_revision = 1} -- WindowCovering } }, + { + endpoint_id = 20, + clusters = { + { + cluster_id = clusters.WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + } + }, + device_types = { + {device_type_id = 0x0202, device_type_revision = 1} -- WindowCovering + } + }, }, } ) +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering-modular.yml"), + device_network_id = string.format("%s:%d", mock_device.id, 20), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", 20) +}) + local function set_preset(device) test.socket.capability:__expect_send( device:generate_test_message( @@ -55,6 +77,8 @@ local function set_preset(device) ) end +local subscribe_request + local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) @@ -69,11 +93,25 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) set_preset(mock_device) - local subscribe_request = WindowCovering.server.attributes.OperationalStatus:subscribe(mock_device) + subscribe_request = WindowCovering.server.attributes.OperationalStatus:subscribe(mock_device) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + test.mock_device.add_test_device(mock_child) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Window Covering 2", + profile = "window-covering-modular", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", 20) + }) + + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + mock_child:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.set_test_init_function(test_init) @@ -85,26 +123,31 @@ local CLUSTER_SUBSCRIBE_LIST = { clusters.PowerSource.server.attributes.BatPercentRemaining } -local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) -for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end -end - local function update_profile() test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( mock_device, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} )}) + mock_child:expect_metadata_update({ profile = "window-covering-modular", optional_component_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel"}}} }) mock_device:expect_metadata_update({ profile = "window-covering-modular", optional_component_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel", "battery"}}} }) local updated_device_profile = t_utils.get_profile_definition("window-covering-modular.yml", + {enabled_optional_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel"}}}} + ) + test.socket.device_lifecycle:__queue_receive(mock_child:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.wait_for_events() + updated_device_profile = t_utils.get_profile_definition("window-covering-modular.yml", {enabled_optional_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel", "battery"}}}} ) test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end test.register_coroutine_test( "WindowCovering OperationalStatus state closed following lift position update", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -136,7 +179,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed following tilt position update", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -168,7 +210,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed before lift position 0", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -200,7 +241,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed before tilt position 0", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -232,7 +272,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open following lift position update", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -264,7 +303,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open following tilt position update", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -296,7 +334,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open before lift position event", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -328,7 +365,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open before tilt position event", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -360,7 +396,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open following lift position update", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -392,7 +427,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open following tilt position update", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -424,7 +458,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open before lift position event", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -456,7 +489,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open before tilt position event", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -487,7 +519,6 @@ test.register_coroutine_test( ) test.register_coroutine_test("WindowCovering OperationalStatus opening", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -522,7 +553,6 @@ test.register_coroutine_test("WindowCovering OperationalStatus opening", functio end) test.register_coroutine_test("WindowCovering OperationalStatus closing", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -557,7 +587,6 @@ test.register_coroutine_test("WindowCovering OperationalStatus closing", functio end) test.register_coroutine_test("WindowCovering OperationalStatus unknown", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -593,6 +622,8 @@ end) test.register_coroutine_test( "WindowShade open cmd handler", function() + update_profile() + test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, @@ -608,6 +639,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowShade close cmd handler", function() + update_profile() + test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, @@ -623,6 +656,8 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowShade pause cmd handler", function() + update_profile() + test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, @@ -733,7 +768,6 @@ test.register_coroutine_test( ) test.register_coroutine_test("OperationalStatus report contains current position report", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() local report = WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( @@ -756,6 +790,8 @@ end) test.register_coroutine_test( "Handle preset commands", function() + update_profile() + test.wait_for_events() local PRESET_LEVEL = 30 test.socket.capability:__queue_receive({ mock_device.id, @@ -778,7 +814,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering shade level adjusted by greater than 2%; status reflects Closing followed by Partially Open", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -889,7 +924,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering shade level adjusted by less than or equal to 2%; status reflects Closing followed by Partially Open", function() - test.socket.capability:__set_channel_ordering("relaxed") update_profile() test.wait_for_events() test.socket.matter:__queue_receive( @@ -1068,4 +1102,35 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "WindowCovering OperationalStatus state closed following lift position update for child device", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_child, 20, 10000 + ), + } + ) + test.socket.capability:__expect_send( + mock_child:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_child:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_child, 20, 0), + } + ) + end +) + test.run_registered_tests() From b2beab4d23c707ab8ecfd4552d688c2b91666c09 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 22 Jan 2026 14:40:21 -0600 Subject: [PATCH 13/17] update fingerprints to use modular profile --- .../matter-switch/fingerprints.yml | 98 +++++++++---------- .../profiles/window-covering-battery.yml | 21 ---- .../profiles/window-covering-tilt.yml | 21 ---- .../profiles/window-covering.yml | 19 ---- 4 files changed, 49 insertions(+), 110 deletions(-) delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml delete mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering.yml diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index b531722741..e758c1d2a1 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -282,32 +282,32 @@ matterManufacturer: deviceLabel: Eve MotionBlinds vendorId: 0x130A productId: 0x55 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "4874/109" deviceLabel: Eve MotionBlinds for Roller Blinds vendorId: 0x130A productId: 0x006D - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "4874/98" deviceLabel: Eve MotionBlinds for Honeycomb Blinds vendorId: 0x130A productId: 0x0062 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "4874/99" deviceLabel: Eve MotionBlinds for Venetian Blinds vendorId: 0x130A productId: 0x0063 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "4874/100" deviceLabel: Eve MotionBlinds for Curtains vendorId: 0x130A productId: 0x0064 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "4874/96" deviceLabel: Eve Shutter Switch vendorId: 0x130A productId: 0x0060 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular #Ezviz - id: "5172/4096" @@ -789,7 +789,7 @@ matterManufacturer: deviceLabel: MSM-1 vendorId: 0x153B productId: 0x3801 - deviceProfileName: window-covering-tilt + deviceProfileName: window-covering-modular # Intecular - id: "5226/32769" deviceLabel: InvisOutlet @@ -1306,7 +1306,7 @@ matterManufacturer: deviceLabel: Wi-Fi Curtain vendorId: 0x1365 productId: 0x1001 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular #Meross - id: "4933/40987" deviceLabel: Smart Wi-Fi Switch @@ -1327,7 +1327,7 @@ matterManufacturer: deviceLabel: Smart Wi-Fi Roller Shutter Timer vendorId: 0x1345 productId: 0xF00D - deviceProfileName: window-covering + deviceProfileName: window-covering-modular #Nanoleaf - id: "Nanoleaf NL53" deviceLabel: Essentials BR30 @@ -1931,14 +1931,14 @@ matterManufacturer: deviceLabel: SmartWave Motorized Roller Shades 100 Blackout Flex vendorId: 0x1500 productId: 0x2711 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular # SmartWings - id: "5231/4097" deviceLabel: SmartWings Window Covering vendorId: 0x146F productId: 0x1001 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular #SONOFF - id: "SONOFF MINIR4M" @@ -2000,107 +2000,107 @@ matterManufacturer: deviceLabel: WISTAR WSERD16-B Smart Tubular Motor vendorId: 0x1457 productId: 0x0003 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5207/4" deviceLabel: WISTAR WSERD24 Smart Tubular Motor vendorId: 0x1457 productId: 0x0004 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/5" deviceLabel: WISTAR WSERD40-B Smart Tubular Motor vendorId: 0x1457 productId: 0x0005 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5207/6" deviceLabel: WISTAR WSERD40-L Smart Tubular Motor vendorId: 0x1457 productId: 0x0006 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5207/7" deviceLabel: WISTAR WSERD40-T Smart Tubular Motor vendorId: 0x1457 productId: 0x0007 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/8" deviceLabel: WISTAR WSERD50-B Smart Tubular Motor vendorId: 0x1457 productId: 0x0008 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5207/9" deviceLabel: WISTAR WSERD50-L Smart Tubular Motor vendorId: 0x1457 productId: 0x0009 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5207/16" deviceLabel: WISTAR WSERD50-T Smart Tubular Motor vendorId: 0x1457 productId: 0x0010 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/19" deviceLabel: WISTAR WSER60 Smart Tubular Motor vendorId: 0x1457 productId: 0x0013 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/17" deviceLabel: WISTAR WSER40 Smart Tubular Motor vendorId: 0x1457 productId: 0x0011 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/18" deviceLabel: WISTAR WSER50 Smart Tubular Motor vendorId: 0x1457 productId: 0x0012 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/2" deviceLabel: WISTAR WSERD30-B Smart Tubular Motor vendorId: 0x1457 productId: 0x0002 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5207/22" deviceLabel: WISTAR WSCMXH Smart Vertical Blind Motor vendorId: 0x1457 productId: 0x0016 - deviceProfileName: window-covering-tilt + deviceProfileName: window-covering-modular - id: "5207/23" deviceLabel: WISTAR WSCMXF Smart Vertical Blind Motor vendorId: 0x1457 productId: 0x0017 - deviceProfileName: window-covering-tilt + deviceProfileName: window-covering-modular - id: "5207/24" deviceLabel: WISTAR WSCMXF-LED Smart Vertical Blind Motor vendorId: 0x1457 productId: 0x0018 - deviceProfileName: window-covering-tilt + deviceProfileName: window-covering-modular - id: "5207/20" deviceLabel: WISTAR WSCMQ Smart Curtain Motor vendorId: 0x1457 productId: 0x0014 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/21" deviceLabel: WISTAR WSCMXI Smart Curtain Motor vendorId: 0x1457 productId: 0x0015 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/32" deviceLabel: WISTAR WSCMT Smart Curtain Motor vendorId: 0x1457 productId: 0x0020 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/34" deviceLabel: WISTAR WSCMXB Smart Curtain Motor vendorId: 0x1457 productId: 0x0022 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/35" deviceLabel: WISTAR WSCMXC Smart Curtain Motor vendorId: 0x1457 productId: 0x0023 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5207/38" deviceLabel: WISTAR WSCMXJ Smart Curtain Motor vendorId: 0x1457 productId: 0x0026 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular #WiZ - id: "WiZ A19" deviceLabel: WiZ A19 @@ -3922,37 +3922,37 @@ matterManufacturer: deviceLabel: Smart WindowCovering Series vendorId: 0x1523 productId: 0x041C - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5411/2660" deviceLabel: Smart WindowCovering Series vendorId: 0x1523 productId: 0x0A64 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5411/2661" deviceLabel: Smart WindowCovering Series vendorId: 0x1523 productId: 0x0A65 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5411/2662" deviceLabel: Smart WindowCovering Series vendorId: 0x1523 productId: 0x0A66 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5411/2663" deviceLabel: Smart WindowCovering Series vendorId: 0x1523 productId: 0x0A67 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5411/2664" deviceLabel: Smart WindowCovering Series vendorId: 0x1523 productId: 0x0A68 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular - id: "5411/2665" deviceLabel: Smart WindowCovering Series vendorId: 0x1523 productId: 0x0A69 - deviceProfileName: window-covering-battery + deviceProfileName: window-covering-modular #Zemismart - id: "5020/61154" deviceLabel: Zemismart Inline Module @@ -4028,42 +4028,42 @@ matterManufacturer: deviceLabel: Zemismart MT01 Slide Curtain vendorId: 0x139C productId: 0xFFFE - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5020/65376" deviceLabel: Zemismart MT25B Roller Motor vendorId: 0x139C productId: 0xFF60 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5020/65296" deviceLabel: Zemismart MT82 Smart Curtain vendorId: 0x139C productId: 0xFF10 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5020/65301" deviceLabel: Zemismart MT25A Thread Roller Motor vendorId: 0x139C productId: 0xFF15 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5020/64050" deviceLabel: Zemismart ZM02 Smart Curtain vendorId: 0x139C productId: 0xFA32 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5020/64017" deviceLabel: Zemismart ZM25C Smart Curtain vendorId: 0x139C productId: 0xFA11 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5020/64049" deviceLabel: Zemismart ZM01 Smart Curtain vendorId: 0x139C productId: 0xFA31 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular - id: "5020/64023" deviceLabel: Zemismart ZM24A Smart Curtain vendorId: 0x139C productId: 0xFA17 - deviceProfileName: window-covering + deviceProfileName: window-covering-modular #Zimi - id: "5410/3" deviceLabel: Zimi Matter Connect @@ -4294,11 +4294,11 @@ matterGeneric: - id: 0x002B # Fan - id: 0x0110 # Mounted Dimmable Load Control deviceProfileName: fan-modular - - id: "windowcovering" + - id: "matter/window/covering" deviceLabel: Matter Window Covering deviceTypes: - id: 0x0202 # Window Covering - deviceProfileName: window-covering + deviceProfileName: window-covering-modular matterThing: - id: SmartThings/MatterThing diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml deleted file mode 100644 index a1896ca634..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-battery.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: window-covering-battery -components: -- id: main - capabilities: - - id: windowShade - version: 1 - - id: windowShadePreset - version: 1 - - id: windowShadeLevel - version: 1 - - id: battery - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Blind -preferences: - - preferenceId: reverse - explicit: true diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml b/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml deleted file mode 100644 index c6a759b610..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering-tilt.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: window-covering-tilt -components: -- id: main - capabilities: - - id: windowShade - version: 1 - - id: windowShadePreset - version: 1 - - id: windowShadeLevel - version: 1 - - id: windowShadeTiltLevel - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Blind -preferences: - - preferenceId: reverse - explicit: true diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering.yml b/drivers/SmartThings/matter-switch/profiles/window-covering.yml deleted file mode 100644 index fbd40ed08d..0000000000 --- a/drivers/SmartThings/matter-switch/profiles/window-covering.yml +++ /dev/null @@ -1,19 +0,0 @@ -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: reverse - explicit: true From 47a52d5b4a02d1e922869ec2b1de3b3b685d1160 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 22 Jan 2026 15:09:41 -0600 Subject: [PATCH 14/17] use extend_device for subscribe function --- .../src/sub_drivers/closures/init.lua | 1 + .../src/test/test_matter_window_covering.lua | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua index 5f0201e49a..4bbfb8e6aa 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua @@ -28,6 +28,7 @@ function ClosureLifecycleHandlers.device_init(driver, device) device:emit_event(capabilities.windowShadePreset.position(preset_position, {visibility = {displayed = false}})) device:set_field(closure_fields.PRESET_LEVEL_KEY, preset_position, {persist = true}) end + device:extend_device("subscribe", switch_utils.subscribe) device:subscribe() end diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua index 68f2652a81..6717028976 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua @@ -94,6 +94,7 @@ local function test_init() set_preset(mock_device) subscribe_request = WindowCovering.server.attributes.OperationalStatus:subscribe(mock_device) + subscribe_request:merge(clusters.PowerSource.server.attributes.AttributeList:subscribe(mock_device)) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) @@ -120,7 +121,8 @@ local CLUSTER_SUBSCRIBE_LIST = { WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, WindowCovering.server.attributes.OperationalStatus, - clusters.PowerSource.server.attributes.BatPercentRemaining + clusters.PowerSource.server.attributes.AttributeList, + clusters.LevelControl.server.attributes.CurrentLevel, } local function update_profile() @@ -133,16 +135,17 @@ local function update_profile() {enabled_optional_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel"}}}} ) test.socket.device_lifecycle:__queue_receive(mock_child:generate_info_changed({ profile = updated_device_profile })) + subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.wait_for_events() updated_device_profile = t_utils.get_profile_definition("window-covering-modular.yml", {enabled_optional_capabilities = {{"main", {"windowShadeLevel", "windowShadeTiltLevel", "battery"}}}} ) test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) - subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end + subscribe_request:merge(clusters.PowerSource.server.attributes.BatPercentRemaining:subscribe(mock_device)) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end @@ -697,6 +700,7 @@ test.register_coroutine_test( for i, attr in ipairs(CLUSTER_SUBSCRIBE_LIST) do if i > 1 then read_request:merge(attr:read(mock_device)) end end + read_request:merge(clusters.PowerSource.server.attributes.BatPercentRemaining:read(mock_device)) test.socket.matter:__expect_send({mock_device.id, read_request}) test.wait_for_events() end From 0fd272e045d153d98f0efaa92524c1d3be0f7e0f Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 22 Jan 2026 15:11:46 -0600 Subject: [PATCH 15/17] remove comment --- .../SmartThings/matter-switch/src/sub_drivers/closures/init.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua index 4bbfb8e6aa..60094681d8 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua @@ -19,8 +19,6 @@ function ClosureLifecycleHandlers.device_init(driver, device) device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then - -- These should only ever be nil once (and at the same time) for already-installed devices - -- It can be removed after migration is complete device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}})) local preset_position = device:get_field(closure_fields.PRESET_LEVEL_KEY) or (device.preferences ~= nil and device.preferences.presetPosition) or From 475172b6ad259df399ea1280b4151674f4a2d269 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 22 Jan 2026 15:21:18 -0600 Subject: [PATCH 16/17] remove subdriver-specific infoChanged handler --- drivers/SmartThings/matter-switch/src/init.lua | 10 ++++++++++ .../src/sub_drivers/closures/init.lua | 15 +-------------- .../src/test/test_matter_window_covering.lua | 1 + 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 9cd013260c..164620955a 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -92,6 +92,16 @@ function SwitchLifecycleHandlers.info_changed(driver, device, event, args) device_cfg.match_profile(driver, device) end end + + if args.old_st_store.preferences.reverse ~= nil and device.preferences.reverse ~= nil and + args.old_st_store.preferences.reverse ~= device.preferences.reverse then + local closure_fields = require "sub_drivers.closures.closure_utils.fields" + if device.preferences.reverse then + device:set_field(closure_fields.REVERSE_POLARITY, true, { persist = true }) + else + device:set_field(closure_fields.REVERSE_POLARITY, false, { persist = true }) + end + end end function SwitchLifecycleHandlers.device_init(driver, device) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua index 60094681d8..b2689a3aed 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/closures/init.lua @@ -36,24 +36,11 @@ function ClosureLifecycleHandlers.device_added(driver, device) switch_utils.handle_electrical_sensor_info(device) end -function ClosureLifecycleHandlers.info_changed(driver, device, event, args) - if device.profile.id ~= args.old_st_store.profile.id then - device:subscribe() - elseif args.old_st_store.preferences.reverse ~= device.preferences.reverse then - if device.preferences.reverse then - device:set_field(closure_fields.REVERSE_POLARITY, true, { persist = true }) - else - device:set_field(closure_fields.REVERSE_POLARITY, false, { persist = true }) - end - end -end - local closures_handler = { NAME = "closures", lifecycle_handlers = { init = ClosureLifecycleHandlers.device_init, - added = ClosureLifecycleHandlers.device_added, - infoChanged = ClosureLifecycleHandlers.info_changed + added = ClosureLifecycleHandlers.device_added }, matter_handlers = { attr = { diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua index 6717028976..106022a8ab 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua @@ -14,6 +14,7 @@ local mock_device = test.mock_device.build_test_matter_device( label = "Matter Window Covering", profile = t_utils.get_profile_definition("window-covering-modular.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, endpoints = { { endpoint_id = 2, From 91917f1ce587ffef36efa3ed5d03f731cf80090a Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 22 Jan 2026 15:49:05 -0600 Subject: [PATCH 17/17] fix refresh --- .../SmartThings/matter-switch/src/switch_utils/fields.lua | 2 ++ .../SmartThings/matter-switch/src/switch_utils/utils.lua | 6 +++++- .../matter-switch/src/test/test_matter_window_covering.lua | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index 772dd9e250..37cd52a0d1 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -84,6 +84,8 @@ SwitchFields.LEVEL_MIN = "__level_min" SwitchFields.LEVEL_MAX = "__level_max" SwitchFields.COLOR_MODE = "__color_mode" +SwitchFields.SUBSCRIBED_ATTRIBUTES_KEY = "__subscribed_attributes" + SwitchFields.updated_fields = { { current_field_name = "__component_to_endpoint_map_button", updated_field_name = SwitchFields.COMPONENT_TO_ENDPOINT_MAP }, { current_field_name = "__switch_intialized", updated_field_name = nil }, diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index ca58e2af74..66edbb8ba4 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -449,7 +449,8 @@ end --- @param subscribed_attributes table key-value pairs mapping capability ids to subscribed attributes --- @param subscribed_events table key-value pairs mapping capability ids to subscribed events function utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, subscribed_attributes, subscribed_events) - for _, component in pairs(checked_device.st_store.profile.components) do + local subscribed_attrs = {} + for _, component in pairs(checked_device.st_store.profile.components) do for _, capability in pairs(component.capabilities) do if not capabilities_seen[capability.id] then for _, attr in ipairs(subscribed_attributes[capability.id] or {}) do @@ -457,6 +458,8 @@ function utils.populate_subscribe_request_for_device(checked_device, subscribe_r local attr_id = attr.ID or attr.attribute if not attributes_seen[cluster_id..attr_id] then local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribed_attrs[cluster_id] = subscribed_attrs[cluster_id] or {} + subscribed_attrs[cluster_id][attr_id] = ib subscribe_request:with_info_block(ib) attributes_seen[cluster_id..attr_id] = true end @@ -474,6 +477,7 @@ function utils.populate_subscribe_request_for_device(checked_device, subscribe_r end end end + checked_device:set_field(fields.SUBSCRIBED_ATTRIBUTES_KEY, subscribed_attrs) end --- create and send a subscription request by checking all devices, accounting for both parent and child devices diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua index 106022a8ab..d135068be2 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_window_covering.lua @@ -122,7 +122,6 @@ local CLUSTER_SUBSCRIBE_LIST = { WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, WindowCovering.server.attributes.OperationalStatus, - clusters.PowerSource.server.attributes.AttributeList, clusters.LevelControl.server.attributes.CurrentLevel, } @@ -140,6 +139,7 @@ local function update_profile() for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end end + subscribe_request:merge(clusters.PowerSource.server.attributes.AttributeList:subscribe(mock_device)) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.wait_for_events() updated_device_profile = t_utils.get_profile_definition("window-covering-modular.yml",