From a3b5249863968ff53e7f7f8199e3627b14d45de8 Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Tue, 11 Nov 2025 15:06:32 -0600 Subject: [PATCH 1/2] Add fanSpeedPercent to Thermostat device types --- .../profiles/thermostat-modular.yml | 7 +++++ .../matter-thermostat/src/init.lua | 4 +-- ...st_matter_thermo_multiple_device_types.lua | 2 ++ .../src/test/test_matter_thermostat.lua | 2 -- .../test/test_matter_thermostat_modular.lua | 27 +++++++++++++++++-- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml index 1e7bd97dc7..9fdecf0af3 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml @@ -9,6 +9,13 @@ components: - id: fanMode version: 1 optional: true + - id: fanSpeedPercent + version: 1 + config: + values: + - key: "percent.value" + range: [ 1, 100 ] + optional: true - id: fanOscillationMode version: 1 optional: true diff --git a/drivers/SmartThings/matter-thermostat/src/init.lua b/drivers/SmartThings/matter-thermostat/src/init.lua index 58f0d75344..64ac9d8b39 100644 --- a/drivers/SmartThings/matter-thermostat/src/init.lua +++ b/drivers/SmartThings/matter-thermostat/src/init.lua @@ -17,16 +17,15 @@ local log = require "log" local clusters = require "st.matter.clusters" local embedded_cluster_utils = require "embedded-cluster-utils" local im = require "st.matter.interaction_model" - local MatterDriver = require "st.matter.driver" local utils = require "st.utils" +local version = require "version" local SUPPORTED_COMPONENT_CAPABILITIES = "__supported_component_capabilities" -- declare match_profile function for use throughout file local match_profile -- Include driver-side definitions when lua libs api version is < 10 -local version = require "version" if version.api < 10 then clusters.HepaFilterMonitoring = require "HepaFilterMonitoring" clusters.ActivatedCarbonFilterMonitoring = require "ActivatedCarbonFilterMonitoring" @@ -966,6 +965,7 @@ local function match_modular_profile_thermostat(driver, device) if #fan_eps > 0 then table.insert(main_component_capabilities, capabilities.fanMode.ID) + table.insert(main_component_capabilities, capabilities.fanSpeedPercent.ID) end if #rock_eps > 0 then table.insert(main_component_capabilities, capabilities.fanOscillationMode.ID) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua index 7f9577fa8f..eacde8b651 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua @@ -208,6 +208,7 @@ local expected_metadata = { { "relativeHumidityMeasurement", "fanMode", + "fanSpeedPercent", "fanOscillationMode", "thermostatHeatingSetpoint", "thermostatCoolingSetpoint" @@ -231,6 +232,7 @@ local new_cluster_subscribe_list = { clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, clusters.FanControl.attributes.FanMode, clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.PercentCurrent, clusters.FanControl.attributes.RockSupport, -- These two attributes will be subscribed to following the profile clusters.FanControl.attributes.RockSetting, -- change since the fanOscillationMode capability will be enabled. } diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua index 44ed395797..5e5fe81f75 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua @@ -331,8 +331,6 @@ test.register_message_test( } ) --- test.socket.matter:__expect_send({mock_device_auto.id, clusters.Thermostat.attributes.MinSetpointDeadBand:read(mock_device_auto)}) - test.register_message_test( "Thermostat mode reports should generate correct messages", { diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua index 6645d39692..8c4c1b0aad 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua @@ -49,6 +49,7 @@ local mock_device_basic = test.mock_device.build_test_matter_device({ {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, }, device_types = { {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat @@ -65,7 +66,6 @@ local function initialize_mock_device(generic_mock_device, generic_subscribed_at subscribe_request:merge(cluster:subscribe(generic_mock_device)) end end - test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) return subscribe_request end @@ -107,6 +107,7 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "init" }) subscribe_request_basic = initialize_mock_device(mock_device_basic, subscribed_attributes) + test.socket.matter:__expect_send({mock_device_basic.id, subscribe_request_basic}) end -- run the profile configuration tests @@ -134,6 +135,7 @@ local expected_metadata = { { "relativeHumidityMeasurement", "fanMode", + "fanSpeedPercent", "thermostatHeatingSetpoint", "thermostatCoolingSetpoint" }, @@ -145,7 +147,28 @@ local expected_metadata = { test.register_coroutine_test( "Device with modular profile should enable correct optional capabilities", function() - test_thermostat_device_type_update_modular_profile(mock_device_basic, expected_metadata, subscribe_request_basic) + local subscribed_attributes = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.PercentCurrent, + clusters.PowerSource.attributes.BatPercentRemaining, + } + local subscribe_request = initialize_mock_device(mock_device_basic, subscribed_attributes) + test_thermostat_device_type_update_modular_profile(mock_device_basic, expected_metadata, subscribe_request) end, { test_init = test_init } ) From 353f6c006422ea1bd47dc5120f2f7eacf1244211 Mon Sep 17 00:00:00 2001 From: nickolas-deboom <158304111+nickolas-deboom@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:09:24 -0600 Subject: [PATCH 2/2] Don't include Off for supportedFanModes for thermostats by default (#2428) Off is an optional enum value for SystemMode and setting fanMode to Off may have no effect if the thermostat mode is set to heating or cooling. This commit removes this value from supportedFanModes attribute unless Off is reported by the SystemMode attribute at some point. --- .../matter-thermostat/src/init.lua | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/drivers/SmartThings/matter-thermostat/src/init.lua b/drivers/SmartThings/matter-thermostat/src/init.lua index 64ac9d8b39..6f8ecee76e 100644 --- a/drivers/SmartThings/matter-thermostat/src/init.lua +++ b/drivers/SmartThings/matter-thermostat/src/init.lua @@ -1392,7 +1392,11 @@ local function system_mode_handler(driver, device, ib, response) return end - local supported_modes = device:get_latest_state(device:endpoint_to_component(ib.endpoint_id), capabilities.thermostatMode.ID, capabilities.thermostatMode.supportedThermostatModes.NAME) or {} + local supported_modes = device:get_latest_state( + device:endpoint_to_component(ib.endpoint_id), + capabilities.thermostatMode.ID, + capabilities.thermostatMode.supportedThermostatModes.NAME + ) or {} -- check that the given mode was in the supported modes list if tbl_contains(supported_modes, THERMOSTAT_MODE_MAP[ib.data.value].NAME) then device:emit_event_for_endpoint(ib.endpoint_id, THERMOSTAT_MODE_MAP[ib.data.value]()) @@ -1538,39 +1542,59 @@ local function fan_mode_handler(driver, device, ib, response) end local function fan_mode_sequence_handler(driver, device, ib, response) - local supportedFanModes, supported_fan_modes_attribute + local supported_fan_modes, supported_fan_modes_capability, supported_fan_modes_attribute if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then - supportedFanModes = { "off", "low", "medium", "high" } + supported_fan_modes = { "off", "low", "medium", "high" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then - supportedFanModes = { "off", "low", "high" } + supported_fan_modes = { "off", "low", "high" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then - supportedFanModes = { "off", "low", "medium", "high", "auto" } + supported_fan_modes = { "off", "low", "medium", "high", "auto" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then - supportedFanModes = { "off", "low", "high", "auto" } + supported_fan_modes = { "off", "low", "high", "auto" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_HIGH_AUTO then - supportedFanModes = { "off", "high", "auto" } + supported_fan_modes = { "off", "high", "auto" } else - supportedFanModes = { "off", "high" } + supported_fan_modes = { "off", "high" } end if device:supports_capability_by_id(capabilities.airPurifierFanMode.ID) then - supported_fan_modes_attribute = capabilities.airPurifierFanMode.supportedAirPurifierFanModes + supported_fan_modes_capability = capabilities.airPurifierFanMode + supported_fan_modes_attribute = supported_fan_modes_capability.supportedAirPurifierFanModes elseif device:supports_capability_by_id(capabilities.airConditionerFanMode.ID) then - supported_fan_modes_attribute = capabilities.airConditionerFanMode.supportedAcFanModes + supported_fan_modes_capability = capabilities.airConditionerFanMode + supported_fan_modes_attribute = supported_fan_modes_capability.supportedAcFanModes elseif device:supports_capability_by_id(capabilities.thermostatFanMode.ID) then + supported_fan_modes_capability = capabilities.thermostatFanMode supported_fan_modes_attribute = capabilities.thermostatFanMode.supportedThermostatFanModes -- Our thermostat fan mode control is not granular enough to handle all of the supported modes if ib.data.value >= clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO and ib.data.value <= clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then - supportedFanModes = { "auto", "on" } + supported_fan_modes = { "auto", "on" } else - supportedFanModes = { "on" } + supported_fan_modes = { "on" } end else - supported_fan_modes_attribute = capabilities.fanMode.supportedFanModes + supported_fan_modes_capability = capabilities.fanMode + supported_fan_modes_attribute = supported_fan_modes_capability.supportedFanModes + end + + -- remove 'off' as a supported fan mode for thermostat device types, unless the + -- device previously had 'off' as a supported fan mode to avoid breaking routines + if get_device_type(device) == THERMOSTAT_DEVICE_TYPE_ID then + local prev_supported_fan_modes = device:get_latest_state( + device:endpoint_to_component(ib.endpoint_id), + supported_fan_modes_capability.ID, + supported_fan_modes_attribute.NAME + ) or {} + if not tbl_contains(prev_supported_fan_modes, "off") then + local _, off_idx = tbl_contains(supported_fan_modes, "off") + if off_idx then + table.remove(supported_fan_modes, off_idx) + end + end end - local event = supported_fan_modes_attribute(supportedFanModes, {visibility = {displayed = false}}) + local event = supported_fan_modes_attribute(supported_fan_modes, {visibility = {displayed = false}}) device:emit_event_for_endpoint(ib.endpoint_id, event) end