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 @@ -3566,6 +3566,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: flowSensor
version: 1
optional: true
- id: operationalState
version: 1
optional: true
- id: firmwareUpdate
version: 1
- id: refresh
version: 1
categories:
- name: IrrigationSystem

36 changes: 30 additions & 6 deletions drivers/SmartThings/matter-switch/src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function SwitchLifecycleHandlers.device_init(driver, device)
end
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
Expand Down Expand Up @@ -147,6 +147,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
},
Expand All @@ -158,6 +163,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 @@ -225,24 +235,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 @@ -286,6 +306,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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -538,4 +538,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)
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think these fields are actually mapped to anything as they are now, since these are defined in fields.lua. Can you add some tests for flow measurement?

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
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 @@ -18,6 +18,7 @@ end
local DeviceConfiguration = {}
local SwitchDeviceConfiguration = {}
local ButtonDeviceConfiguration = {}
local ValveDeviceConfiguration = {}

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 @@ -156,6 +157,48 @@ function ButtonDeviceConfiguration.configure_buttons(device)
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
Comment on lines +163 to +164
Copy link
Contributor

Choose a reason for hiding this comment

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

we can use something like local ep_info = switch_utils.get_endpoint_info(valve_ep_id) helper to do this a little more easily

if switch_utils.find_cluster_on_ep(ep, clusters.ValveConfigurationAndControl.ID) then
Copy link
Contributor

Choose a reason for hiding this comment

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

this helper can take the feature map as a third parameter, might simplify this a bit more

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 ]] --

Expand All @@ -174,7 +217,61 @@ function DeviceConfiguration.match_profile(driver, device)
local default_endpoint_id = switch_utils.find_default_endpoint(device)
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
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe use the get_endpoints_by_device_type helper here?

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"
Copy link
Contributor

Choose a reason for hiding this comment

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

I think I'm a little bit unsure why we even have to make an irrigation-system profile? Why not just use a single modular water valve profile? This can also be used for the child devices as well?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, if we're adding flow measurement, the spec defines that all water valves might have flow support optionally... may want to add that as well?

This would mean that as far as the profile, the only difference we'd see between irrigation-system and water-valve would be the operational state capability at this time. Since that can be handled modularly,

Copy link
Contributor

Choose a reason for hiding this comment

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

However, I see you're using a different category for IrrigationSystem. So I like the idea of having the separate profile. We still may want to add flow to water valve though

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 })
Copy link
Contributor

Choose a reason for hiding this comment

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

If we're going to add support for pre-api 15, we'll also have to extend in init and info_changed some logic like this:

  if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) and (version.api < 15 or version.rpc < 9) then
    -- assume that device is using a modular profile on 0.57 FW, override supports_capability_by_id
    -- library function to utilize optional capabilities
    device:extend_device("supports_capability_by_id", aqs_utils.supports_capability_by_id_modular)
  end

I'm not totally convinced this is a worthwhile path to take though... but that may just be me

end
else
device:try_update_metadata({profile = profile_name})
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this api should have 2 calls in 2 different places for try_update_metadata. I think we should work towards there being a single "path" that this logic follows.

I haven't looked at it super closely, but most of this could/should just be handled within the if #valve_ep_ids > 1 then logic block below?

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)
Comment on lines +270 to +272
Copy link
Contributor

Choose a reason for hiding this comment

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

I might move this logic into the create_child_devices function itself- see the create_child_devices for onOff eps to see what I'm suggesting (since we do the same behavior there as well)

Copy link
Contributor

Choose a reason for hiding this comment

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

We could also plug this logic within the second check, rather than doing an if-else.

Like:

if #valve_ep_ids > 0 then
  ... logic for default ep ...
  if #valve_ep_ids > 1 then
    ... child stuff ...
  end
end

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 @@ -221,5 +318,6 @@ end
return {
DeviceCfg = DeviceConfiguration,
SwitchCfg = SwitchDeviceConfiguration,
ButtonCfg = ButtonDeviceConfiguration
ButtonCfg = ButtonDeviceConfiguration,
ValveCfg = ValveDeviceConfiguration
}
Loading
Loading