mirror of
https://github.com/home-assistant/core.git
synced 2026-10-07 23:09:19 -04:00
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:
co-authored by
Claude Opus 5.5
parent
4155b03535
commit
95481cb2f8
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user