diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index d7ab709469c..ba70836cac9 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -52,9 +52,24 @@ from homeassistant.util.unit_conversion import TemperatureConverter from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, CONF_ENCODING, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, + CONF_PRECISION, CONF_QOS, CONF_RETAIN, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) @@ -86,8 +101,6 @@ CONF_AWAY_MODE_STATE_TOPIC = "away_mode_state_topic" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" -CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" -CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" CONF_FAN_MODE_LIST = "fan_modes" @@ -107,11 +120,6 @@ CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" -CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" -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, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE @@ -120,7 +128,6 @@ CONF_MODE_STATE_TOPIC = "mode_state_topic" 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_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" @@ -133,8 +140,6 @@ CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" -CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" -CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" @@ -143,11 +148,6 @@ CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template" CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic" CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template" CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic" -CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" -CONF_TEMP_STATE_TOPIC = "temperature_state_topic" -CONF_TEMP_INITIAL = "initial" -CONF_TEMP_MAX = "max_temp" -CONF_TEMP_MIN = "min_temp" CONF_TEMP_STEP = "temp_step" DEFAULT_INITIAL_TEMPERATURE = 21.0 @@ -475,7 +475,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): ) -> None: """Add a subscription.""" qos: int = self._config[CONF_QOS] - if self._topic[topic] is not None: + if topic in self._topic and self._topic[topic] is not None: topics[topic] = { "topic": self._topic[topic], "msg_callback": msg_callback, diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 469f52e1488..fdc32a601e0 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -35,6 +35,7 @@ from . import ( text as text_platform, update as update_platform, vacuum as vacuum_platform, + water_heater as water_heater_platform, ) from .const import ( CONF_BIRTH_MESSAGE, @@ -132,6 +133,10 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [vacuum_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.WATER_HEATER.value: vol.All( + cv.ensure_list, + [water_heater_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] + ), } ) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c91c54a79a4..a8d7812965c 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -29,6 +29,22 @@ CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" +CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" +CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" +CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" +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_PRECISION = "precision" +CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" +CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" +CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" +CONF_TEMP_STATE_TOPIC = "temperature_state_topic" +CONF_TEMP_INITIAL = "initial" +CONF_TEMP_MAX = "max_temp" +CONF_TEMP_MIN = "min_temp" + CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" @@ -106,6 +122,7 @@ PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] RELOADABLE_PLATFORMS = [ @@ -129,4 +146,5 @@ RELOADABLE_PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0c0032ec8eb..0411a1f679c 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -66,6 +66,7 @@ SUPPORTED_COMPONENTS = [ "text", "update", "vacuum", + "water_heater", ] MQTT_DISCOVERY_UPDATED = "mqtt_discovery_updated_{}" diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py new file mode 100644 index 00000000000..0f622d55b84 --- /dev/null +++ b/homeassistant/components/mqtt/water_heater.py @@ -0,0 +1,318 @@ +"""Support for MQTT water heater devices.""" +from __future__ import annotations + +import functools +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import water_heater +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + DEFAULT_MIN_TEMP, + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + CONF_OPTIMISTIC, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_TEMPERATURE_UNIT, + CONF_VALUE_TEMPLATE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + STATE_OFF, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.unit_conversion import TemperatureConverter + +from .climate import MqttTemperatureControlEntity +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA +from .const import ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, + CONF_PRECISION, + CONF_RETAIN, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + DEFAULT_OPTIMISTIC, +) +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, async_setup_entry_helper +from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Water Heater" + +MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED = frozenset( + { + water_heater.ATTR_CURRENT_TEMPERATURE, + water_heater.ATTR_MAX_TEMP, + water_heater.ATTR_MIN_TEMP, + water_heater.ATTR_TEMPERATURE, + water_heater.ATTR_OPERATION_LIST, + water_heater.ATTR_OPERATION_MODE, + } +) + +VALUE_TEMPLATE_KEYS = ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_MODE_STATE_TEMPLATE, + CONF_TEMP_STATE_TEMPLATE, +) + +COMMAND_TEMPLATE_KEYS = { + CONF_MODE_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TEMPLATE, +} + + +TOPIC_KEYS = ( + CONF_CURRENT_TEMP_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_STATE_TOPIC, +) + + +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_TEMP_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional( + CONF_MODE_LIST, + default=[ + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + STATE_OFF, + ], + ): cv.ensure_list, + vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + 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_PRECISION): vol.In( + [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + ), + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_TEMP_INITIAL): cv.positive_int, + vol.Optional(CONF_TEMP_MIN): vol.Coerce(float), + vol.Optional(CONF_TEMP_MAX): vol.Coerce(float), + vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_TEMP_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_TEMP_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMP_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All( + _PLATFORM_SCHEMA_BASE, +) + +_DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) + +DISCOVERY_SCHEMA = vol.All( + _DISCOVERY_SCHEMA_BASE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT water heater device through YAML and through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, water_heater.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up the MQTT water heater devices.""" + async_add_entities([MqttWaterHeater(hass, config, config_entry, discovery_data)]) + + +class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): + """Representation of an MQTT water heater device.""" + + _entity_id_format = water_heater.ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the water heater device.""" + MqttTemperatureControlEntity.__init__( + self, hass, config, config_entry, discovery_data + ) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._attr_operation_list = config[CONF_MODE_LIST] + self._attr_temperature_unit = config.get( + CONF_TEMPERATURE_UNIT, self.hass.config.units.temperature_unit + ) + if (min_temp := config.get(CONF_TEMP_MIN)) is not None: + self._attr_min_temp = min_temp + if (max_temp := config.get(CONF_TEMP_MAX)) is not None: + self._attr_max_temp = max_temp + if (precision := config.get(CONF_PRECISION)) is not None: + self._attr_precision = precision + + self._topic = {key: config.get(key) for key in TOPIC_KEYS} + + self._optimistic = config[CONF_OPTIMISTIC] + + # Set init temp, if it is missing convert the default to the temperature units + init_temp: float = config.get( + CONF_TEMP_INITIAL, + TemperatureConverter.convert( + DEFAULT_MIN_TEMP, + UnitOfTemperature.FAHRENHEIT, + self.temperature_unit, + ), + ) + if self._topic[CONF_TEMP_STATE_TOPIC] is None or self._optimistic: + self._attr_target_temperature = init_temp + if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: + self._attr_current_operation = STATE_OFF + + value_templates: dict[str, Template | None] = {} + for key in VALUE_TEMPLATE_KEYS: + value_templates[key] = None + if CONF_VALUE_TEMPLATE in config: + value_templates = { + key: config.get(CONF_VALUE_TEMPLATE) for key in VALUE_TEMPLATE_KEYS + } + for key in VALUE_TEMPLATE_KEYS & config.keys(): + value_templates[key] = config[key] + self._value_templates = { + key: MqttValueTemplate( + template, + entity=self, + ).async_render_with_possible_json_value + for key, template in value_templates.items() + } + + self._command_templates = {} + for key in COMMAND_TEMPLATE_KEYS: + self._command_templates[key] = MqttCommandTemplate( + config.get(key), entity=self + ).async_render + + support = WaterHeaterEntityFeature(0) + if (self._topic[CONF_TEMP_STATE_TOPIC] is not None) or ( + self._topic[CONF_TEMP_COMMAND_TOPIC] is not None + ): + support |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + + if (self._topic[CONF_MODE_STATE_TOPIC] is not None) or ( + self._topic[CONF_MODE_COMMAND_TOPIC] is not None + ): + support |= WaterHeaterEntityFeature.OPERATION_MODE + + self._attr_supported_features = support + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics: dict[str, dict[str, Any]] = {} + + @callback + def handle_mode_received( + msg: ReceiveMessage, template_name: str, attr: str, mode_list: str + ) -> None: + """Handle receiving listed mode via MQTT.""" + payload = self.render_template(msg, template_name) + + if payload not in self._config[mode_list]: + _LOGGER.error("Invalid %s mode: %s", mode_list, payload) + else: + setattr(self, attr, payload) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_current_mode_received(msg: ReceiveMessage) -> None: + """Handle receiving operation mode via MQTT.""" + handle_mode_received( + msg, + CONF_MODE_STATE_TEMPLATE, + "_attr_current_operation", + CONF_MODE_LIST, + ) + + self.add_subscription( + topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + ) + + self.prepare_subscribe_topics(topics) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + operation_mode: str | None + if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None: + await self.async_set_operation_mode(operation_mode) + await super().async_set_temperature(**kwargs) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + payload = self._command_templates[CONF_MODE_COMMAND_TEMPLATE](operation_mode) + await self._publish(CONF_MODE_COMMAND_TOPIC, payload) + + if self._optimistic or self._topic[CONF_MODE_STATE_TOPIC] is None: + self._attr_current_operation = operation_mode + self.async_write_ha_state() diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 452a9b862ff..ce27a479308 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1333,6 +1333,9 @@ async def test_get_target_temperature_low_high_with_templates( # By default, just unquote the JSON-strings "value_template": "{{ value_json }}", "action_template": "{{ value_json }}", + "current_humidity_template": "{{ value_json }}", + "current_temperature_template": "{{ value_json }}", + "temperature_state_template": "{{ value_json }}", # Rendering to a bool for aux heat "aux_state_template": "{{ value == 'switchmeon' }}", # Rendering preset_mode diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py new file mode 100644 index 00000000000..942a2ec87d4 --- /dev/null +++ b/tests/components/mqtt/test_water_heater.py @@ -0,0 +1,1109 @@ +"""The tests for the mqtt water heater component.""" +import copy +import json +from typing import Any +from unittest.mock import call, patch + +import pytest +import voluptuous as vol + +from homeassistant.components import mqtt, water_heater +from homeassistant.components.mqtt.water_heater import ( + MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED, +) +from homeassistant.components.water_heater import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_conversion import TemperatureConverter + +from .test_common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.components.water_heater import common +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +ENTITY_WATER_HEATER = "water_heater.test" + + +_DEFAULT_MIN_TEMP_CELSIUS = round( + TemperatureConverter.convert( + DEFAULT_MIN_TEMP, + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.CELSIUS, + ), + 1, +) +_DEFAULT_MAX_TEMP_CELSIUS = round( + TemperatureConverter.convert( + DEFAULT_MAX_TEMP, + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.CELSIUS, + ), + 1, +) + + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "temperature_command_topic": "temperature-topic", + } + } +} + + +@pytest.fixture(autouse=True) +def water_heater_platform_only(): + """Only setup the water heater platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.WATER_HEATER]): + yield + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_params( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the initial parameters.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.state == "off" + # default water heater min/max temp in celsius + assert state.attributes.get("min_temp") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.attributes.get("max_temp") == _DEFAULT_MAX_TEMP_CELSIUS + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_supported_features( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the supported_features.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + support = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + assert state.attributes.get("supported_features") == support + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_get_operation_modes( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that the operation list returns the correct modes.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert [ + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + STATE_OFF, + ] == state.attributes.get("operation_list") + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_set_operation_mode_bad_attr_and_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting operation mode without required attribute.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + with pytest.raises(vol.Invalid) as excinfo: + await common.async_set_operation_mode(hass, None, ENTITY_WATER_HEATER) + assert "string value is None for dictionary value @ data['operation_mode']" in str( + excinfo.value + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_set_operation( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting of new operation mode.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + await common.async_set_operation_mode(hass, "eco", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + mqtt_mock.async_publish.assert_called_once_with("mode-topic", "eco", 0, False) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"mode_state_topic": "mode-state"},), + ) + ], +) +async def test_set_operation_pessimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting operation mode in pessimistic mode.""" + await hass.async_block_till_done() + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "unknown" + + await common.async_set_operation_mode(hass, "eco", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "unknown" + + async_fire_mqtt_message(hass, "mode-state", "eco") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + + async_fire_mqtt_message(hass, "mode-state", "bogus mode") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "mode_state_topic": "mode-state", + "optimistic": True, + }, + ), + ) + ], +) +async def test_set_operation_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting operation mode in optimistic mode.""" + 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" + + async_fire_mqtt_message(hass, "mode-state", "performance") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "performance" + + async_fire_mqtt_message(hass, "mode-state", "bogus mode") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "performance" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_set_target_temperature( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting the target temperature.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + await common.async_set_operation_mode(hass, "performance", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "performance" + mqtt_mock.async_publish.assert_called_once_with( + "mode-topic", "performance", 0, False + ) + mqtt_mock.async_publish.reset_mock() + await common.async_set_temperature( + hass, temperature=50, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 50 + mqtt_mock.async_publish.assert_called_once_with( + "temperature-topic", "50.0", 0, False + ) + + # also test directly supplying the operation mode to set_temperature + mqtt_mock.async_publish.reset_mock() + await common.async_set_temperature( + hass, temperature=47, operation_mode="eco", entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + assert state.attributes.get("temperature") == 47 + mqtt_mock.async_publish.assert_has_calls( + [ + call("mode-topic", "eco", 0, False), + call("temperature-topic", "47.0", 0, False), + ] + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state"},), + ) + ], +) +async def test_set_target_temperature_pessimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting the target temperature.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") is None + await common.async_set_operation_mode(hass, "performance", ENTITY_WATER_HEATER) + await common.async_set_temperature( + hass, temperature=60, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") is None + + async_fire_mqtt_message(hass, "temperature-state", "1701") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 1701 + + async_fire_mqtt_message(hass, "temperature-state", "not a number") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 1701 + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state", "optimistic": True},), + ) + ], +) +async def test_set_target_temperature_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting the target temperature optimistic.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + await common.async_set_operation_mode(hass, "performance", ENTITY_WATER_HEATER) + await common.async_set_temperature( + hass, temperature=55, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 55 + + async_fire_mqtt_message(hass, "temperature-state", "49") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 49 + + async_fire_mqtt_message(hass, "temperature-state", "not a number") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 49 + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"current_temperature_topic": "current_temperature"},), + ) + ], +) +async def test_receive_mqtt_temperature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test getting the current temperature via MQTT.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "current_temperature", "53") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == 53 + + async_fire_mqtt_message(hass, "current_temperature", "") + state = hass.states.get(ENTITY_WATER_HEATER) + assert ( + "Invalid empty payload for attribute _attr_current_temperature, ignoring update" + in caplog.text + ) + assert state.attributes.get("current_temperature") == 53 + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, water_heater.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "temperature_command_topic": "temperature-topic", + # By default, just unquote the JSON-strings + "value_template": "{{ value_json }}", + "mode_state_template": "{{ value_json.attribute }}", + "temperature_state_template": "{{ value_json }}", + "current_temperature_template": "{{ value_json}}", + "mode_state_topic": "mode-state", + "temperature_state_topic": "temperature-state", + "current_temperature_topic": "current-temperature", + } + } + } + ], +) +async def test_get_with_templates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test getting various attributes with templates.""" + await mqtt_mock_entry() + + # Operation Mode + state = hass.states.get(ENTITY_WATER_HEATER) + async_fire_mqtt_message(hass, "mode-state", '{"attribute": "eco"}') + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + + # Temperature - with valid value + assert state.attributes.get("temperature") is None + async_fire_mqtt_message(hass, "temperature-state", '"1031"') + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 1031 + + # Temperature - with invalid value + async_fire_mqtt_message(hass, "temperature-state", '"-INVALID-"') + state = hass.states.get(ENTITY_WATER_HEATER) + # make sure, the invalid value gets logged... + assert "Could not parse temperature_state_template from -INVALID-" in caplog.text + # ... but the actual value stays unchanged. + assert state.attributes.get("temperature") == 1031 + + # Temperature - with JSON null value + async_fire_mqtt_message(hass, "temperature-state", "null") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") is None + + # Current temperature + async_fire_mqtt_message(hass, "current-temperature", '"74656"') + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == 74656 + # Test resetting the current temperature using a JSON null value + async_fire_mqtt_message(hass, "current-temperature", "null") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") is None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "temperature_command_topic": "temperature-topic", + # Create simple templates + "mode_command_template": "mode: {{ value }}", + "temperature_command_template": "temp: {{ value }}", + } + } + } + ], +) +async def test_set_and_templates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setting various attributes with templates.""" + mqtt_mock = await mqtt_mock_entry() + + # Mode + await common.async_set_operation_mode(hass, "heat_pump", ENTITY_WATER_HEATER) + mqtt_mock.async_publish.assert_called_once_with( + "mode-topic", "mode: heat_pump", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "heat_pump" + + # Temperature + await common.async_set_temperature( + hass, temperature=107, entity_id=ENTITY_WATER_HEATER + ) + mqtt_mock.async_publish.assert_called_once_with( + "temperature-topic", "temp: 107.0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 107 + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"min_temp": 70},))], +) +async def test_min_temp_custom( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a custom min temp.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + min_temp = state.attributes.get("min_temp") + + assert isinstance(min_temp, float) + assert state.attributes.get("min_temp") == 70 + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"max_temp": 220},))], +) +async def test_max_temp_custom( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a custom max temp.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + max_temp = state.attributes.get("max_temp") + + assert isinstance(max_temp, float) + assert max_temp == 220 + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ) + ], +) +async def test_temperature_unit( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting temperature unit converts temperature values.""" + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.attributes.get("min_temp") == _DEFAULT_MIN_TEMP_CELSIUS + assert state.attributes.get("max_temp") == _DEFAULT_MAX_TEMP_CELSIUS + + async_fire_mqtt_message(hass, "current_temperature", "127") + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == 52.8 + + +@pytest.mark.parametrize( + ("hass_config", "temperature_unit", "initial", "min_temp", "max_temp", "current"), + [ + ( + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.CELSIUS, + _DEFAULT_MIN_TEMP_CELSIUS, + _DEFAULT_MIN_TEMP_CELSIUS, + _DEFAULT_MAX_TEMP_CELSIUS, + 48.9, + ), + ( + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.KELVIN, + 316, + 316, + 333, + 322, + ), + ( + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.FAHRENHEIT, + DEFAULT_MIN_TEMP, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + 120, + ), + ], +) +async def test_alt_temperature_unit( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + temperature_unit: UnitOfTemperature, + initial: float, + min_temp: float, + max_temp: float, + current: float, +) -> None: + """Test deriving the systems temperature unit.""" + with patch.object(hass.config.units, "temperature_unit", temperature_unit): + await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min_temp + assert state.attributes.get("max_temp") == max_temp + + async_fire_mqtt_message(hass, "current_temperature", "120") + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == current + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + DEFAULT_CONFIG, + MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + water_heater.DOMAIN: [ + { + "name": "Test 1", + "mode_state_topic": "test_topic1/state", + "mode_command_topic": "test_topic1/command", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "mode_state_topic": "test_topic2/state", + "mode_command_topic": "test_topic2/command", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one water heater per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, water_heater.DOMAIN) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("current_temperature_topic", "22.1", ATTR_CURRENT_TEMPERATURE, 22.1), + ("mode_state_topic", "eco", ATTR_OPERATION_MODE, None), + ("temperature_state_topic", "19.9", ATTR_TEMPERATURE, 19.9), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][water_heater.DOMAIN]) + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + ) + + +async def test_discovery_removal_water_heater( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered water heater.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][water_heater.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry, caplog, water_heater.DOMAIN, data + ) + + +async def test_discovery_update_water_heater( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered water heater.""" + config1 = {"name": "Beer"} + config2 = {"name": "Milk"} + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, water_heater.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_water_heater( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered water heater.""" + data1 = '{ "name": "Beer" }' + with patch( + "homeassistant.components.mqtt.water_heater.MqttWaterHeater.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + water_heater.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer", "mode_command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "mode_command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, water_heater.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT water heater device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT water heater device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + config = { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_state_topic": "test-topic", + "availability_topic": "avty-topic", + } + } + } + await help_test_entity_id_update_subscriptions( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + config, + ["test-topic", "avty-topic"], + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, water_heater.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + config = { + mqtt.DOMAIN: { + water_heater.DOMAIN: { + "name": "test", + "mode_command_topic": "command-topic", + "mode_state_topic": "test-topic", + } + } + } + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + water_heater.DOMAIN, + config, + water_heater.SERVICE_SET_OPERATION_MODE, + command_topic="command-topic", + command_payload="eco", + state_topic="test-topic", + service_parameters={"operation_mode": "eco"}, + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_precision_default( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting precision to tenths works as intended.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_set_temperature( + hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 23.7 + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"precision": 0.5},))], +) +async def test_precision_halves( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting precision to halves works as intended.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_set_temperature( + hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 23.5 + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(water_heater.DOMAIN, DEFAULT_CONFIG, ({"precision": 1.0},))], +) +async def test_precision_whole( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that setting precision to whole works as intended.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_set_temperature( + hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER + ) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 24.0 + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + water_heater.SERVICE_SET_OPERATION_MODE, + "mode_command_topic", + {"operation_mode": "electric"}, + "electric", + "mode_command_template", + ), + ( + water_heater.SERVICE_SET_TEMPERATURE, + "temperature_command_topic", + {"temperature": "20.1"}, + 20.1, + "temperature_command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = water_heater.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG) + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = water_heater.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = water_heater.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = water_heater.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + )