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