From 95481cb2f8d55b6cc3c7e3a27cfbeb83a6855d6d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 3 Oct 2026 13:02:08 +0200 Subject: [PATCH] Swap optimistic MQTT climate setpoints on HVAC mode change (#184122) Co-authored-by: Claude Opus 5.5 --- homeassistant/components/mqtt/climate.py | 75 ++++++++++--- tests/components/mqtt/test_climate.py | 132 +++++++++++++++++++++++ 2 files changed, 193 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 2b0d84912b7f..f469bb6a533b 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -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.""" diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 4c84de16282d..891defba8513 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -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", [