From ad29996eb5e2c08ae01cb857d8cbdd37ab24360b Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Tue, 11 Nov 2025 15:06:32 -0600 Subject: [PATCH 1/3] Add fanSpeedPercent to Thermostat device types --- .../profiles/thermostat-modular.yml | 7 +++++ ...st_matter_thermo_multiple_device_types.lua | 2 ++ .../src/test/test_matter_thermostat.lua | 2 -- .../test/test_matter_thermostat_modular.lua | 27 +++++++++++++++++-- .../thermostat_utils/device_configuration.lua | 1 + 5 files changed, 35 insertions(+), 4 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/test/test_matter_thermo_multiple_device_types.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua index a8acdc253e..4ed44fad6b 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 @@ -197,6 +197,7 @@ local expected_metadata = { { "relativeHumidityMeasurement", "fanMode", + "fanSpeedPercent", "fanOscillationMode", "thermostatHeatingSetpoint", "thermostatCoolingSetpoint" @@ -220,6 +221,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 e40572ff5d..c087eb2e1f 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua @@ -320,8 +320,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 73d180da7e..fa95f692ce 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 @@ -38,6 +38,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 @@ -54,7 +55,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 @@ -96,6 +96,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 @@ -123,6 +124,7 @@ local expected_metadata = { { "relativeHumidityMeasurement", "fanMode", + "fanSpeedPercent", "thermostatHeatingSetpoint", "thermostatCoolingSetpoint" }, @@ -134,7 +136,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 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua index 102b6e8d3b..e6e6994e4b 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua @@ -201,6 +201,7 @@ function DeviceConfiguration.match_modular_profile_thermostat(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) From 82a90bd848dc50be0ff4af6a11173a2bf11947bf Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 4 Dec 2025 13:21:38 -0600 Subject: [PATCH 2/3] Don't include off fan mode for thermostats --- .../attribute_handlers.lua | 52 ++++++++++++++----- .../src/thermostat_utils/utils.lua | 4 +- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua index 78f5ee3bf2..a91c5bec3a 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua @@ -48,7 +48,11 @@ function AttributeHandlers.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 thermostat_utils.tbl_contains(supported_modes, fields.THERMOSTAT_MODE_MAP[ib.data.value].NAME) then device:emit_event_for_endpoint(ib.endpoint_id, fields.THERMOSTAT_MODE_MAP[ib.data.value]()) @@ -325,39 +329,59 @@ function AttributeHandlers.fan_mode_handler(driver, device, ib, response) end function AttributeHandlers.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 thermostat_utils.get_device_type(device) == fields.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 thermostat_utils.tbl_contains(prev_supported_fan_modes, "off") then + local off_mode_present, off_mode_idx = thermostat_utils.tbl_contains(supported_fan_modes, "off") + if off_mode_present then + table.remove(supported_fan_modes, off_mode_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 diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua index 8194f08c48..a3e439b1ea 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua @@ -10,9 +10,9 @@ local ThermostatUtils = {} function ThermostatUtils.tbl_contains(array, value) if value == nil then return false end - for _, element in pairs(array or {}) do + for index, element in pairs(array or {}) do if element == value then - return true + return true, index end end return false From f0fd1ec1304403b7a3577e8ba9862fc66192bb8d Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 4 Dec 2025 13:26:59 -0600 Subject: [PATCH 3/3] addressing review feedback --- .../src/thermostat_handlers/attribute_handlers.lua | 14 ++++++-------- .../src/thermostat_utils/utils.lua | 4 ++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua index a91c5bec3a..9b9ceae5da 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua @@ -367,22 +367,20 @@ function AttributeHandlers.fan_mode_sequence_handler(driver, device, ib, respons -- 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 thermostat_utils.get_device_type(device) == fields.THERMOSTAT_DEVICE_TYPE_ID then + if thermostat_utils.get_device_type(device) == fields.THERMOSTAT_DEVICE_TYPE_ID and + device:supports_capability_by_id(capabilities.fanMode.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 thermostat_utils.tbl_contains(prev_supported_fan_modes, "off") then - local off_mode_present, off_mode_idx = thermostat_utils.tbl_contains(supported_fan_modes, "off") - if off_mode_present then - table.remove(supported_fan_modes, off_mode_idx) - end + -- per the definitions set above, the first index always contains "off" + if prev_supported_fan_modes[1] ~= "off" then + table.remove(supported_fan_modes, 1) end end - local event = supported_fan_modes_attribute(supported_fan_modes, {visibility = {displayed = false}}) - device:emit_event_for_endpoint(ib.endpoint_id, event) + device:emit_event_for_endpoint(ib.endpoint_id, supported_fan_modes_attribute(supported_fan_modes, {visibility = {displayed = false}})) end function AttributeHandlers.percent_current_handler(driver, device, ib, response) diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua index a3e439b1ea..8194f08c48 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua @@ -10,9 +10,9 @@ local ThermostatUtils = {} function ThermostatUtils.tbl_contains(array, value) if value == nil then return false end - for index, element in pairs(array or {}) do + for _, element in pairs(array or {}) do if element == value then - return true, index + return true end end return false