From e85b8a256e3b8e402eb862333cdf5738477ea8c3 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Mon, 7 Sep 2026 13:18:11 -0700 Subject: [PATCH] Add unit_of_measurement to climate and water_heater in ESPHome integration (#168747) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/esphome/climate.py | 4 +- homeassistant/components/esphome/const.py | 9 +- homeassistant/components/esphome/entity.py | 26 +++- .../components/esphome/water_heater.py | 5 +- tests/components/esphome/test_climate.py | 124 +++++++++++++++++ tests/components/esphome/test_entity.py | 7 + tests/components/esphome/test_water_heater.py | 126 ++++++++++++++++++ 7 files changed, 294 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 738ddad59eb6..74cea9151059 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -51,7 +51,6 @@ from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, - UnitOfTemperature, ) from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError @@ -62,6 +61,7 @@ from .entity import ( convert_api_error_ha_error, esphome_float_state_property, esphome_state_property, + get_temperature_unit, platform_async_setup_entry, ) from .enum_mapper import EsphomeEnumMapper @@ -131,7 +131,6 @@ _PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper( class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity): """A climate implementation for ESPHome.""" - _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "climate" _feature_flags = ClimateFeature(0) @@ -144,6 +143,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti self._feature_flags = ClimateFeature( static_info.supported_feature_flags_compat(self._api_version) ) + self._attr_temperature_unit = get_temperature_unit(static_info) self._attr_precision = self._get_precision() self._attr_hvac_modes = [ _CLIMATE_MODES.from_esphome(mode) for mode in static_info.supported_modes diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 508065b091c8..c419ee9a9e62 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -2,10 +2,11 @@ from typing import TYPE_CHECKING, Final +from aioesphomeapi import TemperatureUnit from awesomeversion import AwesomeVersion from homeassistant.components.bluetooth import BluetoothScanningMode -from homeassistant.const import __version__ as ha_version +from homeassistant.const import UnitOfTemperature, __version__ as ha_version from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -43,3 +44,9 @@ NO_WAKE_WORD: Final[str] = "no_wake_word" WAKE_WORDS_DIR_NAME = "custom_wake_words" WAKE_WORDS_API_PATH = "/api/esphome/wake_words" + +TEMPERATURE_UNIT_MAP: dict[TemperatureUnit, UnitOfTemperature] = { + TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS, + TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + TemperatureUnit.KELVIN: UnitOfTemperature.KELVIN, +} diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index b1c0c92c6e98..3392556d9b16 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -8,15 +8,17 @@ from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast, over from aioesphomeapi import ( APIConnectionError, + ClimateInfo, DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, + WaterHeaterInfo, build_device_unique_id, ) import voluptuous as vol -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -29,7 +31,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, TEMPERATURE_UNIT_MAP # Import config flow so that it's added to the registry from .entry_data import ( @@ -42,6 +44,26 @@ from .enum_mapper import EsphomeEnumMapper _LOGGER = logging.getLogger(__name__) + +def get_temperature_unit( + static_info: ClimateInfo | WaterHeaterInfo, +) -> UnitOfTemperature: + """Return the HA temperature unit for the given ESPHome static info.""" + temperature_unit = static_info.temperature_unit + if ( + temperature_unit is not None + and (ha_unit := TEMPERATURE_UNIT_MAP.get(temperature_unit)) is not None + ): + return ha_unit + _LOGGER.warning( + "%s (device_id=%s): Unrecognized ESPHome temperature unit %r, defaulting to Celsius", + static_info.name, + static_info.device_id, + temperature_unit, + ) + return UnitOfTemperature.CELSIUS + + _InfoT = TypeVar("_InfoT", bound=EntityInfo) _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") _StateT = TypeVar("_StateT", bound=EntityState) diff --git a/homeassistant/components/esphome/water_heater.py b/homeassistant/components/esphome/water_heater.py index 12a841405691..d46c82244f92 100644 --- a/homeassistant/components/esphome/water_heater.py +++ b/homeassistant/components/esphome/water_heater.py @@ -16,7 +16,7 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.core import callback from .entity import ( @@ -24,6 +24,7 @@ from .entity import ( convert_api_error_ha_error, esphome_float_state_property, esphome_state_property, + get_temperature_unit, platform_async_setup_entry, ) from .enum_mapper import EsphomeEnumMapper @@ -49,7 +50,6 @@ class EsphomeWaterHeater( ): """A water heater implementation for ESPHome.""" - _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_TENTHS @callback @@ -58,6 +58,7 @@ class EsphomeWaterHeater( """Set attrs from static info.""" super()._on_static_info_update(static_info) static_info = self._static_info + self._attr_temperature_unit = get_temperature_unit(static_info) self._attr_min_temp = static_info.min_temperature self._attr_max_temp = static_info.max_temperature self._attr_target_temperature_step = static_info.target_temperature_step diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 94b10a13377b..df938a04e5b1 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -13,6 +13,7 @@ from aioesphomeapi import ( ClimatePreset, ClimateState, ClimateSwingMode, + TemperatureUnit, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -24,7 +25,9 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_MAX_HUMIDITY, + ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, + ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, @@ -44,6 +47,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .conftest import MockGenericDeviceEntryType @@ -862,3 +866,123 @@ async def test_climate_entity_requires_two_point_keeps_range( assert ATTR_TEMPERATURE not in state.attributes assert state.attributes[ATTR_TARGET_TEMP_LOW] == 3.5 assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 4.0 + + +@pytest.mark.parametrize( + ("temperature_unit", "expected_temperature"), + [ + pytest.param(TemperatureUnit.CELSIUS, 22.0, id="celsius"), + pytest.param(TemperatureUnit.FAHRENHEIT, -5.6, id="fahrenheit"), + pytest.param(TemperatureUnit.KELVIN, -251.1, id="kelvin"), + pytest.param(None, 22.0, id="none_falls_back_to_celsius"), + ], +) +async def test_climate_entity_temperature_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + temperature_unit: TemperatureUnit | int | None, + expected_temperature: float, +) -> None: + """Test that the temperature unit is passed through correctly.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + temperature_unit=temperature_unit, + ) + ] + states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=22)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.attributes[ATTR_TEMPERATURE] == expected_temperature + + +async def test_climate_entity_fahrenheit_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that a Fahrenheit climate entity converts temperatures correctly.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + temperature_unit=TemperatureUnit.FAHRENHEIT, + visual_min_temperature=32.0, + visual_max_temperature=212.0, + ) + ] + states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=32.0)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + # 32 °F and 212 °F displayed in the HA system unit (°C) + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 100.0 + # 32 °F target displayed in °C + assert state.attributes[ATTR_TEMPERATURE] == 0.0 + + # set_temperature is called in °C; ESPHome must receive the °F equivalent + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 10}, + blocking=True, + ) + mock_client.climate_command.assert_called_once_with( + key=1, target_temperature=50.0, device_id=0 + ) + + +async def test_climate_entity_fahrenheit_unit_fahrenheit_system( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a Fahrenheit climate entity under a Fahrenheit HA system passes through unchanged.""" + hass.config.units = US_CUSTOMARY_SYSTEM + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + temperature_unit=TemperatureUnit.FAHRENHEIT, + visual_min_temperature=32.0, + visual_max_temperature=212.0, + ) + ] + states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=72.0)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + # No conversion — device and system are both °F + assert state.attributes[ATTR_MIN_TEMP] == 32.0 + assert state.attributes[ATTR_MAX_TEMP] == 212.0 + assert state.attributes[ATTR_TEMPERATURE] == 72.0 + + # set_temperature is called in °F; ESPHome must receive the same value + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 86}, + blocking=True, + ) + mock_client.climate_command.assert_called_once_with( + key=1, target_temperature=86.0, device_id=0 + ) diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index ef7ced513954..ab605d339a90 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -14,12 +14,14 @@ from aioesphomeapi import ( SensorInfo, SensorState, SubDeviceInfo, + TemperatureUnit, build_device_unique_id, build_unique_id, ) import pytest from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.const import TEMPERATURE_UNIT_MAP from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, @@ -3481,3 +3483,8 @@ async def test_mover_does_not_adopt_other_movers_state( assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_UNKNOWN assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_UNKNOWN + + +def test_temperature_unit_map_covers_all_units() -> None: + """Every aioesphomeapi TemperatureUnit must be mapped.""" + assert set(TEMPERATURE_UNIT_MAP) == set(TemperatureUnit) diff --git a/tests/components/esphome/test_water_heater.py b/tests/components/esphome/test_water_heater.py index 2263ce07074d..0e304432912c 100644 --- a/tests/components/esphome/test_water_heater.py +++ b/tests/components/esphome/test_water_heater.py @@ -4,6 +4,7 @@ from unittest.mock import call from aioesphomeapi import ( APIClient, + TemperatureUnit, WaterHeaterFeature, WaterHeaterInfo, WaterHeaterMode, @@ -14,6 +15,8 @@ import pytest from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, ATTR_OPERATION_LIST, DOMAIN as WATER_HEATER_DOMAIN, SERVICE_SET_AWAY_MODE, @@ -29,6 +32,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .conftest import MockGenericDeviceEntryType @@ -462,3 +466,125 @@ async def test_water_heater_set_away_mode( mock_client.water_heater_command.assert_has_calls( [call(key=1, away=away_mode, device_id=0)] ) + + +@pytest.mark.parametrize( + ("temperature_unit", "expected_temperature"), + [ + pytest.param(TemperatureUnit.CELSIUS, 50.0, id="celsius"), + pytest.param(TemperatureUnit.FAHRENHEIT, 10.0, id="fahrenheit"), + pytest.param(TemperatureUnit.KELVIN, -223.1, id="kelvin"), + pytest.param(None, 50.0, id="none_falls_back_to_celsius"), + ], +) +async def test_water_heater_temperature_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + temperature_unit: TemperatureUnit | int | None, + expected_temperature: float, +) -> None: + """Test that the temperature unit is passed through correctly.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=10.0, + max_temperature=85.0, + temperature_unit=temperature_unit, + ) + ] + states = [WaterHeaterState(key=1, 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[ATTR_TEMPERATURE] == expected_temperature + + +async def test_water_heater_fahrenheit_unit( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that a Fahrenheit water heater converts temperatures correctly.""" + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=32.0, + max_temperature=212.0, + temperature_unit=TemperatureUnit.FAHRENHEIT, + ) + ] + states = [WaterHeaterState(key=1, target_temperature=32.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 + # 32 °F and 212 °F displayed in the HA system unit (°C) + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 100.0 + # 32 °F target displayed in °C + assert state.attributes[ATTR_TEMPERATURE] == 0.0 + + # set_temperature is called in °C; ESPHome must receive the °F equivalent + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.test_my_boiler", ATTR_TEMPERATURE: 10}, + blocking=True, + ) + mock_client.water_heater_command.assert_called_once_with( + key=1, target_temperature=50.0, device_id=0 + ) + + +async def test_water_heater_fahrenheit_unit_fahrenheit_system( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a Fahrenheit water heater under a Fahrenheit HA system passes through unchanged.""" + hass.config.units = US_CUSTOMARY_SYSTEM + entity_info = [ + WaterHeaterInfo( + object_id="my_boiler", + key=1, + name="My Boiler", + min_temperature=32.0, + max_temperature=212.0, + temperature_unit=TemperatureUnit.FAHRENHEIT, + ) + ] + states = [WaterHeaterState(key=1, target_temperature=72.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 + # No conversion — device and system are both °F + assert state.attributes[ATTR_MIN_TEMP] == 32.0 + assert state.attributes[ATTR_MAX_TEMP] == 212.0 + assert state.attributes[ATTR_TEMPERATURE] == 72.0 + + # set_temperature is called in °F; ESPHome must receive the same value + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.test_my_boiler", ATTR_TEMPERATURE: 86}, + blocking=True, + ) + mock_client.water_heater_command.assert_called_once_with( + key=1, target_temperature=86.0, device_id=0 + )