diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 7ac378e1c5b..aaabec66146 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -44,6 +44,7 @@ from aioesphomeapi import ( UpdateInfo, UserService, ValveInfo, + WaterHeaterInfo, build_unique_id, ) from aioesphomeapi.model import ButtonInfo @@ -96,6 +97,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { TimeInfo: Platform.TIME, UpdateInfo: Platform.UPDATE, ValveInfo: Platform.VALVE, + WaterHeaterInfo: Platform.WATER_HEATER, } diff --git a/homeassistant/components/esphome/water_heater.py b/homeassistant/components/esphome/water_heater.py new file mode 100644 index 00000000000..f294f38b24c --- /dev/null +++ b/homeassistant/components/esphome/water_heater.py @@ -0,0 +1,110 @@ +"""Support for ESPHome water heaters.""" + +from __future__ import annotations + +from functools import partial +from typing import Any + +from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState + +from homeassistant.components.water_heater import ( + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature +from homeassistant.core import callback + +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_float_state_property, + esphome_state_property, + platform_async_setup_entry, +) +from .enum_mapper import EsphomeEnumMapper + +PARALLEL_UPDATES = 0 + + +_WATER_HEATER_MODES: EsphomeEnumMapper[WaterHeaterMode, str] = EsphomeEnumMapper( + { + WaterHeaterMode.OFF: "off", + WaterHeaterMode.ECO: "eco", + WaterHeaterMode.ELECTRIC: "electric", + WaterHeaterMode.PERFORMANCE: "performance", + WaterHeaterMode.HIGH_DEMAND: "high_demand", + WaterHeaterMode.HEAT_PUMP: "heat_pump", + WaterHeaterMode.GAS: "gas", + } +) + + +class EsphomeWaterHeater( + EsphomeEntity[WaterHeaterInfo, WaterHeaterState], WaterHeaterEntity +): + """A water heater implementation for ESPHome.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_precision = PRECISION_TENTHS + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_min_temp = static_info.min_temperature + self._attr_max_temp = static_info.max_temperature + features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + if static_info.supported_modes: + features |= WaterHeaterEntityFeature.OPERATION_MODE + self._attr_operation_list = [ + _WATER_HEATER_MODES.from_esphome(mode) + for mode in static_info.supported_modes + ] + else: + self._attr_operation_list = None + self._attr_supported_features = features + + @property + @esphome_float_state_property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._state.current_temperature + + @property + @esphome_float_state_property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self._state.target_temperature + + @property + @esphome_state_property + def current_operation(self) -> str | None: + """Return current operation mode.""" + return _WATER_HEATER_MODES.from_esphome(self._state.mode) + + @convert_api_error_ha_error + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + self._client.water_heater_command( + key=self._key, + target_temperature=kwargs[ATTR_TEMPERATURE], + device_id=self._static_info.device_id, + ) + + @convert_api_error_ha_error + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + self._client.water_heater_command( + key=self._key, + mode=_WATER_HEATER_MODES.from_hass(operation_mode), + device_id=self._static_info.device_id, + ) + + +async_setup_entry = partial( + platform_async_setup_entry, + info_type=WaterHeaterInfo, + entity_type=EsphomeWaterHeater, + state_type=WaterHeaterState, +) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 9fe709322af..58425912425 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -560,9 +560,10 @@ async def _mock_generic_device_entry( async def mock_try_connect(self): """Set an event when ReconnectLogic._try_connect has been awaited.""" - result = await super()._try_connect() - try_connect_done.set() - return result + try: + return await super()._try_connect() + finally: + try_connect_done.set() def stop_callback(self) -> None: """Stop the reconnect logic.""" diff --git a/tests/components/esphome/test_water_heater.py b/tests/components/esphome/test_water_heater.py new file mode 100644 index 00000000000..090e0f37817 --- /dev/null +++ b/tests/components/esphome/test_water_heater.py @@ -0,0 +1,185 @@ +"""Test ESPHome water heaters.""" + +from unittest.mock import call + +from aioesphomeapi import APIClient, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState + +from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant + +from .conftest import MockGenericDeviceEntryType + + +async def test_water_heater_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic water heater entity.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=10.0, + max_temperature=85.0, + supported_modes=[ + WaterHeaterMode.ECO, + WaterHeaterMode.GAS, + ], + ) + ] + states = [ + WaterHeaterState( + key=1, + mode=WaterHeaterMode.ECO, + current_temperature=45.0, + target_temperature=50.0, + ) + ] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + state = hass.states.get("water_heater.test_my_boiler") + assert state is not None + assert state.state == "eco" + assert state.attributes["current_temperature"] == 45.0 + assert state.attributes["temperature"] == 50.0 + assert state.attributes["min_temp"] == 10.0 + assert state.attributes["max_temp"] == 85.0 + assert state.attributes["operation_list"] == ["eco", "gas"] + + +async def test_water_heater_entity_no_modes( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a water heater entity without operation modes.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=10.0, + max_temperature=85.0, + ) + ] + states = [ + WaterHeaterState( + key=1, + current_temperature=45.0, + target_temperature=50.0, + ) + ] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + state = hass.states.get("water_heater.test_my_boiler") + assert state is not None + assert state.attributes["min_temp"] == 10.0 + assert state.attributes["max_temp"] == 85.0 + assert state.attributes.get(ATTR_OPERATION_LIST) is None + + +async def test_water_heater_set_temperature( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test setting the target temperature.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=10.0, + max_temperature=85.0, + ) + ] + states = [ + WaterHeaterState( + key=1, + mode=WaterHeaterMode.ECO, + target_temperature=45.0, + ) + ] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.test_my_boiler", + ATTR_TEMPERATURE: 55, + }, + blocking=True, + ) + + mock_client.water_heater_command.assert_has_calls( + [call(key=1, target_temperature=55.0, device_id=0)] + ) + + +async def test_water_heater_set_operation_mode( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test setting the operation mode.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + supported_modes=[ + WaterHeaterMode.ECO, + WaterHeaterMode.GAS, + ], + ) + ] + states = [ + WaterHeaterState( + key=1, + mode=WaterHeaterMode.ECO, + ) + ] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.test_my_boiler", + "operation_mode": "gas", + }, + blocking=True, + ) + + mock_client.water_heater_command.assert_has_calls( + [call(key=1, mode=WaterHeaterMode.GAS, device_id=0)] + )