Add unit_of_measurement to climate and water_heater in ESPHome integration (#168747)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
This commit is contained in:
Johan Henkens
2026-09-07 22:18:11 +02:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston Simon Lamon
parent 7f8f9517b8
commit e85b8a256e
7 changed files with 294 additions and 7 deletions
+2 -2
View File
@@ -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
+8 -1
View File
@@ -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,
}
+24 -2
View File
@@ -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)
@@ -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
+124
View File
@@ -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
)
+7
View File
@@ -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)
@@ -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
)