From 5eb5d12b1e3b9949f89bfc34e8f3cadac1270161 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Mon, 5 Jan 2026 14:00:35 -0600 Subject: [PATCH 1/5] Add support for Irrigation System device type This adds support for the Irrigation System device type, introduced with Matter 1.5. --- .../matter-switch/fingerprints.yml | 5 + .../profiles/irrigation-system.yml | 26 ++ .../SmartThings/matter-switch/src/init.lua | 34 +- .../switch_handlers/attribute_handlers.lua | 70 ++++ .../switch_handlers/capability_handlers.lua | 17 + .../src/switch_utils/device_configuration.lua | 103 ++++- .../matter-switch/src/switch_utils/fields.lua | 12 + .../matter-switch/src/switch_utils/utils.lua | 5 + .../test/test_matter_irrigation_system.lua | 352 ++++++++++++++++++ 9 files changed, 616 insertions(+), 8 deletions(-) create mode 100644 drivers/SmartThings/matter-switch/profiles/irrigation-system.yml create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index e418d34d3b..74f1799509 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -3992,6 +3992,11 @@ matterGeneric: deviceTypes: - id: 0x0110 # Mounted Dimmable Load Control deviceProfileName: switch-level + - id: "matter/irrigation-system" + deviceLabel: Matter Irrigation System + deviceTypes: + - id: 0x0040 # Irrigation System + deviceProfileName: irrigation-system - id: "matter/water-valve" deviceLabel: Matter Water Valve deviceTypes: diff --git a/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml new file mode 100644 index 0000000000..7aba31ffd7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml @@ -0,0 +1,26 @@ +name: irrigation-system +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: level + version: 1 + config: + values: + - key: "level.value" + range: [0, 100] + optional: true + - id: flowSensor + version: 1 + optional: true + - id: operationalState + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: IrrigationSystem + diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index e8d1b8330a..06ce68dc7a 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -159,6 +159,11 @@ local matter_driver_template = { [clusters.FanControl.attributes.FanModeSequence.ID] = attribute_handlers.fan_mode_sequence_handler, [clusters.FanControl.attributes.PercentCurrent.ID] = attribute_handlers.percent_current_handler }, + [clusters.FlowMeasurement.ID] = { + [clusters.FlowMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.flow_attr_handler, + [clusters.FlowMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(FLOW_MIN), + [clusters.FlowMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(FLOW_MAX) + }, [clusters.IlluminanceMeasurement.ID] = { [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.illuminance_measured_value_handler }, @@ -170,6 +175,11 @@ local matter_driver_template = { [clusters.OccupancySensing.ID] = { [clusters.OccupancySensing.attributes.Occupancy.ID] = attribute_handlers.occupancy_handler, }, + [clusters.OperationalState.ID] = { + [clusters.OperationalState.attributes.AcceptedCommandList.ID] = attribute_handlers.operational_state_accepted_command_list_attr_handler, + [clusters.OperationalState.attributes.OperationalState.ID] = attribute_handlers.operational_state_attr_handler, + [clusters.OperationalState.attributes.OperationalError.ID] = attribute_handlers.operational_error_attr_handler + }, [clusters.OnOff.ID] = { [clusters.OnOff.attributes.OnOff.ID] = attribute_handlers.on_off_attr_handler, }, @@ -237,17 +247,24 @@ local matter_driver_template = { [capabilities.fanSpeedPercent.ID] = { clusters.FanControl.attributes.PercentCurrent }, + [capabilities.flowMeasurement.ID] = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue + }, [capabilities.illuminanceMeasurement.ID] = { clusters.IlluminanceMeasurement.attributes.MeasuredValue }, - [capabilities.motionSensor.ID] = { - clusters.OccupancySensing.attributes.Occupancy - }, [capabilities.level.ID] = { clusters.ValveConfigurationAndControl.attributes.CurrentLevel }, - [capabilities.switch.ID] = { - clusters.OnOff.attributes.OnOff + [capabilities.motionSensor.ID] = { + clusters.OccupancySensing.attributes.Occupancy + }, + [capabilities.operationalState.ID] = { + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError }, [capabilities.powerMeter.ID] = { clusters.ElectricalPowerMeasurement.attributes.ActivePower @@ -255,6 +272,9 @@ local matter_driver_template = { [capabilities.relativeHumidityMeasurement.ID] = { clusters.RelativeHumidityMeasurement.attributes.MeasuredValue }, + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, [capabilities.switchLevel.ID] = { clusters.LevelControl.attributes.CurrentLevel, clusters.LevelControl.attributes.MaxLevel, @@ -298,6 +318,10 @@ local matter_driver_template = { [capabilities.level.ID] = { [capabilities.level.commands.setLevel.NAME] = capability_handlers.handle_set_level }, + [capabilities.operationalState.ID] = { + [capabilities.operationalState.commands.pause.NAME] = capability_handlers.handle_operational_state_pause, + [capabilities.operationalState.commands.resume.NAME] = capability_handlers.handle_operational_state_resume + }, [capabilities.switch.ID] = { [capabilities.switch.commands.off.NAME] = capability_handlers.handle_switch_off, [capabilities.switch.commands.on.NAME] = capability_handlers.handle_switch_on, 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 ae76709be8..74c0c10404 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -534,4 +534,74 @@ function AttributeHandlers.percent_current_handler(driver, device, ib, response) device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(ib.data.value)) end + +-- [[ OPERATIONAL STATE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.operational_state_accepted_command_list_attr_handler(driver, device, ib, response) + local accepted_command_list = {} + for _, accepted_command in ipairs(ib.data.elements) do + local accepted_command_id = accepted_command.value + if fields.operational_state_command_map[accepted_command_id] ~= nil then + table.insert(accepted_command_list, fields.operational_state_command_map[accepted_command_id]) + end + end + local event = capabilities.operationalState.supportedCommands(accepted_command_list, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.operational_state_attr_handler(driver, device, ib, response) + if ib.data.value == clusters.OperationalState.types.OperationalStateEnum.STOPPED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.stopped()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.RUNNING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.running()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.PAUSED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.paused()) + end +end + +function AttributeHandlers.operational_error_attr_handler(driver, device, ib, response) + if version.api < 10 then + clusters.OperationalState.types.ErrorStateStruct:augment_type(ib.data) + end + local operationalError = ib.data.elements.error_state_id.value + if operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToStartOrResume()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToCompleteOperation()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + end +end + +function AttributeHandlers.flow_attr_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local flow = measured_value / 10.0 + local unit = "m^3/h" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flow({value = flow, unit = unit})) + end +end + +function AttributeHandlers.flow_attr_handler_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local flow_bound = ib.data.value / 10.0 + local unit = "m^3/h" + set_field_for_endpoint(device, FLOW_BOUND_RECEIVED..minOrMax, ib.endpoint_id, flow_bound) + local min = get_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MIN, ib.endpoint_id) + local max = get_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flowRange({ value = { minimum = min, maximum = max }, unit = unit })) + set_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MIN, ib.endpoint_id, nil) + set_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min flow measurement %d that is not lower than the reported max flow measurement %d", min, max)) + end + end + end +end + return AttributeHandlers diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua index f96686b73a..ec127845c4 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua @@ -189,4 +189,21 @@ function CapabilityHandlers.handle_reset_energy_meter(driver, device, cmd) end end + +-- [[ OPERATIONAL STATE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_operational_state_resume(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Resume(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +function CapabilityHandlers.handle_operational_state_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Pause(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + return CapabilityHandlers 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 8542972320..8994d3a78b 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 ValveDeviceConfiguration = {} 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,48 @@ function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep end end +function ValveDeviceConfiguration.assign_profile_for_valve_ep(device, valve_ep_id) + local profile = "water-valve" + + for _, ep in ipairs(device.endpoints) do + if ep.endpoint_id == valve_ep_id then + if switch_utils.find_cluster_on_ep(ep, clusters.ValveConfigurationAndControl.ID) then + for _, cluster in ipairs(ep.clusters) do + if cluster.cluster_id == clusters.ValveConfigurationAndControl.ID and + cluster.feature_map and (cluster.feature_map & clusters.ValveConfigurationAndControl.types.Feature.LEVEL) ~= 0 then + profile = profile .. "-level" + break + end + end + end + break + end + end + + return profile +end + +function ValveDeviceConfiguration.create_child_devices(driver, device, valve_ep_ids, default_endpoint_id) + for device_num, ep_id in ipairs(valve_ep_ids) do + local label_and_name = string.format("%s Valve %d", device.label, device_num) + local child_profile = ValveDeviceConfiguration.assign_profile_for_valve_ep(device, ep_id) + driver:try_create_device( + { + type = "EDGE_CHILD", + label = label_and_name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%d", ep_id), + vendor_provided_label = label_and_name + } + ) + end + + -- Persist so that the find_child function is always set on each driver init. + device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) + device:set_find_child(switch_utils.find_child) +end + -- [[ PROFILE MATCHING AND CONFIGURATIONS ]] -- @@ -206,7 +248,61 @@ function DeviceConfiguration.match_profile(driver, device) local optional_component_capabilities local updated_profile - if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID) > 0 then + local is_irrigation_system = false + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM then + is_irrigation_system = true + break + end + end + end + + local valve_ep_ids = device:get_endpoints(clusters.ValveConfigurationAndControl.ID) + + if is_irrigation_system then + updated_profile = "irrigation-system" + if version.api >= 14 and version.rpc >= 8 then + local main_component_capabilities = {} + local optional_supported_component_capabilities = {} + local MAIN_COMPONENT_IDX, CAPABILITIES_LIST_IDX = 1, 2 + + table.sort(valve_ep_ids) + local main_valve_ep = switch_utils.get_endpoint_info(device, valve_ep_ids[1]) + for _, cluster in ipairs(main_valve_ep.clusters) do + if cluster.cluster_id == clusters.ValveConfigurationAndControl.ID and + cluster.feature_map and (cluster.feature_map & clusters.ValveConfigurationAndControl.types.Feature.LEVEL) ~= 0 then + table.insert(main_component_capabilities, capabilities.level.ID) + elseif cluster.cluster_id == clusters.OperationalState.ID then + table.insert(main_component_capabilities, capabilities.operationalState.ID) + elseif cluster.cluster_id == clusters.FlowMeasurement.ID then + table.insert(main_component_capabilities, capabilities.flowSensor.ID) + end + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. + -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. + if version.api < 15 or version.rpc < 9 then + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) + + device:set_field(fields.SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + end + else + device:try_update_metadata({profile = profile_name}) + end + + if #valve_ep_ids > 1 then + table.remove(valve_ep_ids, 1) -- the first valve ep is accounted for in the main irrigation system profile + ValveDeviceConfiguration.create_child_devices(driver, device, valve_ep_ids, default_endpoint_id) + end + elseif #valve_ep_ids > 0 then updated_profile = "water-valve" if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then @@ -256,5 +352,6 @@ end return { DeviceCfg = DeviceConfiguration, SwitchCfg = SwitchDeviceConfiguration, - ButtonCfg = ButtonDeviceConfiguration + ButtonCfg = ButtonDeviceConfiguration, + ValveCfg = ValveDeviceConfiguration } diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index db66c2965c..bb6244e22f 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -30,9 +30,11 @@ SwitchFields.DEVICE_TYPE_ID = { ELECTRICAL_SENSOR = 0x0510, FAN = 0x002B, GENERIC_SWITCH = 0x000F, + IRRIGATION_SYSTEM = 0x0040, MOUNTED_ON_OFF_CONTROL = 0x010F, MOUNTED_DIMMABLE_LOAD_CONTROL = 0x0110, ON_OFF_PLUG_IN_UNIT = 0x010A, + WATER_VALVE = 0x0042, LIGHT = { ON_OFF = 0x0100, DIMMABLE = 0x0101, @@ -82,6 +84,11 @@ SwitchFields.LEVEL_BOUND_RECEIVED = "__level_bound_received" SwitchFields.LEVEL_MIN = "__level_min" SwitchFields.LEVEL_MAX = "__level_max" SwitchFields.COLOR_MODE = "__color_mode" +SwitchFields.FLOW_BOUND_RECEIVED = "__flow_bound_received" +SwitchFields.FLOW_MIN = "__flow_min" +SwitchFields.FLOW_MAX = "__flow_max" + +SwitchFields.SUPPORTED_COMPONENT_CAPABILITIES = "__supported_component_capabilities" SwitchFields.SUBSCRIBED_ATTRIBUTES_KEY = "__subscribed_attributes" @@ -134,6 +141,11 @@ SwitchFields.switch_category_vendor_overrides = { {0xEEE2, 0xAB08, 0xAB31, 0xAB04, 0xAB01, 0xAB43, 0xAB02, 0xAB03, 0xAB05} } +SwitchFields.operational_state_command_map = { + [clusters.OperationalState.commands.Pause.ID] = "pause", + [clusters.OperationalState.commands.Resume.ID] = "resume" +} + --- stores a table of endpoints that support the Electrical Sensor device type, used during profiling --- in AvailableEndpoints and PartsList handlers for SET and TREE PowerTopology features, respectively SwitchFields.ELECTRICAL_SENSOR_EPS = "__electrical_sensor_eps" diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index da5376f031..e2f2b55f79 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -193,6 +193,11 @@ function utils.find_default_endpoint(device) end end + local water_valve_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) + if #water_valve_eps > 0 then + return get_first_non_zero_endpoint(water_valve_eps) + 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_irrigation_system.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua new file mode 100644 index 0000000000..8c049e0943 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua @@ -0,0 +1,352 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 11 then + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +local endpoints = { + ROOT_EP = 0, + IRRIGATION_SYSTEM_EP = 1, + VALVE_1_EP = 2, + VALVE_2_EP = 3, + VALVE_3_EP = 4 +} + +-- Mock device representing an irrigation system with 3 valve endpoints +local mock_irrigation_system = test.mock_device.build_test_matter_device({ + label = "Matter Irrigation System", + profile = t_utils.get_profile_definition("irrigation-system.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = endpoints.ROOT_EP, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = endpoints.IRRIGATION_SYSTEM_EP, + clusters = { + {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0040, device_type_revision = 1} -- Irrigation System + } + }, + { + endpoint_id = endpoints.VALVE_1_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + }, + { + endpoint_id = endpoints.VALVE_2_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + }, + { + endpoint_id = endpoints.VALVE_3_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + } + } +}) + +local mock_children = {} +for i, endpoint in ipairs(mock_irrigation_system.endpoints) do + if endpoint.endpoint_id == 3 or endpoint.endpoint_id == 4 then + local child_data = { + profile = t_utils.get_profile_definition("irrigation-system.yml"), + device_network_id = string.format("%s:%d", mock_irrigation_system.id, endpoint.endpoint_id), + parent_device_id = mock_irrigation_system.id, + parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) + } + mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) + end +end + +local function test_init() + test.mock_device.add_test_device(mock_irrigation_system) + local cluster_subscribe_list = { + clusters.ValveConfigurationAndControl.attributes.CurrentState, + clusters.ValveConfigurationAndControl.attributes.CurrentLevel + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_irrigation_system) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_irrigation_system)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "added" }) + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "init" }) + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end + for i = 3,4 do + mock_irrigation_system:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("Matter Irrigation System Valve %d", i - 2), + profile = "water-valve-level", + parent_device_id = mock_irrigation_system.id, + parent_assigned_child_key = string.format("%d", i) + }) + end + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "doConfigure" }) + mock_irrigation_system:expect_metadata_update({ profile = "irrigation-system" }) + mock_irrigation_system:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Parent device: Open command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "open", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP) + } + } + } +) + +test.register_message_test( + "Parent device: Close command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "close", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_1_EP) + } + } + } +) + +test.register_message_test( + "Parent device: Set level command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_irrigation_system.id, + { capability = "level", component = "main", command = "setLevel", args = { 75 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP, nil, 75) + } + } + } +) + +test.register_message_test( + "Parent device: Current state closed should generate closed event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data(mock_irrigation_system, endpoints.VALVE_1_EP, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_irrigation_system:generate_test_message("main", capabilities.valve.valve.closed()) + }, + } +) + +test.register_message_test( + "Parent device: Current level reports should generate appropriate events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data(mock_irrigation_system, endpoints.VALVE_1_EP, 60) + } + }, + { + channel = "capability", + direction = "send", + message = mock_irrigation_system:generate_test_message("main", capabilities.level.level(60)) + }, + } +) + +test.register_message_test( + "Child device valve 2: Open command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_children[endpoints.VALVE_2_EP].id, + { capability = "valve", component = "main", command = "open", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP) + } + } + } +) + +test.register_message_test( + "Child device valve 2: Set level command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_children[endpoints.VALVE_2_EP].id, + { capability = "level", component = "main", command = "setLevel", args = { 40 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP, nil, 40) + } + } + } +) + +test.register_message_test( + "Child device valve 2: Current state closed should generate closed event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data(mock_irrigation_system, endpoints.VALVE_2_EP, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[endpoints.VALVE_2_EP]:generate_test_message("main", capabilities.valve.valve.closed()) + }, + } +) + +test.register_message_test( + "Child device valve 3: Close command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_children[endpoints.VALVE_3_EP].id, + { capability = "valve", component = "main", command = "close", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_3_EP) + } + } + } +) + +test.register_message_test( + "Child device valve 3: Current level reports should generate appropriate events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data(mock_irrigation_system, endpoints.VALVE_3_EP, 100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[endpoints.VALVE_3_EP]:generate_test_message("main", capabilities.level.level(100)) + }, + } +) + +test.run_registered_tests() + From 70d2a00eac45f25f5e0cafa76c4b4088192053ca Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 4 Feb 2026 11:08:12 -0600 Subject: [PATCH 2/5] rebasing and fixing tests and child device creation logic --- .../SmartThings/matter-switch/src/init.lua | 6 +- .../switch_handlers/attribute_handlers.lua | 10 +- .../src/switch_utils/device_configuration.lua | 10 +- .../matter-switch/src/switch_utils/fields.lua | 2 + .../test/test_matter_irrigation_system.lua | 373 +++++++++--------- 5 files changed, 197 insertions(+), 204 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 06ce68dc7a..7fec35f3d1 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -161,8 +161,8 @@ local matter_driver_template = { }, [clusters.FlowMeasurement.ID] = { [clusters.FlowMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.flow_attr_handler, - [clusters.FlowMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(FLOW_MIN), - [clusters.FlowMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(FLOW_MAX) + [clusters.FlowMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(fields.FLOW_MIN), + [clusters.FlowMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(fields.FLOW_MAX) }, [clusters.IlluminanceMeasurement.ID] = { [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.illuminance_measured_value_handler @@ -348,6 +348,7 @@ local matter_driver_template = { capabilities.energyMeter, capabilities.fanMode, capabilities.fanSpeedPercent, + capabilities.flowSensor, capabilities.hdr, capabilities.illuminanceMeasurement, capabilities.imageControl, @@ -356,6 +357,7 @@ local matter_driver_template = { capabilities.mechanicalPanTiltZoom, capabilities.motionSensor, capabilities.nightVision, + capabilities.operationalState, capabilities.powerMeter, capabilities.powerConsumptionReport, capabilities.relativeHumidityMeasurement, 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 74c0c10404..48d7a3c383 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -589,14 +589,14 @@ function AttributeHandlers.flow_attr_handler_factory(minOrMax) end local flow_bound = ib.data.value / 10.0 local unit = "m^3/h" - set_field_for_endpoint(device, FLOW_BOUND_RECEIVED..minOrMax, ib.endpoint_id, flow_bound) - local min = get_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MIN, ib.endpoint_id) - local max = get_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MAX, ib.endpoint_id) + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..minOrMax, ib.endpoint_id, flow_bound) + local min = switch_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id) if min ~= nil and max ~= nil then if min < max then device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flowRange({ value = { minimum = min, maximum = max }, unit = unit })) - set_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MIN, ib.endpoint_id, nil) - set_field_for_endpoint(device, FLOW_BOUND_RECEIVED..FLOW_MAX, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id, nil) else device.log.warn_with({hub_logs = true}, string.format("Device reported a min flow measurement %d that is not lower than the reported max flow measurement %d", min, max)) 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 8994d3a78b..e07a06e419 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -264,7 +264,6 @@ function DeviceConfiguration.match_profile(driver, device) updated_profile = "irrigation-system" if version.api >= 14 and version.rpc >= 8 then local main_component_capabilities = {} - local optional_supported_component_capabilities = {} local MAIN_COMPONENT_IDX, CAPABILITIES_LIST_IDX = 1, 2 table.sort(valve_ep_ids) @@ -280,22 +279,19 @@ function DeviceConfiguration.match_profile(driver, device) end end - table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) - - device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + optional_component_capabilities = {} + table.insert(optional_component_capabilities, {"main", main_component_capabilities}) -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. if version.api < 15 or version.rpc < 9 then -- add mandatory capabilities for subscription - local total_supported_capabilities = optional_supported_component_capabilities + local total_supported_capabilities = optional_component_capabilities table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) device:set_field(fields.SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) end - else - device:try_update_metadata({profile = profile_name}) end if #valve_ep_ids > 1 then diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index bb6244e22f..d785b02453 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -1,6 +1,8 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local clusters = require "st.matter.clusters" + local SwitchFields = {} SwitchFields.MOST_RECENT_TEMP = "mostRecentTemp" diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua index 8c049e0943..8dbb8f8291 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua @@ -1,11 +1,10 @@ --- Copyright © 2025 SmartThings, Inc. +-- Copyright © 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local test = require "integration_test" local capabilities = require "st.capabilities" -local t_utils = require "integration_test.utils" - local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" local version = require "version" if version.api < 11 then @@ -24,10 +23,8 @@ local endpoints = { local mock_irrigation_system = test.mock_device.build_test_matter_device({ label = "Matter Irrigation System", profile = t_utils.get_profile_definition("irrigation-system.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, endpoints = { { endpoint_id = endpoints.ROOT_EP, @@ -96,7 +93,7 @@ local mock_children = {} for i, endpoint in ipairs(mock_irrigation_system.endpoints) do if endpoint.endpoint_id == 3 or endpoint.endpoint_id == 4 then local child_data = { - profile = t_utils.get_profile_definition("irrigation-system.yml"), + profile = t_utils.get_profile_definition("water-valve-level.yml"), device_network_id = string.format("%s:%d", mock_irrigation_system.id, endpoint.endpoint_id), parent_device_id = mock_irrigation_system.id, parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) @@ -105,13 +102,15 @@ for i, endpoint in ipairs(mock_irrigation_system.endpoints) do end end +local subscribe_request + local function test_init() test.mock_device.add_test_device(mock_irrigation_system) local cluster_subscribe_list = { clusters.ValveConfigurationAndControl.attributes.CurrentState, - clusters.ValveConfigurationAndControl.attributes.CurrentLevel + clusters.ValveConfigurationAndControl.attributes.CurrentLevel, } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_irrigation_system) + subscribe_request = cluster_subscribe_list[1]:subscribe(mock_irrigation_system) for i, cluster in ipairs(cluster_subscribe_list) do if i > 1 then subscribe_request:merge(cluster:subscribe(mock_irrigation_system)) @@ -134,218 +133,212 @@ local function test_init() }) end test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "doConfigure" }) - mock_irrigation_system:expect_metadata_update({ profile = "irrigation-system" }) - mock_irrigation_system:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.set_test_init_function(test_init) -test.register_message_test( - "Parent device: Open command should send the appropriate commands", - { + +local additional_subscribed_attributes = { +} + +local expected_metadata = { + optional_component_capabilities = { { - channel = "capability", - direction = "receive", - message = { - mock_irrigation_system.id, - { capability = "valve", component = "main", command = "open", args = { } } + "main", + { + "level", } }, - { - channel = "matter", - direction = "send", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP) - } - } - } + }, + profile = "irrigation-system" +} + +local function update_device_profile() + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "doConfigure" }) + mock_irrigation_system:expect_metadata_update(expected_metadata) + mock_irrigation_system:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local updated_device_profile = t_utils.get_profile_definition( + "irrigation-system.yml", { enabled_optional_capabilities = expected_metadata.optional_component_capabilities } + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_irrigation_system:generate_info_changed({ profile = updated_device_profile })) + for _, attr in ipairs(additional_subscribed_attributes) do + subscribe_request:merge(attr:subscribe(mock_irrigation_system)) + end + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) +end + +test.register_coroutine_test( + "Parent device: Open command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "open", args = { } } + }) + + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP) + }) + end ) -test.register_message_test( +test.register_coroutine_test( "Parent device: Close command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_irrigation_system.id, - { capability = "valve", component = "main", command = "close", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_1_EP) - } - } - } + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "close", args = { } } + }) + + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_1_EP) + }) + end ) -test.register_message_test( +test.register_coroutine_test( "Parent device: Set level command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_irrigation_system.id, - { capability = "level", component = "main", command = "setLevel", args = { 75 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP, nil, 75) - } - } - } + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "level", component = "main", command = "setLevel", args = { 75 } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP, nil, 75) + }) + end ) -test.register_message_test( +test.register_coroutine_test( "Parent device: Current state closed should generate closed event", - { - { - channel = "matter", - direction = "receive", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data(mock_irrigation_system, endpoints.VALVE_1_EP, 0) - } - }, - { - channel = "capability", - direction = "send", - message = mock_irrigation_system:generate_test_message("main", capabilities.valve.valve.closed()) - }, - } + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_1_EP, + 0 + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.valve.valve.closed()) + ) + end ) -test.register_message_test( +test.register_coroutine_test( "Parent device: Current level reports should generate appropriate events", - { - { - channel = "matter", - direction = "receive", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data(mock_irrigation_system, endpoints.VALVE_1_EP, 60) - } - }, - { - channel = "capability", - direction = "send", - message = mock_irrigation_system:generate_test_message("main", capabilities.level.level(60)) - }, - } + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_1_EP, + 60 + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.level.level(60)) + ) + end ) -test.register_message_test( +test.register_coroutine_test( "Child device valve 2: Open command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[endpoints.VALVE_2_EP].id, - { capability = "valve", component = "main", command = "open", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP) - } - } - } + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_2_EP].id, + { capability = "valve", component = "main", command = "open", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP) + }) + end ) -test.register_message_test( +test.register_coroutine_test( "Child device valve 2: Set level command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[endpoints.VALVE_2_EP].id, - { capability = "level", component = "main", command = "setLevel", args = { 40 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP, nil, 40) - } - } - } + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_2_EP].id, + { capability = "level", component = "main", command = "setLevel", args = { 40 } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP, nil, 40) + }) + end ) -test.register_message_test( +test.register_coroutine_test( "Child device valve 2: Current state closed should generate closed event", - { - { - channel = "matter", - direction = "receive", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data(mock_irrigation_system, endpoints.VALVE_2_EP, 0) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[endpoints.VALVE_2_EP]:generate_test_message("main", capabilities.valve.valve.closed()) - }, - } + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_2_EP, + 0 + ) + }) + test.socket.capability:__expect_send( + mock_children[endpoints.VALVE_2_EP]:generate_test_message("main", capabilities.valve.valve.closed()) + ) + end ) -test.register_message_test( +test.register_coroutine_test( "Child device valve 3: Close command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[endpoints.VALVE_3_EP].id, - { capability = "valve", component = "main", command = "close", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_3_EP) - } - } - } + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_3_EP].id, + { capability = "valve", component = "main", command = "close", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_3_EP) + }) + end ) -test.register_message_test( +test.register_coroutine_test( "Child device valve 3: Current level reports should generate appropriate events", - { - { - channel = "matter", - direction = "receive", - message = { - mock_irrigation_system.id, - clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data(mock_irrigation_system, endpoints.VALVE_3_EP, 100) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[endpoints.VALVE_3_EP]:generate_test_message("main", capabilities.level.level(100)) - }, - } + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_3_EP, + 100 + ) + }) + test.socket.capability:__expect_send( + mock_children[endpoints.VALVE_3_EP]:generate_test_message("main", capabilities.level.level(100)) + ) + end ) test.run_registered_tests() From 07eba5141bab3d4dab3653c551bc303c7106cb90 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 4 Feb 2026 11:48:17 -0600 Subject: [PATCH 3/5] update device config logic based on review feedback --- .../src/switch_utils/device_configuration.lua | 115 +++++------------- .../test/test_matter_irrigation_system.lua | 2 +- 2 files changed, 32 insertions(+), 85 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 e07a06e419..3b2c3a6f8d 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -24,7 +24,7 @@ local ValveDeviceConfiguration = {} 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 - return + return end table.sort(server_cluster_ep_ids) @@ -187,46 +187,36 @@ function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep end end -function ValveDeviceConfiguration.assign_profile_for_valve_ep(device, valve_ep_id) - local profile = "water-valve" +function ValveDeviceConfiguration.assign_profile_for_valve_ep(device, valve_ep_id, is_child_device) + local profile_name = "irrigation-system" + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} - for _, ep in ipairs(device.endpoints) do - if ep.endpoint_id == valve_ep_id then - if switch_utils.find_cluster_on_ep(ep, clusters.ValveConfigurationAndControl.ID) then - for _, cluster in ipairs(ep.clusters) do - if cluster.cluster_id == clusters.ValveConfigurationAndControl.ID and - cluster.feature_map and (cluster.feature_map & clusters.ValveConfigurationAndControl.types.Feature.LEVEL) ~= 0 then - profile = profile .. "-level" - break - end - end - end - break + if is_child_device then + profile_name = "water-valve" + if switch_utils.find_cluster_on_ep( + switch_utils.get_endpoint_info(device, valve_ep_id), + clusters.ValveConfigurationAndControl.ID, + {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL} + ) then + profile_name = profile_name .. "-level" end + return profile_name end - return profile -end - -function ValveDeviceConfiguration.create_child_devices(driver, device, valve_ep_ids, default_endpoint_id) - for device_num, ep_id in ipairs(valve_ep_ids) do - local label_and_name = string.format("%s Valve %d", device.label, device_num) - local child_profile = ValveDeviceConfiguration.assign_profile_for_valve_ep(device, ep_id) - driver:try_create_device( - { - type = "EDGE_CHILD", - label = label_and_name, - profile = child_profile, - parent_device_id = device.id, - parent_assigned_child_key = string.format("%d", ep_id), - vendor_provided_label = label_and_name - } - ) + local valve_ep_info = switch_utils.get_endpoint_info(device, valve_ep_id) + for _, cluster in ipairs(valve_ep_info.clusters) do + if cluster.cluster_id == clusters.ValveConfigurationAndControl.ID and + cluster.feature_map and (cluster.feature_map & clusters.ValveConfigurationAndControl.types.Feature.LEVEL) ~= 0 then + table.insert(main_component_capabilities, capabilities.level.ID) + elseif cluster.cluster_id == clusters.OperationalState.ID then + table.insert(main_component_capabilities, capabilities.operationalState.ID) + elseif cluster.cluster_id == clusters.FlowMeasurement.ID then + table.insert(main_component_capabilities, capabilities.flowSensor.ID) + end end - - -- Persist so that the find_child function is always set on each driver init. - device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) - device:set_find_child(switch_utils.find_child) + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + return profile_name, optional_supported_component_capabilities end @@ -248,55 +238,13 @@ function DeviceConfiguration.match_profile(driver, device) local optional_component_capabilities local updated_profile - local is_irrigation_system = false - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM then - is_irrigation_system = true - break - end - end - end - + local irrigation_system_device_type_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM) local valve_ep_ids = device:get_endpoints(clusters.ValveConfigurationAndControl.ID) - - if is_irrigation_system then - updated_profile = "irrigation-system" - if version.api >= 14 and version.rpc >= 8 then - local main_component_capabilities = {} - local MAIN_COMPONENT_IDX, CAPABILITIES_LIST_IDX = 1, 2 - - table.sort(valve_ep_ids) - local main_valve_ep = switch_utils.get_endpoint_info(device, valve_ep_ids[1]) - for _, cluster in ipairs(main_valve_ep.clusters) do - if cluster.cluster_id == clusters.ValveConfigurationAndControl.ID and - cluster.feature_map and (cluster.feature_map & clusters.ValveConfigurationAndControl.types.Feature.LEVEL) ~= 0 then - table.insert(main_component_capabilities, capabilities.level.ID) - elseif cluster.cluster_id == clusters.OperationalState.ID then - table.insert(main_component_capabilities, capabilities.operationalState.ID) - elseif cluster.cluster_id == clusters.FlowMeasurement.ID then - table.insert(main_component_capabilities, capabilities.flowSensor.ID) - end - end - - optional_component_capabilities = {} - table.insert(optional_component_capabilities, {"main", main_component_capabilities}) - - -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. - -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. - if version.api < 15 or version.rpc < 9 then - -- add mandatory capabilities for subscription - local total_supported_capabilities = optional_component_capabilities - table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) - table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) - - device:set_field(fields.SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) - end - end - + if #irrigation_system_device_type_ep_ids > 0 then + updated_profile, optional_component_capabilities = ValveDeviceConfiguration.assign_profile_for_valve_ep(device, default_endpoint_id, false) + device:set_field(fields.MODULAR_PROFILE_UPDATED, true) if #valve_ep_ids > 1 then - table.remove(valve_ep_ids, 1) -- the first valve ep is accounted for in the main irrigation system profile - ValveDeviceConfiguration.create_child_devices(driver, device, valve_ep_ids, default_endpoint_id) + ChildConfiguration.create_or_update_child_devices(driver, device, valve_ep_ids, default_endpoint_id, ValveDeviceConfiguration.assign_profile_for_valve_ep) end elseif #valve_ep_ids > 0 then updated_profile = "water-valve" @@ -349,5 +297,4 @@ return { DeviceCfg = DeviceConfiguration, SwitchCfg = SwitchDeviceConfiguration, ButtonCfg = ButtonDeviceConfiguration, - ValveCfg = ValveDeviceConfiguration } diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua index 8dbb8f8291..e8ba635100 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua @@ -126,7 +126,7 @@ local function test_init() for i = 3,4 do mock_irrigation_system:expect_device_create({ type = "EDGE_CHILD", - label = string.format("Matter Irrigation System Valve %d", i - 2), + label = string.format("Matter Irrigation System %d", i - 1), profile = "water-valve-level", parent_device_id = mock_irrigation_system.id, parent_assigned_child_key = string.format("%d", i) From a78d4353dc3b08a602e067711806cf6d5423d2c4 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 4 Feb 2026 12:21:29 -0600 Subject: [PATCH 4/5] fix flowMeasurement capability name, add flow sensor test cases --- .../profiles/irrigation-system.yml | 2 +- .../SmartThings/matter-switch/src/init.lua | 2 +- .../src/switch_utils/device_configuration.lua | 17 ++++-- .../test/test_matter_irrigation_system.lua | 55 ++++++++++++++++++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml index 7aba31ffd7..03321887d7 100644 --- a/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml +++ b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml @@ -11,7 +11,7 @@ components: - key: "level.value" range: [0, 100] optional: true - - id: flowSensor + - id: flowMeasurement version: 1 optional: true - id: operationalState diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 7fec35f3d1..61f6e64bca 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -348,7 +348,7 @@ local matter_driver_template = { capabilities.energyMeter, capabilities.fanMode, capabilities.fanSpeedPercent, - capabilities.flowSensor, + capabilities.flowMeasurement, capabilities.hdr, capabilities.illuminanceMeasurement, capabilities.imageControl, 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 3b2c3a6f8d..c762098134 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -209,10 +209,19 @@ function ValveDeviceConfiguration.assign_profile_for_valve_ep(device, valve_ep_i if cluster.cluster_id == clusters.ValveConfigurationAndControl.ID and cluster.feature_map and (cluster.feature_map & clusters.ValveConfigurationAndControl.types.Feature.LEVEL) ~= 0 then table.insert(main_component_capabilities, capabilities.level.ID) - elseif cluster.cluster_id == clusters.OperationalState.ID then - table.insert(main_component_capabilities, capabilities.operationalState.ID) - elseif cluster.cluster_id == clusters.FlowMeasurement.ID then - table.insert(main_component_capabilities, capabilities.flowSensor.ID) + break + end + end + + local irrigation_system_device_type_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM) + if #irrigation_system_device_type_ep_ids > 0 then + local irrigation_system_ep_info = switch_utils.get_endpoint_info(device, irrigation_system_device_type_ep_ids[1]) + for _, cluster in ipairs(irrigation_system_ep_info.clusters) do + if cluster.cluster_id == clusters.OperationalState.ID then + table.insert(main_component_capabilities, capabilities.operationalState.ID) + elseif cluster.cluster_id == clusters.FlowMeasurement.ID then + table.insert(main_component_capabilities, capabilities.flowMeasurement.ID) + end end end table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua index e8ba635100..9d7a2188f4 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua @@ -39,6 +39,8 @@ local mock_irrigation_system = test.mock_device.build_test_matter_device({ endpoint_id = endpoints.IRRIGATION_SYSTEM_EP, clusters = { {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.FlowMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER"}, }, device_types = { {device_type_id = 0x0040, device_type_revision = 1} -- Irrigation System @@ -90,7 +92,7 @@ local mock_irrigation_system = test.mock_device.build_test_matter_device({ }) local mock_children = {} -for i, endpoint in ipairs(mock_irrigation_system.endpoints) do +for _, endpoint in ipairs(mock_irrigation_system.endpoints) do if endpoint.endpoint_id == 3 or endpoint.endpoint_id == 4 then local child_data = { profile = t_utils.get_profile_definition("water-valve-level.yml"), @@ -138,6 +140,12 @@ test.set_test_init_function(test_init) local additional_subscribed_attributes = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.OperationalState, } local expected_metadata = { @@ -146,6 +154,8 @@ local expected_metadata = { "main", { "level", + "flowMeasurement", + "operationalState", } }, }, @@ -255,6 +265,49 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "Flow reports should generate correct messages", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_irrigation_system, 1, 20 * 10) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message( + "main", + capabilities.flowMeasurement.flow({ value = 20.0, unit = "m^3/h" }) + ) + ) + end +) + +test.register_coroutine_test( + "Min and max flow attributes set capability constraint", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_irrigation_system, 1, 20) + }) + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_irrigation_system, 1, 5000) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message( + "main", + capabilities.flowMeasurement.flowRange({ + value = { minimum = 2.0, maximum = 500.0 }, + unit = "m^3/h" + }) + ) + ) + end +) + test.register_coroutine_test( "Child device valve 2: Open command should send the appropriate commands", function() From af4590d93ab7ebb1ff2ebca9108d6312ef0d2001 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Wed, 4 Feb 2026 14:00:43 -0600 Subject: [PATCH 5/5] add operationalState unit tests --- .../src/switch_utils/device_configuration.lua | 6 +-- .../test/test_matter_irrigation_system.lua | 37 +++++++++++++++++++ 2 files changed, 40 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 c762098134..aa26e09fec 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -217,10 +217,10 @@ function ValveDeviceConfiguration.assign_profile_for_valve_ep(device, valve_ep_i if #irrigation_system_device_type_ep_ids > 0 then local irrigation_system_ep_info = switch_utils.get_endpoint_info(device, irrigation_system_device_type_ep_ids[1]) for _, cluster in ipairs(irrigation_system_ep_info.clusters) do - if cluster.cluster_id == clusters.OperationalState.ID then + if cluster.cluster_id == clusters.FlowMeasurement.ID then + table.insert(main_component_capabilities, capabilities.flowMeasurement.ID) + elseif cluster.cluster_id == clusters.OperationalState.ID then table.insert(main_component_capabilities, capabilities.operationalState.ID) - elseif cluster.cluster_id == clusters.FlowMeasurement.ID then - table.insert(main_component_capabilities, capabilities.flowMeasurement.ID) end end end diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua index 9d7a2188f4..4b68728f20 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua @@ -394,5 +394,42 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "OperationalState attribute running should generate running event", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data( + mock_irrigation_system, + endpoints.IRRIGATION_SYSTEM_EP, + clusters.OperationalState.types.OperationalStateEnum.RUNNING + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.operationalState.operationalState.running()) + ) + end +) + +test.register_coroutine_test( + "OperationalState OperationalError UNABLE_TO_COMPLETE_OPERATION should generate unableToCompleteOperation event", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data( + mock_irrigation_system, + endpoints.IRRIGATION_SYSTEM_EP, { error_state_id = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION } + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.operationalState.operationalState.unableToCompleteOperation()) + ) + end +) + test.run_registered_tests()