diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index f29a114620a..f45d2852df0 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -64,6 +64,8 @@ from .const import ( CONF_MODE_LIST, CONF_MODE_STATE_TEMPLATE, CONF_MODE_STATE_TOPIC, + CONF_POWER_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, CONF_QOS, CONF_RETAIN, @@ -113,9 +115,6 @@ CONF_HUMIDITY_MIN = "min_humidity" # was removed in HA Core 2023.8 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" diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index fb1989069af..fcdfeb4bd7d 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -40,6 +40,8 @@ CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_POWER_COMMAND_TOPIC = "power_command_topic" +CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 17e9430dba3..08b9d36d850 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -51,6 +51,8 @@ from .const import ( CONF_MODE_LIST, CONF_MODE_STATE_TEMPLATE, CONF_MODE_STATE_TOPIC, + CONF_POWER_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, CONF_RETAIN, CONF_TEMP_COMMAND_TEMPLATE, @@ -91,6 +93,7 @@ VALUE_TEMPLATE_KEYS = ( COMMAND_TEMPLATE_KEYS = { CONF_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TEMPLATE, } @@ -98,6 +101,7 @@ TOPIC_KEYS = ( CONF_CURRENT_TEMP_TOPIC, CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, + CONF_POWER_COMMAND_TOPIC, CONF_TEMP_COMMAND_TOPIC, CONF_TEMP_STATE_TOPIC, ) @@ -127,6 +131,8 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, 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_PRECISION): vol.In( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] ), @@ -266,6 +272,9 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): ): support |= WaterHeaterEntityFeature.OPERATION_MODE + if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: + support |= WaterHeaterEntityFeature.ON_OFF + self._attr_supported_features = support def _prepare_subscribe_topics(self) -> None: @@ -317,3 +326,19 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): if self._optimistic or self._topic[CONF_MODE_STATE_TOPIC] is None: self._attr_current_operation = operation_mode self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> 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) + + async def async_turn_off(self, **kwargs: Any) -> 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) diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index c4f798e05ec..245af5c6918 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -257,6 +257,91 @@ async def test_set_operation_optimistic( assert state.state == "performance" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"power_command_topic": "power-command"},), + ) + ], +) +async def test_set_operation_with_power_command( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting of new operation mode with power command enabled.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + await common.async_set_operation_mode(hass, "electric", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "electric" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "electric", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_set_operation_mode(hass, "off", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + 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_WATER_HEATER) + # the water heater is not updated optimistically as this is not supported + 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_WATER_HEATER) + 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( + water_heater.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_WATER_HEATER) + assert state.state == "off" + await common.async_set_operation_mode(hass, "electric", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "electric" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "electric", 0, False)]) + mqtt_mock.async_publish.reset_mock() + await common.async_set_operation_mode(hass, "off", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + + await common.async_turn_on(hass, ENTITY_WATER_HEATER) + # the water heater is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_WATER_HEATER) + 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_operation_mode(hass, "gas", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "gas" + await common.async_turn_off(hass, ENTITY_WATER_HEATER) + # the water heater is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "gas" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "OFF", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -509,9 +594,11 @@ async def test_get_with_templates( "name": "test", "mode_command_topic": "mode-topic", "temperature_command_topic": "temperature-topic", + "power_command_topic": "power-topic", # Create simple templates "mode_command_template": "mode: {{ value }}", "temperature_command_template": "temp: {{ value }}", + "power_command_template": "pwr: {{ value }}", } } } @@ -544,6 +631,14 @@ async def test_set_and_templates( state = hass.states.get(ENTITY_WATER_HEATER) assert state.attributes.get("temperature") == 107 + # Power + await common.async_turn_on(hass, entity_id=ENTITY_WATER_HEATER) + mqtt_mock.async_publish.assert_called_once_with("power-topic", "pwr: ON", 0, False) + mqtt_mock.async_publish.reset_mock() + await common.async_turn_off(hass, entity_id=ENTITY_WATER_HEATER) + mqtt_mock.async_publish.assert_called_once_with("power-topic", "pwr: OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + @pytest.mark.parametrize( "hass_config", @@ -1047,6 +1142,20 @@ async def test_precision_whole( 20.1, "temperature_command_template", ), + ( + water_heater.SERVICE_TURN_ON, + "power_command_topic", + {}, + "ON", + "power_command_template", + ), + ( + water_heater.SERVICE_TURN_OFF, + "power_command_topic", + {}, + "OFF", + "power_command_template", + ), ], ) async def test_publishing_with_custom_encoding(