From 4b1cfc6c275337dbbb022744fb0242d1473533c3 Mon Sep 17 00:00:00 2001 From: Kirill Shypachov Date: Sat, 17 Oct 2026 01:19:42 +0300 Subject: [PATCH] Matter Thermostat: Shift the other setpoint on deadband violation On thermostats with the AutoMode feature the driver rejected a heating setpoint above the cooling setpoint minus MinSetpointDeadBand (and a cooling setpoint below the heating setpoint plus the deadband) locally, so in Heat mode the heating setpoint could not be raised past the cooling setpoint and nothing was sent to the device. The Thermostat cluster specification expects the other setpoint to be moved instead; connectedhomeip does this since PR #35673 and fails the write only if the other setpoint would leave its limits. The driver now writes the other setpoint first and then the requested one, and keeps the local rejection only for that case. The moved setpoint is computed in 0.01 C units so that values at a limit are not rejected by floating point errors. The two writes are separate requests: the hub answered a single write request with both attributes with FAILURE. Assisted-by: Claude:claude-opus-5.5 luacheck Signed-off-by: Kirill Shypachov --- .../test_matter_thermo_setpoint_limits.lua | 101 +++++++++++++++++- .../src/test/test_matter_thermostat.lua | 43 ++++++++ .../capability_handlers.lua | 49 ++++++--- 3 files changed, 178 insertions(+), 15 deletions(-) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua index ae5dcc10af..62d6423f6c 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua @@ -242,7 +242,7 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Heat setpoint inside deadband", + "Heat setpoint inside deadband raises the cooling setpoint", function() configure(mock_device) test.socket.matter:__queue_receive({ @@ -254,6 +254,92 @@ test.register_coroutine_test( mock_device.id, { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 26 } } }) + test.socket.matter:__expect_send({mock_device.id, clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 1, 2760)}) + test.socket.matter:__expect_send({mock_device.id, clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 2600)}) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Cool setpoint inside deadband lowers the heating setpoint", + function() + configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(mock_device, 1, 16) --1.6 celcius + }) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 25 } } + }) + test.socket.matter:__expect_send({mock_device.id, clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 2340)}) + test.socket.matter:__expect_send({mock_device.id, clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 1, 2500)}) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Heat setpoint inside deadband raises the cooling setpoint exactly to its max", + function() + configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(mock_device, 1, 16) --1.6 celcius + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:build_test_report_data(mock_device, 1, 1000) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(mock_device, 1, 2720) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 10.00, maximum = 27.20, step = 0.1 }, unit = "C" })) + ) + test.wait_for_events() + -- 25.6 + 1.6 is 27.200000000000003 in floating point, still the max of 27.20 + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 25.6 } } + }) + test.socket.matter:__expect_send({mock_device.id, clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 1, 2720)}) + test.socket.matter:__expect_send({mock_device.id, clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 2560)}) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Heat setpoint inside deadband is rejected when the cooling setpoint cannot be raised", + function() + configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(mock_device, 1, 16) --1.6 celcius + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:build_test_report_data(mock_device, 1, 1000) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(mock_device, 1, 2700) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 10.00, maximum = 27.00, step = 0.1 }, unit = "C" })) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 26 } } + }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_heating_setpoint) ) @@ -264,13 +350,24 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Cool setpoint inside deadband", + "Cool setpoint inside deadband is rejected when the heating setpoint cannot be lowered", function() configure(mock_device) test.socket.matter:__queue_receive({ mock_device.id, clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(mock_device, 1, 16) --1.6 celcius }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:build_test_report_data(mock_device, 1, 2400) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(mock_device, 1, 3000) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 24.00, maximum = 30.00, step = 0.1 }, unit = "C" })) + ) test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, 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 c7ab7d646f..5343ab0018 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua @@ -745,6 +745,49 @@ test.register_message_test( } ) +test.register_message_test( + "Without the Auto feature a heating setpoint above the cooling setpoint is written alone", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.OccupiedCoolingSetpoint:build_test_report_data(mock_device, 1, 20*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { maximum = 100.0, minimum = 0.0, step = 0.1 }, unit = "C" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 20.0, unit = "C" })) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 25 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 25*100) + } + } + }, + { + min_api_version = 15 + } +) + test.register_message_test( "Setting the cooling setpoint should send the appropriate commands", { diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua index 4a32b7a700..0b0064ab22 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua @@ -210,6 +210,9 @@ function CapabilityHandlers.thermostat_set_setpoint_factory(setpoint) --Check setpoint limits for the device local setpoint_type = string.match(setpoint.NAME, "Heat") or "Cool" local deadband = device:get_field(fields.setpoint_limit_device_field.MIN_DEADBAND) or 2.5 --spec default + -- With the AutoMode feature a setpoint that breaks the deadband moves the other setpoint along, as the + -- Thermostat cluster specification describes; it is rejected only if the other setpoint would leave its limits. + local companion_setpoint, companion_centi if setpoint_type == "Heat" then local min = device:get_field(fields.setpoint_limit_device_field.MIN_HEAT) or MIN_TEMP_IN_C local max = device:get_field(fields.setpoint_limit_device_field.MAX_HEAT) or MAX_TEMP_IN_C @@ -222,12 +225,19 @@ function CapabilityHandlers.thermostat_set_setpoint_factory(setpoint) return end if is_auto_capable and value > (cached_cooling_val - deadband) then - log.warn(string.format( - "Invalid setpoint (%s) is greater than the cooling setpoint (%s) with the deadband (%s)", - value, cooling_setpoint, deadband - )) - device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpoint(heating_setpoint, {state_change = true})) - return + local cool_min = device:get_field(fields.setpoint_limit_device_field.MIN_COOL) or MIN_TEMP_IN_C + local cool_max = device:get_field(fields.setpoint_limit_device_field.MAX_COOL) or MAX_TEMP_IN_C + companion_centi = st_utils.round((value + deadband) * 100.0) + if companion_centi < st_utils.round(cool_min * 100.0) or companion_centi > st_utils.round(cool_max * 100.0) then + log.warn(string.format( + "Invalid setpoint (%s) is greater than the cooling setpoint (%s) with the deadband (%s), and the cooling setpoint cannot be raised to %s (max %s)", + value, cached_cooling_val, deadband, companion_centi / 100.0, cool_max + )) + device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpoint(heating_setpoint, {state_change = true})) + return + end + log.info(string.format("Raising the cooling setpoint to %s to keep the deadband (%s)", companion_centi / 100.0, deadband)) + companion_setpoint = clusters.Thermostat.attributes.OccupiedCoolingSetpoint end else local min = device:get_field(fields.setpoint_limit_device_field.MIN_COOL) or MIN_TEMP_IN_C @@ -241,15 +251,28 @@ function CapabilityHandlers.thermostat_set_setpoint_factory(setpoint) return end if is_auto_capable and value < (cached_heating_val + deadband) then - log.warn(string.format( - "Invalid setpoint (%s) is less than the heating setpoint (%s) with the deadband (%s)", - value, heating_setpoint, deadband - )) - device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpoint(cooling_setpoint, {state_change = true})) - return + local heat_min = device:get_field(fields.setpoint_limit_device_field.MIN_HEAT) or MIN_TEMP_IN_C + local heat_max = device:get_field(fields.setpoint_limit_device_field.MAX_HEAT) or MAX_TEMP_IN_C + companion_centi = st_utils.round((value - deadband) * 100.0) + if companion_centi < st_utils.round(heat_min * 100.0) or companion_centi > st_utils.round(heat_max * 100.0) then + log.warn(string.format( + "Invalid setpoint (%s) is less than the heating setpoint (%s) with the deadband (%s), and the heating setpoint cannot be lowered to %s (min %s)", + value, cached_heating_val, deadband, companion_centi / 100.0, heat_min + )) + device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpoint(cooling_setpoint, {state_change = true})) + return + end + log.info(string.format("Lowering the heating setpoint to %s to keep the deadband (%s)", companion_centi / 100.0, deadband)) + companion_setpoint = clusters.Thermostat.attributes.OccupiedHeatingSetpoint end end - device:send(setpoint:write(device, thermostat_utils.component_to_endpoint(device, cmd.component, clusters.Thermostat.ID), st_utils.round(value * 100.0))) + if companion_setpoint then + -- The other setpoint goes first, so a server that rejects deadband violations accepts both writes, + -- and one that adjusts itself has nothing to adjust. Separate requests: the hub rejects a write + -- request with more than one attribute. + device:send(companion_setpoint:write(device, endpoint_id, companion_centi)) + end + device:send(setpoint:write(device, endpoint_id, st_utils.round(value * 100.0))) end end