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