From a01537eef4bd5f0b80456e947d0a5306c4ea3559 Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Thu, 11 Jun 2026 14:12:54 -0500 Subject: [PATCH] Matter Thermostat: add setpoint step size override for Lennox --- ...matter_thermo_lennox_setpoint_override.lua | 126 ++++++++++++++++++ .../attribute_handlers.lua | 10 +- .../src/thermostat_utils/fields.lua | 10 +- .../src/thermostat_utils/utils.lua | 9 ++ 4 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_lennox_setpoint_override.lua diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_lennox_setpoint_override.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_lennox_setpoint_override.lua new file mode 100644 index 0000000000..1433d66e4e --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_lennox_setpoint_override.lua @@ -0,0 +1,126 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local mock_lennox_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x1356, + product_id = 0x0001, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.Thermostat.ID, + cluster_revision = 5, + cluster_type = "SERVER", + feature_map = 35, -- Heat, Cool, and Auto features. + }, + { cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "BOTH" }, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat + } + } + } +}) + +local function test_init() + local cluster_subscribe_list = { + 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.PowerSource.attributes.BatPercentRemaining, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_lennox_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_lennox_device)) + end + end + test.socket.capability:__expect_send( + mock_lennox_device:generate_test_message("main", + capabilities.thermostatOperatingState.supportedThermostatOperatingStates({ "idle", "heating", "cooling" }, + { visibility = { displayed = false } })) + ) + test.socket.matter:__expect_send({ mock_lennox_device.id, subscribe_request }) + + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({ mock_lennox_device.id, read_setpoint_deadband }) + + test.mock_device.add_test_device(mock_lennox_device) + + test.socket.device_lifecycle:__queue_receive({ mock_lennox_device.id, "added" }) + local read_req = clusters.Thermostat.attributes.ControlSequenceOfOperation:read() + read_req:merge(clusters.FanControl.attributes.FanModeSequence:read()) + read_req:merge(clusters.FanControl.attributes.WindSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.PowerSource.attributes.AttributeList:read()) + read_req:merge(clusters.Thermostat.attributes.AttributeList:read()) + test.socket.matter:__expect_send({ mock_lennox_device.id, read_req }) + + test.set_rpc_version(6) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Lennox thermostat uses 0.5 setpoint range step", + { + { + channel = "matter", + direction = "receive", + message = { + mock_lennox_device.id, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:build_test_report_data(mock_lennox_device, 1, 1000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_lennox_device.id, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(mock_lennox_device, 1, 3222) + } + }, + { + channel = "capability", + direction = "send", + message = mock_lennox_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.5 }, unit = + "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() 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 f0a210bc16..fe01fd98f4 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua @@ -177,7 +177,8 @@ function AttributeHandlers.abs_heat_setpoint_limit_factory(minOrMax) -- Only emit the capability for RPC version >= 5 (unit conversion for -- heating setpoint range capability is only supported for RPC >= 5) if version.rpc >= 5 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" })) + local setpoint_step = thermostat_utils.get_product_override_field(device, "setpoint_step") or 0.1 + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = min, maximum = max, step = setpoint_step }, unit = "C" })) end else device.log.warn_with({hub_logs = true}, string.format("Device reported a min heating setpoint %d that is not lower than the reported max %d", min, max)) @@ -201,7 +202,8 @@ function AttributeHandlers.abs_cool_setpoint_limit_factory(minOrMax) -- Only emit the capability for RPC version >= 5 (unit conversion for -- cooling setpoint range capability is only supported for RPC >= 5) if version.rpc >= 5 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" })) + local setpoint_step = thermostat_utils.get_product_override_field(device, "setpoint_step") or 0.1 + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = min, maximum = max, step = setpoint_step }, unit = "C" })) end else device.log.warn_with({hub_logs = true}, string.format("Device reported a min cooling setpoint %d that is not lower than the reported max %d", min, max)) @@ -228,7 +230,7 @@ function AttributeHandlers.temperature_handler_factory(attribute) local range = { minimum = device:get_field(fields.setpoint_limit_device_field.MIN_COOL) or fields.THERMOSTAT_MIN_TEMP_IN_C, maximum = device:get_field(fields.setpoint_limit_device_field.MAX_COOL) or fields.THERMOSTAT_MAX_TEMP_IN_C, - step = 0.1 + step = thermostat_utils.get_product_override_field(device, "setpoint_step") or 0.1 } event = capabilities.thermostatCoolingSetpoint.coolingSetpointRange({value = range, unit = unit}) device:emit_event_for_endpoint(ib.endpoint_id, event) @@ -244,7 +246,7 @@ function AttributeHandlers.temperature_handler_factory(attribute) local range = { minimum = device:get_field(fields.setpoint_limit_device_field.MIN_HEAT) or MIN_TEMP_IN_C, maximum = device:get_field(fields.setpoint_limit_device_field.MAX_HEAT) or MAX_TEMP_IN_C, - step = 0.1 + step = thermostat_utils.get_product_override_field(device, "setpoint_step") or 0.1 } event = capabilities.thermostatHeatingSetpoint.heatingSetpointRange({value = range, unit = unit}) device:emit_event_for_endpoint(ib.endpoint_id, event) diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua index 770fd7d65e..793886b044 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua @@ -235,4 +235,12 @@ ThermostatFields.conversion_tables = { }, } -return ThermostatFields \ No newline at end of file +ThermostatFields.vendor_overrides = { + [0x1356] = { -- LENNOX_MANUFACTURER_ID + [0x0001] = { setpoint_step = 0.5 }, -- This device requires a setpoint step of 0.5 + [0x0002] = { setpoint_step = 0.5 }, -- This device requires a setpoint step of 0.5 + [0x0003] = { setpoint_step = 0.5 }, -- This device requires a setpoint step of 0.5 + } +} + +return ThermostatFields diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua index a233a0c0e7..49ea50acde 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua @@ -244,4 +244,13 @@ function ThermostatUtils.report_power_consumption_to_st_energy(device, latest_to })) end +function ThermostatUtils.get_product_override_field(device, override_key) + if device.manufacturer_info + and fields.vendor_overrides[device.manufacturer_info.vendor_id] + and fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id] + then + return fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id][override_key] + end +end + return ThermostatUtils