From 83846bb5cc7513ec832a75b810c263b2b8fe027e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis <jbouwh@users.noreply.github.com> Date: Thu, 17 Feb 2022 13:51:35 +0100 Subject: [PATCH] MQTT climate preset_modes rework (#66062) * MQTT climate preset_modes rework * Set deprection date to 2022.9 (6 months) * add valid_preset_mode_configuration for discovery * Update deprecation date --- homeassistant/components/mqtt/climate.py | 173 +++++++++++-- tests/components/mqtt/test_climate.py | 313 +++++++++++++++++++---- 2 files changed, 427 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 043a291f159..e145edde7d7 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -80,6 +80,7 @@ CONF_ACTION_TOPIC = "action_topic" CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 CONF_AWAY_MODE_COMMAND_TOPIC = "away_mode_command_topic" CONF_AWAY_MODE_STATE_TEMPLATE = "away_mode_state_template" CONF_AWAY_MODE_STATE_TOPIC = "away_mode_state_topic" @@ -90,6 +91,7 @@ CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" CONF_FAN_MODE_LIST = "fan_modes" CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template" CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 CONF_HOLD_COMMAND_TEMPLATE = "hold_command_template" CONF_HOLD_COMMAND_TOPIC = "hold_command_topic" CONF_HOLD_STATE_TEMPLATE = "hold_state_template" @@ -104,7 +106,12 @@ CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_STATE_TEMPLATE = "power_state_template" CONF_POWER_STATE_TOPIC = "power_state_topic" CONF_PRECISION = "precision" -# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.4 +CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" +CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" +CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" +CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" +CONF_PRESET_MODES_LIST = "preset_modes" +# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 CONF_SEND_IF_OFF = "send_if_off" CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" @@ -155,13 +162,16 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( VALUE_TEMPLATE_KEYS = ( CONF_AUX_STATE_TEMPLATE, + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 CONF_AWAY_MODE_STATE_TEMPLATE, CONF_CURRENT_TEMP_TEMPLATE, CONF_FAN_MODE_STATE_TEMPLATE, + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 CONF_HOLD_STATE_TEMPLATE, CONF_MODE_STATE_TEMPLATE, CONF_POWER_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, + CONF_PRESET_MODE_VALUE_TEMPLATE, CONF_SWING_MODE_STATE_TEMPLATE, CONF_TEMP_HIGH_STATE_TEMPLATE, CONF_TEMP_LOW_STATE_TEMPLATE, @@ -170,29 +180,48 @@ VALUE_TEMPLATE_KEYS = ( COMMAND_TEMPLATE_KEYS = { CONF_FAN_MODE_COMMAND_TEMPLATE, + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 CONF_HOLD_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TEMPLATE, + CONF_PRESET_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_HIGH_COMMAND_TEMPLATE, CONF_TEMP_LOW_COMMAND_TEMPLATE, } +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 +DEPRECATED_INVALID = [ + CONF_AWAY_MODE_COMMAND_TOPIC, + CONF_AWAY_MODE_STATE_TEMPLATE, + CONF_AWAY_MODE_STATE_TOPIC, + CONF_HOLD_COMMAND_TEMPLATE, + CONF_HOLD_COMMAND_TOPIC, + CONF_HOLD_STATE_TEMPLATE, + CONF_HOLD_STATE_TOPIC, + CONF_HOLD_LIST, +] + + TOPIC_KEYS = ( + CONF_ACTION_TOPIC, CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC, + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 CONF_AWAY_MODE_COMMAND_TOPIC, CONF_AWAY_MODE_STATE_TOPIC, CONF_CURRENT_TEMP_TOPIC, CONF_FAN_MODE_COMMAND_TOPIC, CONF_FAN_MODE_STATE_TOPIC, + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 CONF_HOLD_COMMAND_TOPIC, CONF_HOLD_STATE_TOPIC, CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC, - CONF_ACTION_TOPIC, + CONF_PRESET_MODE_COMMAND_TOPIC, + CONF_PRESET_MODE_STATE_TOPIC, CONF_SWING_MODE_COMMAND_TOPIC, CONF_SWING_MODE_STATE_TOPIC, CONF_TEMP_COMMAND_TOPIC, @@ -203,12 +232,27 @@ TOPIC_KEYS = ( CONF_TEMP_STATE_TOPIC, ) + +def valid_preset_mode_configuration(config): + """Validate that the preset mode reset payload is not one of the preset modes.""" + if PRESET_NONE in config.get(CONF_PRESET_MODES_LIST): + raise ValueError("preset_modes must not include preset mode 'none'") + if config.get(CONF_PRESET_MODE_COMMAND_TOPIC): + for config_parameter in DEPRECATED_INVALID: + if config.get(config_parameter): + raise vol.MultipleInvalid( + "preset_modes cannot be used with deprecated away or hold mode config options" + ) + return config + + SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend( { vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template, vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic, + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_AWAY_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, @@ -222,6 +266,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend( ): cv.ensure_list, vol.Optional(CONF_FAN_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 vol.Optional(CONF_HOLD_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template, @@ -252,10 +297,20 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] ), vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, - # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.4 + # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean, vol.Optional(CONF_ACTION_TEMPLATE): cv.template, vol.Optional(CONF_ACTION_TOPIC): mqtt.valid_subscribe_topic, + # CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together + vol.Inclusive( + CONF_PRESET_MODE_COMMAND_TOPIC, "preset_modes" + ): mqtt.valid_publish_topic, + vol.Inclusive( + CONF_PRESET_MODES_LIST, "preset_modes", default=[] + ): cv.ensure_list, + vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional( @@ -285,17 +340,37 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA = vol.All( - # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.4 - cv.deprecated(CONF_SEND_IF_OFF), _PLATFORM_SCHEMA_BASE, + # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 + cv.deprecated(CONF_SEND_IF_OFF), + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 + cv.deprecated(CONF_AWAY_MODE_COMMAND_TOPIC), + cv.deprecated(CONF_AWAY_MODE_STATE_TEMPLATE), + cv.deprecated(CONF_AWAY_MODE_STATE_TOPIC), + cv.deprecated(CONF_HOLD_COMMAND_TEMPLATE), + cv.deprecated(CONF_HOLD_COMMAND_TOPIC), + cv.deprecated(CONF_HOLD_STATE_TEMPLATE), + cv.deprecated(CONF_HOLD_STATE_TOPIC), + cv.deprecated(CONF_HOLD_LIST), + valid_preset_mode_configuration, ) _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) DISCOVERY_SCHEMA = vol.All( - # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.4 - cv.deprecated(CONF_SEND_IF_OFF), _DISCOVERY_SCHEMA_BASE, + # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 + cv.deprecated(CONF_SEND_IF_OFF), + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 + cv.deprecated(CONF_AWAY_MODE_COMMAND_TOPIC), + cv.deprecated(CONF_AWAY_MODE_STATE_TEMPLATE), + cv.deprecated(CONF_AWAY_MODE_STATE_TOPIC), + cv.deprecated(CONF_HOLD_COMMAND_TEMPLATE), + cv.deprecated(CONF_HOLD_COMMAND_TOPIC), + cv.deprecated(CONF_HOLD_STATE_TEMPLATE), + cv.deprecated(CONF_HOLD_STATE_TOPIC), + cv.deprecated(CONF_HOLD_LIST), + valid_preset_mode_configuration, ) @@ -346,12 +421,15 @@ class MqttClimate(MqttEntity, ClimateEntity): self._current_swing_mode = None self._current_temp = None self._hold = None + self._preset_mode = None self._target_temp = None self._target_temp_high = None self._target_temp_low = None self._topic = None self._value_templates = None self._command_templates = None + self._feature_preset_mode = False + self._optimistic_preset_mode = None MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @@ -384,7 +462,14 @@ class MqttClimate(MqttEntity, ClimateEntity): self._current_swing_mode = HVAC_MODE_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None: self._current_operation = HVAC_MODE_OFF + self._feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config + if self._feature_preset_mode: + self._preset_modes = config[CONF_PRESET_MODES_LIST] + else: + self._preset_modes = [] + self._optimistic_preset_mode = CONF_PRESET_MODE_STATE_TOPIC not in config self._action = None + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 self._away = False self._hold = None self._aux = False @@ -582,6 +667,7 @@ class MqttClimate(MqttEntity, ClimateEntity): self.async_write_ha_state() + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 @callback @log_messages(self.hass, self.entity_id) def handle_away_mode_received(msg): @@ -598,6 +684,7 @@ class MqttClimate(MqttEntity, ClimateEntity): add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 @callback @log_messages(self.hass, self.entity_id) def handle_hold_mode_received(msg): @@ -608,10 +695,38 @@ class MqttClimate(MqttEntity, ClimateEntity): payload = None self._hold = payload + self._preset_mode = None self.async_write_ha_state() add_subscription(topics, CONF_HOLD_STATE_TOPIC, handle_hold_mode_received) + @callback + @log_messages(self.hass, self.entity_id) + def handle_preset_mode_received(msg): + """Handle receiving preset mode via MQTT.""" + preset_mode = render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) + if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: + self._preset_mode = None + self.async_write_ha_state() + return + if not preset_mode: + _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) + return + if preset_mode not in self._preset_modes: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid preset mode", + msg.payload, + msg.topic, + preset_mode, + ) + else: + self._preset_mode = preset_mode + self.async_write_ha_state() + + add_subscription( + topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received + ) + self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) @@ -668,8 +783,11 @@ class MqttClimate(MqttEntity, ClimateEntity): return self._config[CONF_TEMP_STEP] @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return preset mode.""" + if self._feature_preset_mode and self._preset_mode is not None: + return self._preset_mode + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 if self._hold: return self._hold if self._away: @@ -677,10 +795,12 @@ class MqttClimate(MqttEntity, ClimateEntity): return PRESET_NONE @property - def preset_modes(self): + def preset_modes(self) -> list: """Return preset modes.""" presets = [] + presets.extend(self._preset_modes) + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 if (self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None) or ( self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None ): @@ -726,7 +846,7 @@ class MqttClimate(MqttEntity, ClimateEntity): # optimistic mode setattr(self, attr, temp) - # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.4 + # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 if ( self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF @@ -769,7 +889,7 @@ class MqttClimate(MqttEntity, ClimateEntity): async def async_set_swing_mode(self, swing_mode): """Set new swing mode.""" - # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.4 + # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF: payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE]( swing_mode @@ -782,7 +902,7 @@ class MqttClimate(MqttEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" - # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.4 + # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF: payload = self._command_templates[CONF_FAN_MODE_COMMAND_TEMPLATE](fan_mode) await self._publish(CONF_FAN_MODE_COMMAND_TOPIC, payload) @@ -817,11 +937,29 @@ class MqttClimate(MqttEntity, ClimateEntity): """List of available swing modes.""" return self._config[CONF_SWING_MODE_LIST] - async def async_set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set a preset mode.""" - # Track if we should optimistic update the state + if self._feature_preset_mode: + if preset_mode not in self.preset_modes and preset_mode is not PRESET_NONE: + _LOGGER.warning("'%s' is not a valid preset mode", preset_mode) + return + mqtt_payload = self._command_templates[CONF_PRESET_MODE_COMMAND_TEMPLATE]( + preset_mode + ) + await self._publish( + CONF_PRESET_MODE_COMMAND_TOPIC, + mqtt_payload, + ) + + if self._optimistic_preset_mode: + self._preset_mode = preset_mode if preset_mode != PRESET_NONE else None + self.async_write_ha_state() + + return + + # Update hold or away mode: Track if we should optimistic update the state optimistic_update = await self._set_away_mode(preset_mode == PRESET_AWAY) - hold_mode = preset_mode + hold_mode: str | None = preset_mode if preset_mode in [PRESET_NONE, PRESET_AWAY]: hold_mode = None optimistic_update = await self._set_hold_mode(hold_mode) or optimistic_update @@ -829,6 +967,7 @@ class MqttClimate(MqttEntity, ClimateEntity): if optimistic_update: self.async_write_ha_state() + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def _set_away_mode(self, state): """Set away mode. @@ -909,8 +1048,10 @@ class MqttClimate(MqttEntity, ClimateEntity): ): support |= SUPPORT_SWING_MODE + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 if ( - (self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None) + self._feature_preset_mode + or (self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None) or (self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None) or (self._topic[CONF_HOLD_STATE_TOPIC] is not None) or (self._topic[CONF_HOLD_COMMAND_TOPIC] is not None) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 624823e0ebb..c3501267e12 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -82,9 +82,34 @@ DEFAULT_CONFIG = { "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", + "preset_mode_command_topic": "preset-mode-topic", + "preset_modes": [ + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "activity", + ], + } +} + +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 +DEFAULT_LEGACY_CONFIG = { + CLIMATE_DOMAIN: { + "platform": "mqtt", + "name": "test", + "mode_command_topic": "mode-topic", + "temperature_command_topic": "temperature-topic", + "temperature_low_command_topic": "temperature-low-topic", + "temperature_high_command_topic": "temperature-high-topic", + "fan_mode_command_topic": "fan-mode-topic", + "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", "away_mode_command_topic": "away-mode-topic", "hold_command_topic": "hold-topic", - "aux_command_topic": "aux-topic", } } @@ -103,6 +128,42 @@ async def test_setup_params(hass, mqtt_mock): assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP +async def test_preset_none_in_preset_modes(hass, mqtt_mock, caplog): + """Test the preset mode payload reset configuration.""" + config = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) + config["preset_modes"].append("none") + assert await async_setup_component(hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: config}) + await hass.async_block_till_done() + assert "Invalid config for [climate.mqtt]: not a valid value" in caplog.text + state = hass.states.get(ENTITY_CLIMATE) + assert state is None + + +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 +@pytest.mark.parametrize( + "parameter,config_value", + [ + ("away_mode_command_topic", "away-mode-command-topic"), + ("away_mode_state_topic", "away-mode-state-topic"), + ("away_mode_state_template", "{{ value_json }}"), + ("hold_mode_command_topic", "hold-mode-command-topic"), + ("hold_mode_command_template", "hold-mode-command-template"), + ("hold_mode_state_topic", "hold-mode-state-topic"), + ("hold_mode_state_template", "{{ value_json }}"), + ], +) +async def test_preset_modes_deprecation_guard( + hass, mqtt_mock, caplog, parameter, config_value +): + """Test the configuration for invalid legacy parameters.""" + config = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) + config[parameter] = config_value + assert await async_setup_component(hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: config}) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_CLIMATE) + assert state is None + + async def test_supported_features(hass, mqtt_mock): """Test the supported_features.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) @@ -469,9 +530,99 @@ async def test_handle_action_received(hass, mqtt_mock): assert hvac_action == action +async def test_set_preset_mode_optimistic(hass, mqtt_mock, caplog): + """Test setting of the preset mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + assert await async_setup_component(hass, CLIMATE_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "none" + + await common.async_set_preset_mode(hass, "away", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-topic", "away", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "away" + + await common.async_set_preset_mode(hass, "eco", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-topic", "eco", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "eco" + + await common.async_set_preset_mode(hass, "none", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-topic", "none", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "none" + + await common.async_set_preset_mode(hass, "comfort", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-topic", "comfort", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "comfort" + + await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) + assert "'invalid' is not a valid preset mode" in caplog.text + + +async def test_set_preset_mode_pessimistic(hass, mqtt_mock, caplog): + """Test setting of the preset mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["climate"]["preset_mode_state_topic"] = "preset-mode-state" + assert await async_setup_component(hass, CLIMATE_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "none" + + async_fire_mqtt_message(hass, "preset-mode-state", "away") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "away" + + async_fire_mqtt_message(hass, "preset-mode-state", "eco") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "eco" + + async_fire_mqtt_message(hass, "preset-mode-state", "none") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "none" + + async_fire_mqtt_message(hass, "preset-mode-state", "comfort") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "comfort" + + async_fire_mqtt_message(hass, "preset-mode-state", "None") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "none" + + async_fire_mqtt_message(hass, "preset-mode-state", "home") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "home" + + async_fire_mqtt_message(hass, "preset-mode-state", "nonsense") + assert ( + "'nonsense' received on topic preset-mode-state. 'nonsense' is not a valid preset mode" + in caplog.text + ) + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "home" + + +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def test_set_away_mode_pessimistic(hass, mqtt_mock): """Test setting of the away mode.""" - config = copy.deepcopy(DEFAULT_CONFIG) + config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["away_mode_state_topic"] = "away-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() @@ -496,9 +647,10 @@ async def test_set_away_mode_pessimistic(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "none" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def test_set_away_mode(hass, mqtt_mock): """Test setting of the away mode.""" - config = copy.deepcopy(DEFAULT_CONFIG) + config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["payload_on"] = "AN" config["climate"]["payload_off"] = "AUS" @@ -537,9 +689,10 @@ async def test_set_away_mode(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "away" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def test_set_hold_pessimistic(hass, mqtt_mock): """Test setting the hold mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG) + config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["hold_state_topic"] = "hold-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() @@ -560,9 +713,10 @@ async def test_set_hold_pessimistic(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "none" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def test_set_hold(hass, mqtt_mock): """Test setting the hold mode.""" - assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) + assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_LEGACY_CONFIG) await hass.async_block_till_done() state = hass.states.get(ENTITY_CLIMATE) @@ -591,9 +745,10 @@ async def test_set_hold(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "none" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def test_set_preset_away(hass, mqtt_mock): """Test setting the hold mode and away mode.""" - assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) + assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_LEGACY_CONFIG) await hass.async_block_till_done() state = hass.states.get(ENTITY_CLIMATE) @@ -624,9 +779,10 @@ async def test_set_preset_away(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "hold-on-again" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def test_set_preset_away_pessimistic(hass, mqtt_mock): """Test setting the hold mode and away mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG) + config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["hold_state_topic"] = "hold-state" config["climate"]["away_mode_state_topic"] = "away-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) @@ -674,9 +830,10 @@ async def test_set_preset_away_pessimistic(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "hold-on-again" +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 async def test_set_preset_mode_twice(hass, mqtt_mock): """Test setting of the same mode twice only publishes once.""" - assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) + assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_LEGACY_CONFIG) await hass.async_block_till_done() state = hass.states.get(ENTITY_CLIMATE) @@ -804,21 +961,19 @@ async def test_get_with_templates(hass, mqtt_mock, caplog): # By default, just unquote the JSON-strings config["climate"]["value_template"] = "{{ value_json }}" config["climate"]["action_template"] = "{{ value_json }}" - # Something more complicated for hold mode - config["climate"]["hold_state_template"] = "{{ value_json.attribute }}" # Rendering to a bool for aux heat config["climate"]["aux_state_template"] = "{{ value == 'switchmeon' }}" + # Rendering preset_mode + config["climate"]["preset_mode_value_template"] = "{{ value_json.attribute }}" config["climate"]["action_topic"] = "action" config["climate"]["mode_state_topic"] = "mode-state" config["climate"]["fan_mode_state_topic"] = "fan-state" config["climate"]["swing_mode_state_topic"] = "swing-state" config["climate"]["temperature_state_topic"] = "temperature-state" - config["climate"]["away_mode_state_topic"] = "away-state" - config["climate"]["hold_state_topic"] = "hold-state" config["climate"]["aux_state_topic"] = "aux-state" config["climate"]["current_temperature_topic"] = "current-temperature" - + config["climate"]["preset_mode_state_topic"] = "current-preset-mode" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() @@ -854,31 +1009,18 @@ async def test_get_with_templates(hass, mqtt_mock, caplog): # ... but the actual value stays unchanged. assert state.attributes.get("temperature") == 1031 - # Away Mode + # Preset Mode assert state.attributes.get("preset_mode") == "none" - async_fire_mqtt_message(hass, "away-state", '"ON"') - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("preset_mode") == "away" - - # Away Mode with JSON values - async_fire_mqtt_message(hass, "away-state", "false") + async_fire_mqtt_message(hass, "current-preset-mode", '{"attribute": "eco"}') state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("preset_mode") == "none" - - async_fire_mqtt_message(hass, "away-state", "true") - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("preset_mode") == "away" - - # Hold Mode + assert state.attributes.get("preset_mode") == "eco" + # Test with an empty json async_fire_mqtt_message( - hass, - "hold-state", - """ - { "attribute": "somemode" } - """, + hass, "current-preset-mode", '{"other_attribute": "some_value"}' ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("preset_mode") == "somemode" + assert "Ignoring empty preset_mode from 'current-preset-mode'" + assert state.attributes.get("preset_mode") == "eco" # Aux mode assert state.attributes.get("aux_heat") == "off" @@ -911,12 +1053,60 @@ async def test_get_with_templates(hass, mqtt_mock, caplog): ) -async def test_set_with_templates(hass, mqtt_mock, caplog): +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 +async def test_get_with_hold_and_away_mode_and_templates(hass, mqtt_mock, caplog): + """Test getting various for hold and away mode attributes with templates.""" + config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) + config["climate"]["mode_state_topic"] = "mode-state" + # By default, just unquote the JSON-strings + config["climate"]["value_template"] = "{{ value_json }}" + # Something more complicated for hold mode + config["climate"]["hold_state_template"] = "{{ value_json.attribute }}" + config["climate"]["away_mode_state_topic"] = "away-state" + config["climate"]["hold_state_topic"] = "hold-state" + + assert await async_setup_component(hass, CLIMATE_DOMAIN, config) + await hass.async_block_till_done() + + # Operation Mode + state = hass.states.get(ENTITY_CLIMATE) + async_fire_mqtt_message(hass, "mode-state", '"cool"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + + # Away Mode + assert state.attributes.get("preset_mode") == "none" + async_fire_mqtt_message(hass, "away-state", '"ON"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "away" + + # Away Mode with JSON values + async_fire_mqtt_message(hass, "away-state", "false") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "none" + + async_fire_mqtt_message(hass, "away-state", "true") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "away" + + # Hold Mode + async_fire_mqtt_message( + hass, + "hold-state", + """ + { "attribute": "somemode" } + """, + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "somemode" + + +async def test_set_and_templates(hass, mqtt_mock, caplog): """Test setting various attributes with templates.""" config = copy.deepcopy(DEFAULT_CONFIG) # Create simple templates config["climate"]["fan_mode_command_template"] = "fan_mode: {{ value }}" - config["climate"]["hold_command_template"] = "hold: {{ value }}" + config["climate"]["preset_mode_command_template"] = "preset_mode: {{ value }}" config["climate"]["mode_command_template"] = "mode: {{ value }}" config["climate"]["swing_mode_command_template"] = "swing_mode: {{ value }}" config["climate"]["temperature_command_template"] = "temp: {{ value }}" @@ -935,11 +1125,12 @@ async def test_set_with_templates(hass, mqtt_mock, caplog): state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "high" - # Hold Mode + # Preset Mode await common.async_set_preset_mode(hass, PRESET_ECO, ENTITY_CLIMATE) - mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) - mqtt_mock.async_publish.assert_any_call("hold-topic", "hold: eco", 0, False) + mqtt_mock.async_publish.call_count == 1 + mqtt_mock.async_publish.assert_any_call( + "preset-mode-topic", "preset_mode: eco", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == PRESET_ECO @@ -987,6 +1178,26 @@ async def test_set_with_templates(hass, mqtt_mock, caplog): assert state.attributes.get("target_temp_high") == 23 +# AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 +async def test_set_with_away_and_hold_modes_and_templates(hass, mqtt_mock, caplog): + """Test setting various attributes on hold and away mode with templates.""" + config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) + # Create simple templates + config["climate"]["hold_command_template"] = "hold: {{ value }}" + + assert await async_setup_component(hass, CLIMATE_DOMAIN, config) + await hass.async_block_till_done() + + # Hold Mode + await common.async_set_preset_mode(hass, PRESET_ECO, ENTITY_CLIMATE) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold: eco", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == PRESET_ECO + + async def test_min_temp_custom(hass, mqtt_mock): """Test a custom min temp.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -1118,9 +1329,11 @@ async def test_unique_id(hass, mqtt_mock): ("action_topic", "heating", ATTR_HVAC_ACTION, "heating"), ("action_topic", "cooling", ATTR_HVAC_ACTION, "cooling"), ("aux_state_topic", "ON", ATTR_AUX_HEAT, "on"), + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 ("away_mode_state_topic", "ON", ATTR_PRESET_MODE, "away"), ("current_temperature_topic", "22.1", ATTR_CURRENT_TEMPERATURE, 22.1), ("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"), + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 ("hold_state_topic", "mode1", ATTR_PRESET_MODE, "mode1"), ("mode_state_topic", "cool", None, None), ("mode_state_topic", "fan_only", None, None), @@ -1135,7 +1348,11 @@ async def test_encoding_subscribable_topics( ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) - config["hold_modes"] = ["mode1", "mode2"] + # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 + if topic in ["hold_state_topic", "away_mode_state_topic"]: + config["hold_modes"] = ["mode1", "mode2"] + del config["preset_modes"] + del config["preset_mode_command_topic"] await help_test_encoding_subscribable_topics( hass, mqtt_mock, @@ -1317,6 +1534,13 @@ async def test_precision_whole(hass, mqtt_mock): "cool", "mode_command_template", ), + ( + climate.SERVICE_SET_PRESET_MODE, + "preset_mode_command_topic", + {"preset_mode": "sleep"}, + "sleep", + "preset_mode_command_template", + ), ( climate.SERVICE_SET_PRESET_MODE, "away_mode_command_topic", @@ -1334,8 +1558,8 @@ async def test_precision_whole(hass, mqtt_mock): ( climate.SERVICE_SET_PRESET_MODE, "hold_command_topic", - {"preset_mode": "some_hold_mode"}, - "some_hold_mode", + {"preset_mode": "comfort"}, + "comfort", "hold_command_template", ), ( @@ -1402,7 +1626,10 @@ async def test_publishing_with_custom_encoding( ): """Test publishing MQTT payload with different encoding.""" domain = climate.DOMAIN - config = DEFAULT_CONFIG[domain] + config = copy.deepcopy(DEFAULT_CONFIG[domain]) + if topic != "preset_mode_command_topic": + del config["preset_mode_command_topic"] + del config["preset_modes"] await help_test_publishing_with_custom_encoding( hass, -- GitLab