Swap optimistic MQTT climate setpoints on HVAC mode change (#184122)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan Bouwhuis
2026-10-03 13:02:08 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4155b03535
commit 95481cb2f8
2 changed files with 193 additions and 14 deletions
+61 -14
View File
@@ -471,6 +471,8 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
_attr_target_temperature_low: float | None = None
_attr_target_temperature_high: float | None = None
_single_and_range_setpoints: bool
_parked_target_temperature: float | None = None
_parked_target_temperature_range: tuple[float | None, float | None] = (None, None)
@staticmethod
@override
@@ -677,9 +679,8 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
) is UNDEFINED:
return
self._attr_target_temperature = value
if value is not None and self._single_and_range_setpoints:
self._attr_target_temperature_low = None
self._attr_target_temperature_high = None
if value is not None:
self._park_range_setpoints()
@callback
def _handle_target_temperature_low_received(self, msg: ReceiveMessage) -> None:
@@ -691,8 +692,8 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
) is UNDEFINED:
return
self._attr_target_temperature_low = value
if value is not None and self._single_and_range_setpoints:
self._attr_target_temperature = None
if value is not None:
self._park_single_setpoint()
@callback
def _handle_target_temperature_high_received(self, msg: ReceiveMessage) -> None:
@@ -704,8 +705,8 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
) is UNDEFINED:
return
self._attr_target_temperature_high = value
if value is not None and self._single_and_range_setpoints:
self._attr_target_temperature = None
if value is not None:
self._park_single_setpoint()
@callback
def _handle_current_humidity_received(self, msg: ReceiveMessage) -> None:
@@ -831,9 +832,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
optimistic_update = True
self._attr_target_temperature = temperature
# We reset low and high setpoints when a single setpoint is set
if self._single_and_range_setpoints:
self._attr_target_temperature_low = None
self._attr_target_temperature_high = None
self._park_range_setpoints()
mqtt_payload = self._command_templates[CONF_TEMP_COMMAND_TEMPLATE](
temperature
)
@@ -845,8 +844,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
optimistic_update = True
self._attr_target_temperature_low = target_temp_low
# We reset the single setpoint when a setpoint range is set
if self._single_and_range_setpoints:
self._attr_target_temperature = None
self._park_single_setpoint()
mqtt_payload = self._command_templates[CONF_TEMP_LOW_COMMAND_TEMPLATE](
target_temp_low
)
@@ -857,8 +855,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if self._optimistic or self._topic[CONF_TEMP_HIGH_STATE_TOPIC] is None:
optimistic_update = True
self._attr_target_temperature_high = target_temp_high
if self._single_and_range_setpoints:
self._attr_target_temperature = None
self._park_single_setpoint()
mqtt_payload = self._command_templates[CONF_TEMP_HIGH_COMMAND_TEMPLATE](
target_temp_high
)
@@ -920,8 +917,58 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if self._optimistic or self._topic[CONF_MODE_STATE_TOPIC] is None:
self._attr_hvac_mode = hvac_mode
self._swap_setpoints(hvac_mode)
self.async_write_ha_state()
def _park_single_setpoint(self) -> None:
"""Park and reset the single setpoint when a setpoint range is active."""
if not self._single_and_range_setpoints or self.target_temperature is None:
return
self._parked_target_temperature = self.target_temperature
self._attr_target_temperature = None
def _park_range_setpoints(self) -> None:
"""Park and reset the setpoint range when a single setpoint is active."""
temp_range = (self.target_temperature_low, self.target_temperature_high)
if not self._single_and_range_setpoints or temp_range == (None, None):
return
self._parked_target_temperature_range = temp_range
self._attr_target_temperature_low = None
self._attr_target_temperature_high = None
def _swap_setpoints(self, hvac_mode: HVACMode) -> None:
"""Swap optimistic single and range setpoints on an HVAC mode change.
The inactive setpoints are parked, so they can be restored
when switching back, as the device is expected to remember them.
"""
if not self._single_and_range_setpoints or hvac_mode not in (
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.HEAT_COOL,
):
return
uses_range = hvac_mode is HVACMode.HEAT_COOL
if self._optimistic or self._topic[CONF_TEMP_STATE_TOPIC] is None:
if uses_range:
self._park_single_setpoint()
elif self.target_temperature is None:
self._attr_target_temperature = self._parked_target_temperature
if self._optimistic or (
self._topic[CONF_TEMP_LOW_STATE_TOPIC] is None
and self._topic[CONF_TEMP_HIGH_STATE_TOPIC] is None
):
if not uses_range:
self._park_range_setpoints()
elif (
self.target_temperature_low is None
and self.target_temperature_high is None
):
(
self._attr_target_temperature_low,
self._attr_target_temperature_high,
) = self._parked_target_temperature_range
@override
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set a preset mode."""
+132
View File
@@ -1127,6 +1127,138 @@ async def test_received_setpoint_resets_other_setpoints(
) == expected
SWAP_MODES = {"modes": ["off", "auto", "heat_cool", "heat", "cool"]}
def _setpoints(hass: HomeAssistant) -> tuple[float | None, float | None, float | None]:
"""Return the single and range setpoints of the climate entity."""
state = hass.states.get(ENTITY_CLIMATE)
return (
state.attributes.get("temperature"),
state.attributes.get("target_temp_low"),
state.attributes.get("target_temp_high"),
)
@pytest.mark.parametrize(
"hass_config",
[help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, (SWAP_MODES,))],
)
async def test_set_hvac_mode_swaps_setpoints_optimistic(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test inactive optimistic setpoints are parked and restored on mode changes."""
await mqtt_mock_entry()
assert _setpoints(hass) == (21, 21, 21)
await common.async_set_hvac_mode(hass, HVACMode.HEAT, ENTITY_CLIMATE)
assert _setpoints(hass) == (21, None, None)
await common.async_set_temperature(hass, temperature=19, entity_id=ENTITY_CLIMATE)
await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL, ENTITY_CLIMATE)
assert _setpoints(hass) == (None, 21, 21)
await common.async_set_temperature(
hass, target_temp_low=20, target_temp_high=23, entity_id=ENTITY_CLIMATE
)
await common.async_set_hvac_mode(hass, HVACMode.COOL, ENTITY_CLIMATE)
assert _setpoints(hass) == (19, None, None)
# Modes without specific setpoint logic leave the setpoints untouched
await common.async_set_hvac_mode(hass, HVACMode.OFF, ENTITY_CLIMATE)
assert _setpoints(hass) == (19, None, None)
await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL, ENTITY_CLIMATE)
assert _setpoints(hass) == (None, 20, 23)
await common.async_set_hvac_mode(hass, HVACMode.HEAT, ENTITY_CLIMATE)
assert _setpoints(hass) == (19, None, None)
await common.async_set_hvac_mode(hass, HVACMode.AUTO, ENTITY_CLIMATE)
assert _setpoints(hass) == (19, None, None)
# Setpoints reset by a temperature command are parked as well
await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL, ENTITY_CLIMATE)
await common.async_set_temperature(
hass, target_temp_low=21, target_temp_high=24, entity_id=ENTITY_CLIMATE
)
await common.async_set_hvac_mode(hass, HVACMode.AUTO, ENTITY_CLIMATE)
await common.async_set_temperature(hass, temperature=18, entity_id=ENTITY_CLIMATE)
assert _setpoints(hass) == (18, None, None)
await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL, ENTITY_CLIMATE)
assert _setpoints(hass) == (None, 21, 24)
@pytest.mark.parametrize(
("hass_config", "messages", "expected"),
[
pytest.param(
help_custom_config(
climate.DOMAIN,
DEFAULT_CONFIG,
({**SETPOINT_STATE_TOPICS, **SWAP_MODES, "optimistic": True},),
),
[],
[(21, None, None), (None, 21, 21)],
id="optimistic_with_state_topics",
),
pytest.param(
help_custom_config(
climate.DOMAIN,
DEFAULT_CONFIG,
(
{
"temperature_low_state_topic": "temperature-low-state",
"temperature_high_state_topic": "temperature-high-state",
**SWAP_MODES,
},
),
),
[("temperature-low-state", "18"), ("temperature-high-state", "25")],
[(21, 18, 25), (None, 18, 25)],
id="range_from_device",
),
pytest.param(
help_custom_config(
climate.DOMAIN,
DEFAULT_CONFIG,
({"temperature_state_topic": "temperature-state", **SWAP_MODES},),
),
[("temperature-state", "19")],
[(19, None, None), (19, 21, 21)],
id="single_from_device",
),
pytest.param(
help_custom_config(
climate.DOMAIN,
DEFAULT_CONFIG,
({"mode_state_topic": "mode-state", **SWAP_MODES},),
),
[],
[(21, 21, 21), (21, 21, 21)],
id="mode_from_device",
),
],
)
async def test_set_hvac_mode_keeps_setpoints(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
messages: list[tuple[str, str]],
expected: list[tuple[float | None, float | None, float | None]],
) -> None:
"""Test mode changes only swap setpoints that are optimistic and apply."""
await mqtt_mock_entry()
for topic, payload in messages:
async_fire_mqtt_message(hass, topic, payload)
await common.async_set_hvac_mode(hass, HVACMode.HEAT, ENTITY_CLIMATE)
assert _setpoints(hass) == expected[0]
await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL, ENTITY_CLIMATE)
assert _setpoints(hass) == expected[1]
@pytest.mark.parametrize(
"hass_config",
[