From 384adb1c87184a83fb5e9d6cbe84401f5c993ffc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 11:22:57 +0200 Subject: [PATCH] Avoid redundant calls to `async_write_ha_state` in MQTT climate & water_heater (#100696) Limit state writes for mqtt climate & water_heater --- homeassistant/components/mqtt/climate.py | 30 ++++++---- homeassistant/components/mqtt/water_heater.py | 10 +++- tests/components/mqtt/test_climate.py | 58 +++++++++++++++++++ tests/components/mqtt/test_water_heater.py | 41 +++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index d5bda57c2b3..dfd5d70dca6 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -82,7 +82,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -90,7 +95,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -478,11 +483,9 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): return if payload == PAYLOAD_NONE: setattr(self, attr, None) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: setattr(self, attr, float(payload)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) except ValueError: _LOGGER.error("Could not parse %s from %s", template_name, payload) @@ -493,6 +496,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_temperature"}) def handle_current_temperature_received(msg: ReceiveMessage) -> None: """Handle current temperature coming via MQTT.""" self.handle_climate_attribute_received( @@ -505,6 +509,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature"}) def handle_target_temperature_received(msg: ReceiveMessage) -> None: """Handle target temperature coming via MQTT.""" self.handle_climate_attribute_received( @@ -517,6 +522,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature_low"}) def handle_temperature_low_received(msg: ReceiveMessage) -> None: """Handle target temperature low coming via MQTT.""" self.handle_climate_attribute_received( @@ -529,6 +535,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature_high"}) def handle_temperature_high_received(msg: ReceiveMessage) -> None: """Handle target temperature high coming via MQTT.""" self.handle_climate_attribute_received( @@ -789,6 +796,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_hvac_action"}) def handle_action_received(msg: ReceiveMessage) -> None: """Handle receiving action via MQTT.""" payload = self.render_template(msg, CONF_ACTION_TEMPLATE) @@ -808,12 +816,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_humidity"}) def handle_current_humidity_received(msg: ReceiveMessage) -> None: """Handle current humidity coming via MQTT.""" self.handle_climate_attribute_received( @@ -825,6 +833,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ) @callback + @write_state_on_attr_change(self, {"_attr_target_humidity"}) @log_messages(self.hass, self.entity_id) def handle_target_humidity_received(msg: ReceiveMessage) -> None: """Handle target humidity coming via MQTT.""" @@ -848,10 +857,10 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _LOGGER.error("Invalid %s mode: %s", mode_list, payload) else: setattr(self, attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_hvac_mode"}) def handle_current_mode_received(msg: ReceiveMessage) -> None: """Handle receiving mode via MQTT.""" handle_mode_received( @@ -864,6 +873,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_fan_mode"}) def handle_fan_mode_received(msg: ReceiveMessage) -> None: """Handle receiving fan mode via MQTT.""" handle_mode_received( @@ -879,6 +889,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_swing_mode"}) def handle_swing_mode_received(msg: ReceiveMessage) -> None: """Handle receiving swing mode via MQTT.""" handle_mode_received( @@ -913,13 +924,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): else: _LOGGER.error("Invalid %s mode: %s", attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 # Support will be removed in HA Core 2024.3 @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_aux_heat"}) def handle_aux_mode_received(msg: ReceiveMessage) -> None: """Handle receiving aux mode via MQTT.""" handle_onoff_mode_received( @@ -930,12 +940,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_preset_mode"}) def handle_preset_mode_received(msg: ReceiveMessage) -> None: """Handle receiving preset mode via MQTT.""" preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: self._attr_preset_mode = PRESET_NONE - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) @@ -953,8 +963,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): else: self._attr_preset_mode = str(preset_mode) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self.add_subscription( topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received ) diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 08b9d36d850..f35e7f8b0ea 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -65,9 +65,13 @@ from .const import ( DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -292,10 +296,10 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _LOGGER.error("Invalid %s mode: %s", mode_list, payload) else: setattr(self, attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_operation"}) def handle_current_mode_received(msg: ReceiveMessage) -> None: """Handle receiving operation mode via MQTT.""" handle_mode_received( diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9e0363b3611..9c0adbe2adf 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -58,6 +58,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2555,3 +2556,60 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "action_topic": "action-topic", + "fan_mode_state_topic": "fan-mode-state-topic", + "mode_state_topic": "mode-state-topic", + "current_humidity_topic": "current-humidity-topic", + "current_temperature_topic": "current-temperature-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_modes": ["eco", "away"], + "swing_mode_state_topic": "swing-mode-state-topic", + "target_humidity_state_topic": "target-humidity-state-topic", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_state_topic": "temperature-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("action-topic", "cooling", "heating"), + ("fan-mode-state-topic", "low", "medium"), + ("mode-state-topic", "cool", "heat"), + ("current-humidity-topic", "45", "46"), + ("current-temperature-topic", "18.0", "18.1"), + ("preset-mode-state-topic", "eco", "away"), + ("swing-mode-state-topic", "on", "off"), + ("target-humidity-state-topic", "45", "50"), + ("temperature-state-topic", "18", "19"), + ("temperature-low-state-topic", "18", "19"), + ("temperature-high-state-topic", "18", "19"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 245af5c6918..60c3af63bf4 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -52,6 +52,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1220,3 +1221,43 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "mode_state_topic": "mode-state-topic", + "current_temperature_topic": "current-temperature-topic", + "temperature_state_topic": "temperature-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("mode-state-topic", "gas", "electric"), + ("current-temperature-topic", "18.0", "18.1"), + ("temperature-state-topic", "18", "19"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2)