diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 5e44f9409b8..257e0fe95ae 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -159,6 +159,7 @@ ABBREVIATIONS = { "pos_clsd": "position_closed", "pos_open": "position_open", "pow_cmd_t": "power_command_topic", + "pow_cmd_tpl": "power_command_template", "pow_stat_t": "power_state_topic", "pow_stat_tpl": "power_state_template", "pr_mode_cmd_t": "preset_mode_command_topic", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 98fd344a30c..095b1d958a9 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -109,13 +109,15 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" -# CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE +# CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # was already removed or never added support was deprecated with release 2023.2 # and will be removed with release 2023.8 -CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_STATE_TEMPLATE = "power_state_template" CONF_POWER_STATE_TOPIC = "power_state_topic" + +CONF_POWER_COMMAND_TOPIC = "power_command_topic" +CONF_POWER_COMMAND_TEMPLATE = "power_command_template" 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" @@ -183,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = { CONF_FAN_MODE_COMMAND_TEMPLATE, CONF_HUMIDITY_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, @@ -300,6 +303,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_POWER_STATE_TEMPLATE): cv.template, vol.Optional(CONF_POWER_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRECISION): vol.In( @@ -348,11 +352,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE + # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # was already removed or never added support was deprecated with release 2023.2 # and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_COMMAND_TOPIC), cv.deprecated(CONF_POWER_STATE_TEMPLATE), cv.deprecated(CONF_POWER_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, @@ -365,10 +368,9 @@ _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA DISCOVERY_SCHEMA = vol.All( _DISCOVERY_SCHEMA_BASE, - # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, + # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added # support was deprecated with release 2023.2 and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_COMMAND_TOPIC), cv.deprecated(CONF_POWER_STATE_TEMPLATE), cv.deprecated(CONF_POWER_STATE_TOPIC), valid_preset_mode_configuration, @@ -962,13 +964,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - if hvac_mode == HVACMode.OFF: - await self._publish( - CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_OFF] - ) - else: - await self._publish(CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_ON]) - payload = self._command_templates[CONF_MODE_COMMAND_TEMPLATE](hvac_mode) await self._publish(CONF_MODE_COMMAND_TOPIC, payload) @@ -1013,3 +1008,28 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" await self._set_aux_heat(False) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_ON] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) + return + # Fall back to default behavior without power command topic + await super().async_turn_on() + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_OFF] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) + if self._optimistic: + self._attr_hvac_mode = HVACMode.OFF + self.async_write_ha_state() + return + # Fall back to default behavior without power command topic + await super().async_turn_off() diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index de55bf71bed..4a6d1bf64d4 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -355,9 +355,6 @@ async def test_set_operation_optimistic( assert state.state == "heat" -# CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, -# support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added -# support was deprecated with release 2023.2 and will be removed with release 2023.8 @pytest.mark.parametrize( "hass_config", [ @@ -377,17 +374,134 @@ async def test_set_operation_with_power_command( await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.state == "cool" - mqtt_mock.async_publish.assert_has_calls( - [call("power-command", "ON", 0, False), call("mode-topic", "cool", 0, False)] - ) + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "cool", 0, False)]) mqtt_mock.async_publish.reset_mock() await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" - mqtt_mock.async_publish.assert_has_calls( - [call("power-command", "OFF", 0, False), call("mode-topic", "off", 0, False)] - ) + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "off", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_on(hass, ENTITY_CLIMATE) + # the hvac_mode is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "ON", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_off(hass, ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_has_calls([call("power-command", "OFF", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"power_command_topic": "power-command", "optimistic": True},), + ) + ], +) +async def test_turn_on_and_off_optimistic_with_power_command( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting of turn on/off with power command enabled.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "cool", 0, False)]) + mqtt_mock.async_publish.reset_mock() + await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + await common.async_turn_on(hass, ENTITY_CLIMATE) + # the hvac_mode is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "ON", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + await common.async_turn_off(hass, ENTITY_CLIMATE) + # the hvac_mode is updated optimistically + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "OFF", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + ("hass_config", "climate_on", "climate_off"), + [ + ( + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["heat", "cool"]},) + ), + "heat", + None, + ), + ( + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "dry"]},) + ), + None, + "off", + ), + ( + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "cool"]},) + ), + "cool", + "off", + ), + ], +) +async def test_turn_on_and_off_without_power_command( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + climate_on: str | None, + climate_off: str | None, +) -> None: + """Test setting of turn on/off with power command enabled.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + await common.async_turn_on(hass, ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert climate_on is None or state.state == climate_on + if climate_on: + mqtt_mock.async_publish.assert_has_calls( + [call("mode-topic", climate_on, 0, False)] + ) + else: + mqtt_mock.async_publish.assert_has_calls([]) + + await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_off(hass, ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert climate_off is None or state.state == climate_off + if climate_off: + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "off", 0, False)]) + else: + assert state.state == "cool" + mqtt_mock.async_publish.assert_has_calls([]) mqtt_mock.async_publish.reset_mock() @@ -1480,6 +1594,7 @@ async def test_get_with_templates( climate.DOMAIN: { "name": "test", "mode_command_topic": "mode-topic", + "power_command_topic": "power-topic", "target_humidity_command_topic": "humidity-topic", "temperature_command_topic": "temperature-topic", "temperature_low_command_topic": "temperature-low-topic", @@ -1499,6 +1614,7 @@ async def test_get_with_templates( ], # Create simple templates "fan_mode_command_template": "fan_mode: {{ value }}", + "power_command_template": "power: {{ value }}", "preset_mode_command_template": "preset_mode: {{ value }}", "mode_command_template": "mode: {{ value }}", "swing_mode_command_template": "swing_mode: {{ value }}", @@ -1540,13 +1656,38 @@ async def test_set_and_templates( # Mode await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with( - "mode-topic", "mode: cool", 0, False - ) + mqtt_mock.async_publish.assert_any_call("mode-topic", "mode: cool", 0, False) + assert mqtt_mock.async_publish.call_count == 1 mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "cool" + await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_any_call("mode-topic", "mode: off", 0, False) + assert mqtt_mock.async_publish.call_count == 1 + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + # Power + await common.async_turn_on(hass, ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_any_call("power-topic", "power: ON", 0, False) + # Only power command is sent + # the mode is not updated when power_command_topic is set + assert mqtt_mock.async_publish.call_count == 1 + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + + await common.async_turn_off(hass, ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_any_call("power-topic", "power: OFF", 0, False) + # Only power command is sent + # the mode is not updated when power_command_topic is set + assert mqtt_mock.async_publish.call_count == 1 + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "off" + # Swing Mode await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) mqtt_mock.async_publish.assert_called_once_with( @@ -2141,10 +2282,17 @@ async def test_precision_whole( ( climate.SERVICE_TURN_ON, "power_command_topic", - None, + {}, "ON", None, ), + ( + climate.SERVICE_TURN_OFF, + "power_command_topic", + {}, + "OFF", + None, + ), ( climate.SERVICE_SET_HVAC_MODE, "mode_command_topic",