[HVAC] Thermostat deadband handling (#35673)
* [HVAC] Shift heating and cooling setpoints to preserve deadband, when possible
* Minor function renames, fix incorrect setters
* Populate deadband on setpointLimits when Auto is supported
* [Thermostat] Add test for deadband handling
* Restyled Python test
* Fix int promotion errors on some platforms
* Remove unused Python imports
* Fix capitalization of structs, methods, fields, etc.
* Silly C++ int promotion rules
* [HVAC] Fix incorrect naming of TSTAT 2.3 test class
* Fix errant quote in TC_TSTAT_2_3.py
* Drop deadband fix test in favor of separate PR
* Apply suggestions from code review
Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>
* Don't use three-arg setter for attributes
* Rename deadband member
* Add backwards-compatibility comment for return code substitution
* Restyled
---------
Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>
diff --git a/src/app/clusters/thermostat-server/thermostat-server.cpp b/src/app/clusters/thermostat-server/thermostat-server.cpp
index bda97c5..a1f0d27 100644
--- a/src/app/clusters/thermostat-server/thermostat-server.cpp
+++ b/src/app/clusters/thermostat-server/thermostat-server.cpp
@@ -220,6 +220,292 @@
return CoolingSetpoint;
}
+struct SetpointLimits
+{
+ bool autoSupported = false;
+ bool heatSupported = false;
+ bool coolSupported = false;
+ bool occupancySupported = false;
+
+ int16_t absMinHeatSetpointLimit;
+ int16_t absMaxHeatSetpointLimit;
+ int16_t minHeatSetpointLimit;
+ int16_t maxHeatSetpointLimit;
+ int16_t absMinCoolSetpointLimit;
+ int16_t absMaxCoolSetpointLimit;
+ int16_t minCoolSetpointLimit;
+ int16_t maxCoolSetpointLimit;
+ int16_t deadBand = 0;
+ int16_t occupiedCoolingSetpoint;
+ int16_t occupiedHeatingSetpoint;
+ int16_t unoccupiedCoolingSetpoint;
+ int16_t unoccupiedHeatingSetpoint;
+};
+
+/**
+ * @brief Reads all the attributes for enforcing setpoint limits
+ *
+ * @param endpoint The endpoint for the server whose limits are being enforced
+ * @param setpointLimits The SetpointLimits to populate
+ * @return Success if the limits were read completely, otherwise an error code
+ */
+Status GetSetpointLimits(EndpointId endpoint, SetpointLimits & setpointLimits)
+{
+ uint32_t flags;
+ if (FeatureMap::Get(endpoint, &flags) != Status::Success)
+ {
+ ChipLogError(Zcl, "GetSetpointLimits: could not get feature flags");
+ flags = FEATURE_MAP_DEFAULT;
+ }
+
+ auto featureMap = BitMask<Feature, uint32_t>(flags);
+ setpointLimits.autoSupported = featureMap.Has(Feature::kAutoMode);
+ setpointLimits.heatSupported = featureMap.Has(Feature::kHeating);
+ setpointLimits.coolSupported = featureMap.Has(Feature::kCooling);
+ setpointLimits.occupancySupported = featureMap.Has(Feature::kOccupancy);
+
+ if (setpointLimits.autoSupported)
+ {
+ int8_t deadBand;
+ if (MinSetpointDeadBand::Get(endpoint, &deadBand) != Status::Success)
+ {
+ deadBand = kDefaultDeadBand;
+ }
+ setpointLimits.deadBand = static_cast<int16_t>(deadBand * 10);
+ }
+
+ if (AbsMinCoolSetpointLimit::Get(endpoint, &setpointLimits.absMinCoolSetpointLimit) != Status::Success)
+ setpointLimits.absMinCoolSetpointLimit = kDefaultAbsMinCoolSetpointLimit;
+
+ if (AbsMaxCoolSetpointLimit::Get(endpoint, &setpointLimits.absMaxCoolSetpointLimit) != Status::Success)
+ setpointLimits.absMaxCoolSetpointLimit = kDefaultAbsMaxCoolSetpointLimit;
+
+ if (MinCoolSetpointLimit::Get(endpoint, &setpointLimits.minCoolSetpointLimit) != Status::Success)
+ setpointLimits.minCoolSetpointLimit = setpointLimits.absMinCoolSetpointLimit;
+
+ if (MaxCoolSetpointLimit::Get(endpoint, &setpointLimits.maxCoolSetpointLimit) != Status::Success)
+ setpointLimits.maxCoolSetpointLimit = setpointLimits.absMaxCoolSetpointLimit;
+
+ if (AbsMinHeatSetpointLimit::Get(endpoint, &setpointLimits.absMinHeatSetpointLimit) != Status::Success)
+ setpointLimits.absMinHeatSetpointLimit = kDefaultAbsMinHeatSetpointLimit;
+
+ if (AbsMaxHeatSetpointLimit::Get(endpoint, &setpointLimits.absMaxHeatSetpointLimit) != Status::Success)
+ setpointLimits.absMaxHeatSetpointLimit = kDefaultAbsMaxHeatSetpointLimit;
+
+ if (MinHeatSetpointLimit::Get(endpoint, &setpointLimits.minHeatSetpointLimit) != Status::Success)
+ setpointLimits.minHeatSetpointLimit = setpointLimits.absMinHeatSetpointLimit;
+
+ if (MaxHeatSetpointLimit::Get(endpoint, &setpointLimits.maxHeatSetpointLimit) != Status::Success)
+ setpointLimits.maxHeatSetpointLimit = setpointLimits.absMaxHeatSetpointLimit;
+
+ if (setpointLimits.coolSupported)
+ {
+ if (OccupiedCoolingSetpoint::Get(endpoint, &setpointLimits.occupiedCoolingSetpoint) != Status::Success)
+ {
+ // We're substituting the failure code here for backwards-compatibility reasons
+ ChipLogError(Zcl, "Error: Can not read Occupied Cooling Setpoint");
+ return Status::Failure;
+ }
+ }
+
+ if (setpointLimits.heatSupported)
+ {
+ if (OccupiedHeatingSetpoint::Get(endpoint, &setpointLimits.occupiedHeatingSetpoint) != Status::Success)
+ {
+ // We're substituting the failure code here for backwards-compatibility reasons
+ ChipLogError(Zcl, "Error: Can not read Occupied Heating Setpoint");
+ return Status::Failure;
+ }
+ }
+
+ if (setpointLimits.coolSupported && setpointLimits.occupancySupported)
+ {
+ if (UnoccupiedCoolingSetpoint::Get(endpoint, &setpointLimits.unoccupiedCoolingSetpoint) != Status::Success)
+ {
+ // We're substituting the failure code here for backwards-compatibility reasons
+ ChipLogError(Zcl, "Error: Can not read Unoccupied Cooling Setpoint");
+ return Status::Failure;
+ }
+ }
+
+ if (setpointLimits.heatSupported && setpointLimits.occupancySupported)
+ {
+ if (UnoccupiedHeatingSetpoint::Get(endpoint, &setpointLimits.unoccupiedHeatingSetpoint) != Status::Success)
+ {
+ // We're substituting the failure code here for backwards-compatibility reasons
+ ChipLogError(Zcl, "Error: Can not read Unoccupied Heating Setpoint");
+ return Status::Failure;
+ }
+ }
+ return Status::Success;
+}
+
+/**
+ * @brief Checks to see if it's possible to adjust the heating setpoint to preserve a given deadband
+ * if the cooling setpoint is changed
+ *
+ * @param autoSupported Whether or not the thermostat supports Auto mode
+ * @param newCoolingSetpoing The desired cooling setpoint
+ * @param minHeatingSetpoint The minimum allowed heating setpoint
+ * @param deadband The deadband to preserve
+ * @return Success if the deadband can be preserved, InvalidValue if it cannot
+ */
+Status CheckHeatingSetpointDeadband(bool autoSupported, int16_t newCoolingSetpoint, int16_t minHeatingSetpoint, int16_t deadband)
+{
+ if (!autoSupported)
+ {
+ return Status::Success;
+ }
+ int16_t maxValidHeatingSetpoint = static_cast<int16_t>(newCoolingSetpoint - deadband);
+ if (maxValidHeatingSetpoint < minHeatingSetpoint)
+ {
+ // If we need to adjust the heating setpoint to preserve the deadband, it will go below the min heat setpoint
+ return Status::InvalidValue;
+ }
+ // It's possible to adjust the heating setpoint, if needed
+ return Status::Success;
+}
+
+/**
+ * @brief Checks to see if it's possible to adjust the cooling setpoint to preserve a given deadband
+ * if the heating setpoint is changed
+ *
+ * @param autoSupported Whether or not the thermostat supports Auto mode
+ * @param newHeatingSetpoint The desired heating setpoint
+ * @param maxCoolingSetpoint The maximum allowed cooling setpoint
+ * @param deadband The deadband to preserve
+ * @return Success if the deadband can be preserved, InvalidValue if it cannot
+ */
+Status CheckCoolingSetpointDeadband(bool autoSupported, int16_t newHeatingSetpoint, int16_t maxCoolingSetpoint, int16_t deadband)
+{
+ if (!autoSupported)
+ {
+ return Status::Success;
+ }
+ int16_t minValidCoolingSetpoint = static_cast<int16_t>(newHeatingSetpoint + deadband);
+ if (minValidCoolingSetpoint > maxCoolingSetpoint)
+ {
+ // If we need to adjust the cooling setpoint to preserve the deadband, it will go above the max cool setpoint
+ return Status::InvalidValue;
+ }
+ // It's possible to adjust the cooling setpoint, if needed
+ return Status::Success;
+}
+
+typedef Status (*SetpointSetter)(EndpointId endpoint, int16_t value);
+
+/**
+ * @brief Attempts to ensure that a change to the heating setpoint maintains the deadband with the cooling setpoint
+ * by adjusting the cooling setpoint
+ *
+ * @param endpoint The endpoint on which the heating setpoint has been changed
+ * @param currentCoolingSetpoint The current cooling setpoint
+ * @param newHeatingSetpoint The newly adjusted heating setpoint
+ * @param maxCoolingSetpoint The maximum allowed cooling setpoint
+ * @param deadband The deadband to preserve
+ * @param setter A function for setting the cooling setpoint
+ */
+void EnsureCoolingSetpointDeadband(EndpointId endpoint, int16_t currentCoolingSetpoint, int16_t newHeatingSetpoint,
+ int16_t maxCoolingSetpoint, int16_t deadband, SetpointSetter setter)
+{
+ int16_t minValidCoolingSetpoint = static_cast<int16_t>(newHeatingSetpoint + deadband);
+ if (currentCoolingSetpoint >= minValidCoolingSetpoint)
+ {
+ // The current cooling setpoint doesn't violate the deadband
+ return;
+ }
+ if (minValidCoolingSetpoint > maxCoolingSetpoint)
+ {
+ // Adjusting the cool setpoint to preserve the deadband would violate the max cool setpoint
+ // This should have been caught in CheckCoolingSetpointDeadband, so log and exit
+ ChipLogError(Zcl, "Failed ensuring cooling setpoint deadband");
+ return;
+ }
+ // Adjust the cool setpoint to preserve deadband
+ auto status = setter(endpoint, minValidCoolingSetpoint);
+ if (status != Status::Success)
+ {
+ ChipLogError(Zcl, "Error: EnsureCoolingSetpointDeadband failed!");
+ }
+}
+
+/**
+ * @brief Attempts to ensure that a change to the cooling setpoint maintains the deadband with the heating setpoint
+ * by adjusting the heating setpoint
+ *
+ * @param endpoint The endpoint on which the cooling setpoint has been changed
+ * @param currentHeatingSetpointThe current heating setpoint
+ * @param newCoolingSetpoint The newly adjusted cooling setpoint
+ * @param minHeatingSetpoint The minimum allowed cooling setpoint
+ * @param deadband The deadband to preserve
+ * @param setter A function for setting the heating setpoint
+ */
+void EnsureHeatingSetpointDeadband(EndpointId endpoint, int16_t currentHeatingSetpoint, int16_t newCoolingSetpoint,
+ int16_t minHeatingSetpoint, int16_t deadband, SetpointSetter setter)
+{
+ int16_t maxValidHeatingSetpoint = static_cast<int16_t>(newCoolingSetpoint - deadband);
+ if (currentHeatingSetpoint <= maxValidHeatingSetpoint)
+ {
+ // The current heating setpoint doesn't violate the deadband
+ return;
+ }
+ if (maxValidHeatingSetpoint < minHeatingSetpoint)
+ {
+ // Adjusting the heating setpoint to preserve the deadband would violate the min heating setpoint
+ // This should have been caught in CheckHeatingSetpointDeadband, so log and exit
+ ChipLogError(Zcl, "Failed ensuring heating setpoint deadband");
+ return;
+ }
+ // Adjust the heating setpoint to preserve deadband
+ auto status = setter(endpoint, maxValidHeatingSetpoint);
+ if (status != Status::Success)
+ {
+ ChipLogError(Zcl, "Error: EnsureHeatingSetpointDeadband failed!");
+ }
+}
+
+/**
+ * @brief For thermostats that support auto, shift setpoints to maintain the current deadband
+ * Note: this assumes that the shift is possible; setpoint changes which prevent the deadband
+ * from being maintained due to the min/max limits for setpoints should be rejected by
+ * MatterThermostatClusterServerPreAttributeChangedCallback
+ *
+ * @param attributePath
+ */
+void EnsureDeadband(const ConcreteAttributePath & attributePath)
+{
+ auto endpoint = attributePath.mEndpointId;
+ SetpointLimits setpointLimits;
+ auto status = GetSetpointLimits(endpoint, setpointLimits);
+ if (status != Status::Success)
+ {
+ return;
+ }
+ if (!setpointLimits.autoSupported)
+ {
+ return;
+ }
+ switch (attributePath.mAttributeId)
+ {
+ case OccupiedHeatingSetpoint::Id:
+ EnsureCoolingSetpointDeadband(endpoint, setpointLimits.occupiedCoolingSetpoint, setpointLimits.occupiedHeatingSetpoint,
+ setpointLimits.maxCoolSetpointLimit, setpointLimits.deadBand, OccupiedCoolingSetpoint::Set);
+ break;
+ case OccupiedCoolingSetpoint::Id:
+ EnsureHeatingSetpointDeadband(endpoint, setpointLimits.occupiedHeatingSetpoint, setpointLimits.occupiedCoolingSetpoint,
+ setpointLimits.minHeatSetpointLimit, setpointLimits.deadBand, OccupiedHeatingSetpoint::Set);
+ break;
+ case UnoccupiedHeatingSetpoint::Id:
+ EnsureCoolingSetpointDeadband(endpoint, setpointLimits.unoccupiedCoolingSetpoint, setpointLimits.unoccupiedHeatingSetpoint,
+ setpointLimits.maxCoolSetpointLimit, setpointLimits.deadBand, UnoccupiedCoolingSetpoint::Set);
+ break;
+ case UnoccupiedCoolingSetpoint::Id:
+ EnsureHeatingSetpointDeadband(endpoint, setpointLimits.unoccupiedHeatingSetpoint, setpointLimits.unoccupiedCoolingSetpoint,
+ setpointLimits.minHeatSetpointLimit, setpointLimits.deadBand, UnoccupiedHeatingSetpoint::Set);
+ break;
+ }
+}
+
Delegate * GetDelegate(EndpointId endpoint)
{
uint16_t ep =
@@ -477,14 +763,9 @@
return;
}
- auto featureMap = BitMask<Feature, uint32_t>(flags);
- if (!featureMap.Has(Feature::kPresets))
- {
- // This server does not support presets, so nothing to do
- return;
- }
-
- bool occupied = true;
+ auto featureMap = BitMask<Feature, uint32_t>(flags);
+ bool supportsPresets = featureMap.Has(Feature::kPresets);
+ bool occupied = true;
if (featureMap.Has(Feature::kOccupancy))
{
BitMask<OccupancyBitmap, uint8_t> occupancy;
@@ -499,19 +780,20 @@
{
case OccupiedHeatingSetpoint::Id:
case OccupiedCoolingSetpoint::Id:
- clearActivePreset = occupied;
+ clearActivePreset = supportsPresets && occupied;
+ EnsureDeadband(attributePath);
break;
case UnoccupiedHeatingSetpoint::Id:
case UnoccupiedCoolingSetpoint::Id:
- clearActivePreset = !occupied;
+ clearActivePreset = supportsPresets && !occupied;
+ EnsureDeadband(attributePath);
break;
}
- if (!clearActivePreset)
+ if (clearActivePreset)
{
- return;
+ ChipLogProgress(Zcl, "Setting active preset to null");
+ gThermostatAttrAccess.SetActivePreset(attributePath.mEndpointId, std::nullopt);
}
- ChipLogProgress(Zcl, "Setting active preset to null");
- gThermostatAttrAccess.SetActivePreset(attributePath.mEndpointId, std::nullopt);
}
} // namespace Thermostat
@@ -535,15 +817,15 @@
// or should this just be the responsibility of the thermostat application?
}
-Protocols::InteractionModel::Status
-MatterThermostatClusterServerPreAttributeChangedCallback(const app::ConcreteAttributePath & attributePath,
- EmberAfAttributeType attributeType, uint16_t size, uint8_t * value)
+Status MatterThermostatClusterServerPreAttributeChangedCallback(const app::ConcreteAttributePath & attributePath,
+ EmberAfAttributeType attributeType, uint16_t size, uint8_t * value)
{
EndpointId endpoint = attributePath.mEndpointId;
int16_t requested;
// Limits will be needed for all checks
// so we just get them all now
+ // TODO: use GetSetpointLimits to fetch this information
int16_t AbsMinHeatSetpointLimit;
int16_t AbsMaxHeatSetpointLimit;
int16_t MinHeatSetpointLimit;
@@ -649,12 +931,8 @@
if (requested < AbsMinHeatSetpointLimit || requested < MinHeatSetpointLimit || requested > AbsMaxHeatSetpointLimit ||
requested > MaxHeatSetpointLimit)
return Status::InvalidValue;
- if (AutoSupported)
- {
- if (requested > OccupiedCoolingSetpoint - DeadBandTemp)
- return Status::InvalidValue;
- }
- return Status::Success;
+ return CheckCoolingSetpointDeadband(AutoSupported, requested, std::min(MaxCoolSetpointLimit, AbsMaxCoolSetpointLimit),
+ DeadBandTemp);
}
case OccupiedCoolingSetpoint::Id: {
@@ -664,12 +942,8 @@
if (requested < AbsMinCoolSetpointLimit || requested < MinCoolSetpointLimit || requested > AbsMaxCoolSetpointLimit ||
requested > MaxCoolSetpointLimit)
return Status::InvalidValue;
- if (AutoSupported)
- {
- if (requested < OccupiedHeatingSetpoint + DeadBandTemp)
- return Status::InvalidValue;
- }
- return Status::Success;
+ return CheckHeatingSetpointDeadband(AutoSupported, requested, std::max(MinHeatSetpointLimit, AbsMinHeatSetpointLimit),
+ DeadBandTemp);
}
case UnoccupiedHeatingSetpoint::Id: {
@@ -679,12 +953,8 @@
if (requested < AbsMinHeatSetpointLimit || requested < MinHeatSetpointLimit || requested > AbsMaxHeatSetpointLimit ||
requested > MaxHeatSetpointLimit)
return Status::InvalidValue;
- if (AutoSupported)
- {
- if (requested > UnoccupiedCoolingSetpoint - DeadBandTemp)
- return Status::InvalidValue;
- }
- return Status::Success;
+ return CheckCoolingSetpointDeadband(AutoSupported, requested, std::min(MaxCoolSetpointLimit, AbsMaxCoolSetpointLimit),
+ DeadBandTemp);
}
case UnoccupiedCoolingSetpoint::Id: {
requested = static_cast<int16_t>(chip::Encoding::LittleEndian::Get16(value));
@@ -693,12 +963,8 @@
if (requested < AbsMinCoolSetpointLimit || requested < MinCoolSetpointLimit || requested > AbsMaxCoolSetpointLimit ||
requested > MaxCoolSetpointLimit)
return Status::InvalidValue;
- if (AutoSupported)
- {
- if (requested < UnoccupiedHeatingSetpoint + DeadBandTemp)
- return Status::InvalidValue;
- }
- return Status::Success;
+ return CheckHeatingSetpointDeadband(AutoSupported, requested, std::max(MinHeatSetpointLimit, AbsMinHeatSetpointLimit),
+ DeadBandTemp);
}
case MinHeatSetpointLimit::Id: {