Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions drivers/SmartThings/matter-switch/fingerprints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions drivers/SmartThings/matter-switch/profiles/irrigation-system.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: irrigation-system
components:
- id: main
capabilities:
- id: valve
version: 1
- id: level
Copy link
Contributor Author

@nickolas-deboom nickolas-deboom Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that in 66bf122 I reimplemented level as an optional capabilty in this profile (as well as added flowSensor + operationalState). I think this makes sense but let me know if you agree with this approach or not

version: 1
config:
values:
- key: "level.value"
range: [0, 100]
optional: true
- id: flowMeasurement
version: 1
optional: true
- id: operationalState
version: 1
optional: true
- id: firmwareUpdate
version: 1
- id: refresh
version: 1
categories:
- name: IrrigationSystem

36 changes: 31 additions & 5 deletions drivers/SmartThings/matter-switch/src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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(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
},
Expand All @@ -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,
},
Expand Down Expand Up @@ -237,24 +247,34 @@ 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
},
[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,
Expand Down Expand Up @@ -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,
Expand All @@ -324,6 +348,7 @@ local matter_driver_template = {
capabilities.energyMeter,
capabilities.fanMode,
capabilities.fanSpeedPercent,
capabilities.flowMeasurement,
capabilities.hdr,
capabilities.illuminanceMeasurement,
capabilities.imageControl,
Expand All @@ -332,6 +357,7 @@ local matter_driver_template = {
capabilities.mechanicalPanTiltZoom,
capabilities.motionSensor,
capabilities.nightVision,
capabilities.operationalState,
capabilities.powerMeter,
capabilities.powerConsumptionReport,
capabilities.relativeHumidityMeasurement,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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 }))
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
end
end
end

return AttributeHandlers
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ 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
return
return
end

table.sort(server_cluster_ep_ids)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -187,6 +187,47 @@ function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep
end
end

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 = {}

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

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)
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.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)
end
end
end
table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities})
return profile_name, optional_supported_component_capabilities
end


-- [[ PROFILE MATCHING AND CONFIGURATIONS ]] --

Expand All @@ -206,7 +247,15 @@ 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 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 #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
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"
if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID,
{feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then
Expand Down Expand Up @@ -256,5 +305,5 @@ end
return {
DeviceCfg = DeviceConfiguration,
SwitchCfg = SwitchDeviceConfiguration,
ButtonCfg = ButtonDeviceConfiguration
ButtonCfg = ButtonDeviceConfiguration,
}
14 changes: 14 additions & 0 deletions drivers/SmartThings/matter-switch/src/switch_utils/fields.lua
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -30,9 +32,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,
Expand Down Expand Up @@ -82,6 +86,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"

Expand Down Expand Up @@ -134,6 +143,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"
Expand Down
5 changes: 5 additions & 0 deletions drivers/SmartThings/matter-switch/src/switch_utils/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading