From 6a0e0358f8f4cdde7ca36f50f50906cfeff82da0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 21:01:57 -1000 Subject: [PATCH] Share the entity unit lookup and reject unknown temperature units --- esphome/components/climate/__init__.py | 23 ++++--------------- .../components/template/climate/__init__.py | 3 ++- .../template/water_heater/__init__.py | 3 ++- esphome/components/water_heater/__init__.py | 20 +++------------- esphome/config_validation.py | 2 ++ esphome/core/entity_helpers.py | 16 ++++++++++++- .../climate/test_visual_temperatures.py | 6 ++--- 7 files changed, 32 insertions(+), 41 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index c195c86bd12..0bfd65f4d0b 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -49,16 +49,16 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VISUAL, CONF_WEB_SERVER, - UNIT_CELSIUS, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + entity_temperature_unit, + entity_temperature_unit_of_id, queue_entity_register, setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.cpp_types import TEMPERATURE_UNITS from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -158,30 +158,17 @@ def visual_temperature_step(value: Any) -> ConfigType: return {CONF_TARGET_TEMPERATURE: value, CONF_CURRENT_TEMPERATURE: value} -def temperature_unit_of(config: ConfigType) -> str: - """The unit a climate reports in: ``unit_of_measurement`` when the platform took the option - through ``climate_schema``, else Celsius (anova has an unrelated option of that name).""" - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - return unit if unit in TEMPERATURE_UNITS else UNIT_CELSIUS - - -def temperature_unit_of_id(climate_id: ID) -> str: - """The unit of the climate with this id, for actions that take static temperatures.""" - path = CORE.config.get_path_for_id(climate_id)[:-1] - return temperature_unit_of(CORE.config.get_config_for_path(path)) - - def temperature_in_entity_unit( config: ConfigType, value: tuple[float, str | None] ) -> str: """Render a static action temperature in the unit of the climate the action targets.""" - unit = temperature_unit_of_id(config[CONF_ID]) + unit = entity_temperature_unit_of_id(config[CONF_ID]) return str(cg.safe_exp(cv.convert_temperature(*value, unit))) def _visual_temperatures_in_entity_unit(config: ConfigType) -> ConfigType: """Convert the visual temperatures into the entity's unit.""" - unit = temperature_unit_of(config) + unit = entity_temperature_unit(config) visual = config[CONF_VISUAL] for key in (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE): if isinstance(value := visual.get(key), tuple): diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 873ade96128..7300bd7aa42 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_LOW, CONF_UNIT_OF_MEASUREMENT, ) +from esphome.core.entity_helpers import entity_temperature_unit from esphome.cpp_types import TEMPERATURE_UNITS from esphome.types import ConfigType @@ -200,7 +201,7 @@ def _validate_initial_state(config: ConfigType) -> ConfigType: f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled", path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY], ) - unit = climate.temperature_unit_of(config) + unit = entity_temperature_unit(config) for key in TEMPERATURE_KEYS: if isinstance(value := initial_state.get(key), tuple): initial_state[key] = cv.convert_temperature(*value, unit) diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 923248e8c31..71b3e54a7d9 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core import ID +from esphome.core.entity_helpers import entity_temperature_unit_of_id from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.cpp_types import TEMPERATURE_UNITS from esphome.types import ConfigType @@ -154,7 +155,7 @@ async def water_heater_template_publish_to_code( await cg.register_parented(var, config[CONF_ID]) # A static temperature is in the unit of the water heater it is published to - unit = water_heater.temperature_unit_of_id(config[CONF_ID]) + unit = entity_temperature_unit_of_id(config[CONF_ID]) for key, setter in ( (CONF_CURRENT_TEMPERATURE, var.set_current_temperature), (CONF_TARGET_TEMPERATURE, var.set_target_temperature), diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index 028648d7bcf..03cedc02f1d 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -8,16 +8,15 @@ from esphome.const import ( CONF_MIN_TEMPERATURE, CONF_UNIT_OF_MEASUREMENT, CONF_VISUAL, - UNIT_CELSIUS, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + entity_temperature_unit, queue_entity_register, setup_entity, ) from esphome.cpp_generator import MockObjClass -from esphome.cpp_types import TEMPERATURE_UNITS from esphome.types import ConfigType CODEOWNERS = ["@dhoeben"] @@ -45,22 +44,9 @@ WATER_HEATER_MODES = { validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True) -def temperature_unit_of(config: ConfigType) -> str: - """The unit a water heater reports in: ``unit_of_measurement`` when the platform took the - option through ``water_heater_schema``, else Celsius.""" - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - return unit if unit in TEMPERATURE_UNITS else UNIT_CELSIUS - - -def temperature_unit_of_id(water_heater_id: ID) -> str: - """The unit of the water heater with this id, for actions that take static temperatures.""" - path = CORE.config.get_path_for_id(water_heater_id)[:-1] - return temperature_unit_of(CORE.config.get_config_for_path(path)) - - def _visual_temperatures_in_entity_unit(config: ConfigType) -> ConfigType: """Convert the visual temperatures into the entity's unit.""" - unit = temperature_unit_of(config) + unit = entity_temperature_unit(config) visual = config[CONF_VISUAL] for key in (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE): if isinstance(value := visual.get(key), tuple): diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c1a0e8a6faa..8e791bc062d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1472,6 +1472,8 @@ def _temperature_to_celsius(value: float, unit: str, delta: bool) -> float: return value * (5 / 9) if delta else (value - 32) * (5 / 9) if unit == UNIT_KELVIN: return value if delta else value - 273.15 + if unit != UNIT_CELSIUS: + raise ValueError(f"Unknown temperature unit {unit!r}") return value diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 4f446050253..409d3229ce5 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_INTERNAL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, + UNIT_CELSIUS, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import ( @@ -24,7 +25,7 @@ from esphome.core.config import ( UNIT_OF_MEASUREMENT_MAX_LENGTH, ) from esphome.cpp_generator import MockObj, RawStatement, add, get_variable -from esphome.cpp_types import App +from esphome.cpp_types import TEMPERATURE_UNITS, App import esphome.final_validate as fv from esphome.helpers import ( cpp_string_escape, @@ -550,6 +551,19 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non return inherit_property +def entity_temperature_unit(config: ConfigType) -> str: + """The unit a climate or water heater reports in: ``unit_of_measurement`` when the platform took + the option through its schema factory, else Celsius (anova has an unrelated option of that name).""" + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + return unit if unit in TEMPERATURE_UNITS else UNIT_CELSIUS + + +def entity_temperature_unit_of_id(entity_id: ID) -> str: + """The unit of the entity with this id, for actions that take static temperatures.""" + path = CORE.config.get_path_for_id(entity_id)[:-1] + return entity_temperature_unit(CORE.config.get_config_for_path(path)) + + def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]: """Create a validator function to check for duplicate entity names. diff --git a/tests/component_tests/climate/test_visual_temperatures.py b/tests/component_tests/climate/test_visual_temperatures.py index 4b566912f42..8db73dc8a7f 100644 --- a/tests/component_tests/climate/test_visual_temperatures.py +++ b/tests/component_tests/climate/test_visual_temperatures.py @@ -4,7 +4,6 @@ import pytest from esphome.components.climate import ( _visual_temperatures_in_entity_unit, - temperature_unit_of, visual_temperature_step, ) from esphome.const import ( @@ -18,6 +17,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_FAHRENHEIT, ) +from esphome.core.entity_helpers import entity_temperature_unit def test_visual_temperature_step_scalar_applies_to_both() -> None: @@ -67,9 +67,9 @@ def test_visual_temperatures_follow_unit_of_measurement() -> None: def test_platform_with_its_own_unit_option_keeps_celsius() -> None: # anova validates unit_of_measurement as c|f for the device, not for the entity - assert temperature_unit_of({CONF_UNIT_OF_MEASUREMENT: "f"}) == UNIT_CELSIUS + assert entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: "f"}) == UNIT_CELSIUS assert ( - temperature_unit_of({CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT}) + entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT}) == UNIT_FAHRENHEIT )