Add cooling preset temperatures for HEAT_COOL mode#1945
Add cooling preset temperatures for HEAT_COOL mode#1945quittung wants to merge 7 commits intoKartoffelToby:developfrom
Conversation
When a cooler entity is configured, presets now also control the cooling target temperature. Adds BetterThermostatPresetCoolNumber entities alongside the existing heating preset numbers. - Heating-only thermostats: unchanged (e.g. "Away") - Thermostats with cooler: "Away Min" / "Away Max" number entities - Preset switching saves/restores both heating and cooling targets - Cooling preset temps persisted via state attributes and RestoreEntity - Sensible defaults: Away 28, Home 24, Sleep 22, Comfort 24, Eco 27, Boost 20, Activity 23 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)✅ Unit Test PR creation complete.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
custom_components/better_thermostat/climate.py (2)
3590-3604:⚠️ Potential issue | 🟡 Minor
_preset_cool_temperatureis not persisted or restored across restarts.The heating counterpart
_preset_temperatureis persisted viaextra_state_attributes(asATTR_STATE_PRESET_TEMPERATURE) and restored during startup (lines 1271–1282). The new_preset_cool_temperaturehas no corresponding attribute or restore logic.If HA restarts while a non-NONE preset is active, the "before-preset" cooling target is lost. When the user later returns to
PRESET_NONE,bt_target_cooltempwon't be restored to its pre-preset value.Proposed fix — add to extra_state_attributes and startup restore
In
extra_state_attributes(around line 2675):ATTR_STATE_PRESET_TEMPERATURE: self._preset_temperature, + "bt_preset_cool_temperature": self._preset_cool_temperature,In startup restore (after line 1282):
+ if old_state.attributes.get("bt_preset_cool_temperature", None) is not None: + self._preset_cool_temperature = convert_to_float( + str(old_state.attributes.get("bt_preset_cool_temperature", None)), + self.device_name, + "startup()", + )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@custom_components/better_thermostat/climate.py` around lines 3590 - 3604, Add persistence and restore for the cooling preset temperature: include _preset_cool_temperature in extra_state_attributes (using a new ATTR_STATE_PRESET_COOL_TEMPERATURE constant analogous to ATTR_STATE_PRESET_TEMPERATURE) so its value is saved, and update the startup/restore logic (where _preset_temperature is restored) to read that attribute, set self._preset_cool_temperature and, if present, restore self.bt_target_cooltemp accordingly; ensure references to _preset_cool_temperature, bt_target_cooltemp, extra_state_attributes and the new ATTR_STATE_PRESET_COOL_TEMPERATURE are updated consistently.
3623-3642:⚠️ Potential issue | 🟠 MajorNo heating/cooling ordering enforcement after preset application.
async_set_preset_modedirectly assigns bothbt_target_temp(line 3625) andbt_target_cooltemp(line 3636) without checking thatbt_target_cooltemp > bt_target_temp. Theasync_set_temperaturemethod has explicit ordering enforcement (lines 3248–3263 and others), butasync_set_preset_modebypasses it.Even if the BOOST defaults are fixed, a user could configure presets via the number entities such that
heating ≥ cooling. Add a post-application check:Proposed fix
if self.cooler_entity_id is not None and preset_mode in self._preset_cool_temperatures: cool_temp = self._preset_cool_temperatures[preset_mode] self.bt_target_cooltemp = min(self.max_temp, max(self.min_temp, cool_temp)) _LOGGER.debug( "better_thermostat %s: Applied preset %s cooling temperature: %s°C", self.device_name, preset_mode, self.bt_target_cooltemp, ) + + # Enforce ordering: cooling target must be above heating target + if ( + self.cooler_entity_id is not None + and self.bt_target_cooltemp is not None + and self.bt_target_temp is not None + and self.bt_target_cooltemp <= self.bt_target_temp + ): + step = self.bt_target_temp_step or 0.5 + self.bt_target_cooltemp = self.bt_target_temp + step + _LOGGER.warning( + "better_thermostat %s: Preset %s cooling target adjusted to %.2f to stay above heating target %.2f", + self.device_name, + preset_mode, + self.bt_target_cooltemp, + self.bt_target_temp, + )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@custom_components/better_thermostat/climate.py` around lines 3623 - 3642, async_set_preset_mode sets self.bt_target_temp and self.bt_target_cooltemp directly and can violate the required invariant that cooling > heating; mirror the ordering enforcement used in async_set_temperature: after computing and assigning bt_target_temp and (if present) bt_target_cooltemp, check if self.bt_target_cooltemp is not None and self.bt_target_cooltemp <= self.bt_target_temp, and if so adjust the cooling value to at least self.bt_target_temp + the minimal step (or smallest allowed delta used by async_set_temperature), clamped to self.max_temp/self.min_temp, then log the adjustment; this keeps the same variables (bt_target_temp, bt_target_cooltemp, self._preset_cool_temperatures) and reuses the ordering logic from async_set_temperature to enforce heating < cooling.
🧹 Nitpick comments (1)
custom_components/better_thermostat/number.py (1)
204-223: Consider sharing a base class to reduce duplication between preset number entities.
BetterThermostatPresetCoolNumberis nearly identical toBetterThermostatPresetNumber— the same class attributes, similar__init__,async_added_to_hass,device_info,native_value, andasync_set_native_value. The only differences are:
- Which dict is used (
_preset_cool_temperaturesvs_preset_temperatures)- The unique_id suffix (
_cool)- The name suffix (
MaxvsMin/ none)- The
async_set_temperaturecall kwarg (target_temp_highvstemperature)This is fine for now, but extracting a shared base or parameterizing a single class would reduce maintenance burden if more logic is added later.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@custom_components/better_thermostat/number.py` around lines 204 - 223, Refactor the nearly identical BetterThermostatPresetCoolNumber and BetterThermostatPresetNumber by extracting a shared base class (e.g., BetterThermostatPresetBaseNumber) or by parameterizing a single class to accept the differing bits: the preset dict to read/write (_preset_cool_temperatures vs _preset_temperatures), the unique_id/name suffix (`_cool` and "Max" vs none/"Min"), and the temperature kwarg used in async_set_temperature (`target_temp_high` vs `temperature`). Move the common attributes (_attr_* declarations), __init__ logic for min/max/step, async_added_to_hass, device_info, native_value, and async_set_native_value into the base/parameterized class and use constructor params or subclass properties to supply the three differences so both original classes become thin wrappers that pass the correct dict, id/name suffix, and async_set_temperature kwarg.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@custom_components/better_thermostat/climate.py`:
- Around line 412-423: The BOOST preset currently in _preset_cool_temperatures
(PRESET_BOOST = 20.0) inverts heat/cool targets for HEAT_COOL mode (heating=24.0
vs cooling=20.0); update the preset defaults so heating < cooling: either change
the PRESET_BOOST value in _preset_cool_temperatures to be >= the BOOST heating
value, swap the BOOST values between the heat and cool preset maps, or remove
BOOST from the cooling presets; also update _original_preset_cool_temperatures
after the change so copies remain consistent and verify behavior when mode ==
HEAT_COOL.
---
Outside diff comments:
In `@custom_components/better_thermostat/climate.py`:
- Around line 3590-3604: Add persistence and restore for the cooling preset
temperature: include _preset_cool_temperature in extra_state_attributes (using a
new ATTR_STATE_PRESET_COOL_TEMPERATURE constant analogous to
ATTR_STATE_PRESET_TEMPERATURE) so its value is saved, and update the
startup/restore logic (where _preset_temperature is restored) to read that
attribute, set self._preset_cool_temperature and, if present, restore
self.bt_target_cooltemp accordingly; ensure references to
_preset_cool_temperature, bt_target_cooltemp, extra_state_attributes and the new
ATTR_STATE_PRESET_COOL_TEMPERATURE are updated consistently.
- Around line 3623-3642: async_set_preset_mode sets self.bt_target_temp and
self.bt_target_cooltemp directly and can violate the required invariant that
cooling > heating; mirror the ordering enforcement used in
async_set_temperature: after computing and assigning bt_target_temp and (if
present) bt_target_cooltemp, check if self.bt_target_cooltemp is not None and
self.bt_target_cooltemp <= self.bt_target_temp, and if so adjust the cooling
value to at least self.bt_target_temp + the minimal step (or smallest allowed
delta used by async_set_temperature), clamped to self.max_temp/self.min_temp,
then log the adjustment; this keeps the same variables (bt_target_temp,
bt_target_cooltemp, self._preset_cool_temperatures) and reuses the ordering
logic from async_set_temperature to enforce heating < cooling.
---
Nitpick comments:
In `@custom_components/better_thermostat/number.py`:
- Around line 204-223: Refactor the nearly identical
BetterThermostatPresetCoolNumber and BetterThermostatPresetNumber by extracting
a shared base class (e.g., BetterThermostatPresetBaseNumber) or by
parameterizing a single class to accept the differing bits: the preset dict to
read/write (_preset_cool_temperatures vs _preset_temperatures), the
unique_id/name suffix (`_cool` and "Max" vs none/"Min"), and the temperature
kwarg used in async_set_temperature (`target_temp_high` vs `temperature`). Move
the common attributes (_attr_* declarations), __init__ logic for min/max/step,
async_added_to_hass, device_info, native_value, and async_set_native_value into
the base/parameterized class and use constructor params or subclass properties
to supply the three differences so both original classes become thin wrappers
that pass the correct dict, id/name suffix, and async_set_temperature kwarg.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
custom_components/better_thermostat/climate.pycustom_components/better_thermostat/number.py
| self._preset_cool_temperatures = { | ||
| PRESET_NONE: 24.0, | ||
| PRESET_AWAY: 28.0, | ||
| PRESET_BOOST: 20.0, | ||
| PRESET_COMFORT: 24.0, | ||
| PRESET_ECO: 27.0, | ||
| PRESET_HOME: 24.0, | ||
| PRESET_SLEEP: 22.0, | ||
| PRESET_ACTIVITY: 23.0, | ||
| } | ||
| self._original_preset_cool_temperatures = self._preset_cool_temperatures.copy() | ||
| self._preset_cool_temperature = None # saved cool temp before entering preset |
There was a problem hiding this comment.
BOOST preset defaults will invert heating/cooling targets in HEAT_COOL mode.
The BOOST preset has heating=24.0 and cooling=20.0, which means bt_target_temp (24) > bt_target_cooltemp (20). In HEAT_COOL mode, the low target (heating) must be below the high target (cooling), otherwise the system would simultaneously try to heat to 24°C and cool to 20°C.
All other presets are correctly ordered (heating < cooling). BOOST is the only violation.
Proposed fix — swap or adjust BOOST defaults
self._preset_cool_temperatures = {
PRESET_NONE: 24.0,
PRESET_AWAY: 28.0,
- PRESET_BOOST: 20.0,
+ PRESET_BOOST: 28.0,
PRESET_COMFORT: 24.0,
PRESET_ECO: 27.0,
PRESET_HOME: 24.0,
PRESET_SLEEP: 22.0,
PRESET_ACTIVITY: 23.0,
}Alternatively, if "BOOST cooling" should mean aggressive cooling (low setpoint), then the heating BOOST should also be lowered below it, or BOOST could be excluded from cooling presets entirely. The choice depends on the intended semantics of BOOST in dual-mode setups.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@custom_components/better_thermostat/climate.py` around lines 412 - 423, The
BOOST preset currently in _preset_cool_temperatures (PRESET_BOOST = 20.0)
inverts heat/cool targets for HEAT_COOL mode (heating=24.0 vs cooling=20.0);
update the preset defaults so heating < cooling: either change the PRESET_BOOST
value in _preset_cool_temperatures to be >= the BOOST heating value, swap the
BOOST values between the heat and cool preset maps, or remove BOOST from the
cooling presets; also update _original_preset_cool_temperatures after the change
so copies remain consistent and verify behavior when mode == HEAT_COOL.
- Fix BOOST cooling default (20 -> 28) to avoid inverting heat/cool targets - Persist _preset_cool_temperature across restarts via state attributes - Add heating < cooling ordering enforcement in async_set_preset_mode - Fix control queue not triggered when cooling preset number changes (bypass async_set_temperature, set bt_target_cooltemp directly) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
custom_components/better_thermostat/climate.py (1)
1283-1293: Consider defining constants for the new attribute keys.The heating preset temperature uses the
ATTR_STATE_PRESET_TEMPERATUREconstant, but the cooling counterparts use hardcoded strings"bt_preset_cool_temperature"and"bt_preset_cool_temperatures"in three places (lines 1284, 2687, 2702). Extracting these into constants inutils/const.pywould keep the pattern consistent and reduce the risk of typo-induced mismatches.Also applies to: 2687-2687, 2702-2702
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@custom_components/better_thermostat/climate.py` around lines 1283 - 1293, Extract the hardcoded cooling attribute keys ("bt_preset_cool_temperature" and "bt_preset_cool_temperatures") into named constants in utils/const.py (e.g., ATTR_STATE_PRESET_COOL_TEMPERATURE and ATTR_STATE_PRESET_COOL_TEMPERATURES) and replace all direct string usages in climate.py—including the startup() logic that sets self._preset_cool_temperature and the other two occurrences referenced—to use those constants; update any imports in climate.py to pull the new constants and ensure the existing heating constant usage (ATTR_STATE_PRESET_TEMPERATURE) remains consistent with this pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@custom_components/better_thermostat/number.py`:
- Around line 258-272: The async_set_native_value in
BetterThermostatPresetNumber sets _bt_climate.bt_target_cooltemp directly when
the preset is active, which can violate ordering with
_bt_climate.bt_target_temp; modify async_set_native_value to enforce cooling >
heating before assigning (similar to async_set_temperature) — e.g., compute and
clamp the new cool target against _bt_climate.bt_target_temp (or call
_bt_climate.async_set_temperature-like logic) so bt_target_cooltemp is adjusted
if necessary, then proceed to set bt_target_cooltemp, queue control_queue_task
when _bt_climate.bt_hvac_mode != HVACMode.OFF, and call async_write_ha_state on
both entities.
---
Nitpick comments:
In `@custom_components/better_thermostat/climate.py`:
- Around line 1283-1293: Extract the hardcoded cooling attribute keys
("bt_preset_cool_temperature" and "bt_preset_cool_temperatures") into named
constants in utils/const.py (e.g., ATTR_STATE_PRESET_COOL_TEMPERATURE and
ATTR_STATE_PRESET_COOL_TEMPERATURES) and replace all direct string usages in
climate.py—including the startup() logic that sets self._preset_cool_temperature
and the other two occurrences referenced—to use those constants; update any
imports in climate.py to pull the new constants and ensure the existing heating
constant usage (ATTR_STATE_PRESET_TEMPERATURE) remains consistent with this
pattern.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
custom_components/better_thermostat/climate.pycustom_components/better_thermostat/number.py
| async def async_set_native_value(self, value: float) -> None: | ||
| """Update the current value.""" | ||
| self._bt_climate._preset_cool_temperatures[self._preset_mode] = value | ||
|
|
||
| # If this preset is currently active, update the cooling target immediately. | ||
| # We set bt_target_cooltemp directly and trigger the control queue because | ||
| # async_set_temperature does not trigger the control queue when only | ||
| # target_temp_high is provided. | ||
| if self._bt_climate.preset_mode == self._preset_mode: | ||
| self._bt_climate.bt_target_cooltemp = value | ||
| if self._bt_climate.bt_hvac_mode != HVACMode.OFF: | ||
| await self._bt_climate.control_queue_task.put(self._bt_climate) | ||
|
|
||
| self.async_write_ha_state() | ||
| self._bt_climate.async_write_ha_state() |
There was a problem hiding this comment.
Missing heating/cooling ordering enforcement when setting cooling preset directly.
When the preset is active, bt_target_cooltemp is set directly (line 267) without verifying it stays above bt_target_temp. The heating counterpart (BetterThermostatPresetNumber) is protected because it routes through async_set_temperature, which enforces ordering. This bypass could leave the system with inverted targets (cooltemp ≤ heat_target) until the next preset switch.
Proposed fix — add ordering enforcement
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
self._bt_climate._preset_cool_temperatures[self._preset_mode] = value
# If this preset is currently active, update the cooling target immediately.
- # We set bt_target_cooltemp directly and trigger the control queue because
- # async_set_temperature does not trigger the control queue when only
- # target_temp_high is provided.
if self._bt_climate.preset_mode == self._preset_mode:
self._bt_climate.bt_target_cooltemp = value
+ # Enforce ordering: cooling target must be above heating target
+ if (
+ self._bt_climate.bt_target_temp is not None
+ and self._bt_climate.bt_target_cooltemp <= self._bt_climate.bt_target_temp
+ ):
+ step = self._bt_climate.bt_target_temp_step or 0.5
+ self._bt_climate.bt_target_cooltemp = self._bt_climate.bt_target_temp + step
if self._bt_climate.bt_hvac_mode != HVACMode.OFF:
await self._bt_climate.control_queue_task.put(self._bt_climate)
self.async_write_ha_state()
self._bt_climate.async_write_ha_state()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@custom_components/better_thermostat/number.py` around lines 258 - 272, The
async_set_native_value in BetterThermostatPresetNumber sets
_bt_climate.bt_target_cooltemp directly when the preset is active, which can
violate ordering with _bt_climate.bt_target_temp; modify async_set_native_value
to enforce cooling > heating before assigning (similar to async_set_temperature)
— e.g., compute and clamp the new cool target against _bt_climate.bt_target_temp
(or call _bt_climate.async_set_temperature-like logic) so bt_target_cooltemp is
adjusted if necessary, then proceed to set bt_target_cooltemp, queue
control_queue_task when _bt_climate.bt_hvac_mode != HVACMode.OFF, and call
async_write_ha_state on both entities.
- Extract shared _BetterThermostatPresetBaseNumber base class - Add heating < cooling ordering check in cooling number setter to prevent inverted targets when editing cooling preset directly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Note Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it. Generating unit tests... This may take up to 20 minutes. |
1 similar comment
|
Note Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it. Generating unit tests... This may take up to 20 minutes. |
|
❌ Failed to create PR with unit tests: AGENT_CHAT: Failed to open pull request |
1 similar comment
|
❌ Failed to create PR with unit tests: AGENT_CHAT: Failed to open pull request |
Summary
BetterThermostatPresetCoolNumberentitiesDetails
Currently, switching presets only changes
bt_target_temp(the heating floor). When a cooler entity is configured and the thermostat operates inHEAT_COOLmode, the cooling ceiling (bt_target_cooltemp) is left unchanged. This means users have to manually adjust cooling targets after every preset switch.Changes:
climate.py:_preset_cool_temperaturesdict with sensible defaults (Away: 28, Home: 24, Sleep: 22, Comfort: 24, Eco: 27, Boost: 20, Activity: 23)async_set_preset_modenow saves/restoresbt_target_cooltempwhen entering/leaving presets (only whencooler_entity_idis set)extra_state_attributesfor persistencenumber.py:BetterThermostatPresetCoolNumber) are only created when a cooler is configuredNaming rationale: The existing "Preset Away" naming was already getting clipped in the device info UI. Adding "Cool" to distinguish cooling entities made it worse — "Preset Away" and "Preset Away..." were indistinguishable. Dropping the "Preset" prefix and using short Min/Max suffixes keeps names readable. For heating-only setups (no cooler), the suffix is omitted entirely since there's no ambiguity.
Defaults
Test plan
bt_target_tempandbt_target_cooltempchangebt_target_cooltempupdates immediatelySummary by CodeRabbit
Release Notes