Compare commits

...
22 changed files with 504 additions and 93 deletions
+3 -2
View File
@@ -338,8 +338,9 @@ class ApplyField:
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
``type_`` may be a C++ type string using ``{parent}`` when the type is only known per
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
is not the right spelling, or the value depends on another entity's config, such as the unit
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
``type_``. An absent key emits nothing.
"""
conf_key: str | tuple[str, ...]
+61 -20
View File
@@ -46,12 +46,15 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
CONF_TEMPERATURE_STEP,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
CONF_WEB_SERVER,
)
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,
)
@@ -137,13 +140,10 @@ CONF_MIN_HUMIDITY = "min_humidity"
CONF_MAX_HUMIDITY = "max_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
{
cv.Required(CONF_TARGET_TEMPERATURE): visual_temperature,
cv.Required(CONF_CURRENT_TEMPERATURE): visual_temperature,
cv.Required(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
cv.Required(CONF_CURRENT_TEMPERATURE): cv.temperature_with_unit,
}
)
@@ -154,13 +154,30 @@ def visual_temperature_step(value: Any) -> ConfigType:
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
# Otherwise, use the single value for both properties
value = visual_temperature(value)
return VISUAL_TEMPERATURE_STEP_SCHEMA(
{
CONF_TARGET_TEMPERATURE: value,
CONF_CURRENT_TEMPERATURE: value,
}
)
value = cv.temperature_with_unit(value)
return {CONF_TARGET_TEMPERATURE: value, CONF_CURRENT_TEMPERATURE: value}
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 = 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 = entity_temperature_unit(config)
visual = config[CONF_VISUAL]
for key in (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE):
if isinstance(value := visual.get(key), tuple):
visual[key] = cv.convert_temperature(*value, unit)
if (step := visual.get(CONF_TEMPERATURE_STEP)) is not None:
for key in (CONF_TARGET_TEMPERATURE, CONF_CURRENT_TEMPERATURE):
if isinstance(value := step[key], tuple):
step[key] = cv.convert_temperature(*value, unit, delta=True)
return config
_CLIMATE_SCHEMA = (
@@ -171,8 +188,8 @@ _CLIMATE_SCHEMA = (
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
{
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
@@ -249,6 +266,7 @@ _CLIMATE_SCHEMA = (
_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate"))
_CLIMATE_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
def climate_schema(
@@ -256,10 +274,18 @@ def climate_schema(
*,
entity_category: str = cv.UNDEFINED,
icon: str = cv.UNDEFINED,
temperature_unit: bool = False,
) -> cv.Schema:
"""Build the schema of a climate platform.
``temperature_unit`` adds ``unit_of_measurement`` for platforms whose temperatures are all
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``.
"""
schema = {
cv.GenerateID(): cv.declare_id(class_),
}
if temperature_unit:
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.temperature_unit
for key, default, validator in [
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
@@ -460,9 +486,13 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(Climate),
cv.Optional(CONF_MODE): cv.templatable(validate_climate_mode),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature_with_unit),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_AWAY): cv.invalid("Use preset instead"),
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
@@ -482,12 +512,23 @@ automation.register_apply_action(
"climate.control",
CLIMATE_CONTROL_ACTION_SCHEMA,
automation.ApplyField(CONF_MODE, "set_mode", ClimateMode),
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
CONF_TARGET_TEMPERATURE,
"set_target_temperature",
cg.float_,
temperature_in_entity_unit,
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
CONF_TARGET_TEMPERATURE_LOW,
"set_target_temperature_low",
cg.float_,
temperature_in_entity_unit,
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH,
"set_target_temperature_high",
cg.float_,
temperature_in_entity_unit,
),
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
+50 -12
View File
@@ -27,7 +27,10 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
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
from .. import template_ns
@@ -162,6 +165,13 @@ def _resolve_supports(config: ConfigType) -> ConfigType:
return config
TEMPERATURE_KEYS = (
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_LOW,
CONF_TARGET_TEMPERATURE_HIGH,
)
def _validate_initial_state(config: ConfigType) -> ConfigType:
# Climate keeps target_temperature and target_temperature_low in a union, so writing the wrong
# one of the pair corrupts the setpoint with no runtime complaint.
@@ -191,6 +201,10 @@ 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 = 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)
return config
@@ -200,9 +214,9 @@ INITIAL_STATE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_MODE): climate.validate_climate_mode,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
cv.Exclusive(
@@ -217,7 +231,7 @@ INITIAL_STATE_SCHEMA = cv.All(
)
CONFIG_SCHEMA = cv.All(
climate.climate_schema(TemplateClimate)
climate.climate_schema(TemplateClimate, temperature_unit=True)
.extend(
{
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
@@ -295,6 +309,8 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await climate.register_climate(var, config)
if (unit := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None:
cg.add(var.set_temperature_unit(TEMPERATURE_UNITS[unit]))
if (sens := config.get(CONF_SENSOR)) is not None:
cg.add(var.set_sensor(await cg.get_variable(sens)))
@@ -381,11 +397,19 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateClimate),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
@@ -414,15 +438,29 @@ automation.register_apply_action(
"climate.template.publish",
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
automation.ApplyField(
CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_
CONF_CURRENT_TEMPERATURE,
"current_temperature = {}",
cg.float_,
climate.temperature_in_entity_unit,
),
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
CONF_TARGET_TEMPERATURE,
"set_target_temperature",
cg.float_,
climate.temperature_in_entity_unit,
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
CONF_TARGET_TEMPERATURE_LOW,
"set_target_temperature_low",
cg.float_,
climate.temperature_in_entity_unit,
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH,
"set_target_temperature_high",
cg.float_,
climate.temperature_in_entity_unit,
),
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode),
@@ -22,6 +22,7 @@ class TemplateClimate final : public climate::Climate, public Component {
climate::ClimateTraits traits() override { return this->traits_; }
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
void set_temperature_unit(TemperatureUnit unit) { this->traits_.set_temperature_unit(unit); }
#ifdef USE_SENSOR
// The matching feature flag is added from codegen, so the configuration alone decides it.
@@ -12,9 +12,12 @@ from esphome.const import (
CONF_SET_ACTION,
CONF_SUPPORTED_MODES,
CONF_TARGET_TEMPERATURE,
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
from .. import template_ns
@@ -39,7 +42,7 @@ RESTORE_MODES = {
}
CONFIG_SCHEMA = (
water_heater.water_heater_schema(TemplateWaterHeater)
water_heater.water_heater_schema(TemplateWaterHeater, temperature_unit=True)
.extend(
{
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
@@ -65,6 +68,8 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await water_heater.register_water_heater(var, config)
if (unit := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None:
cg.add(var.set_temperature_unit(TEMPERATURE_UNITS[unit]))
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
@@ -125,8 +130,12 @@ async def to_code(config: ConfigType) -> None:
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_MODE): cv.templatable(
water_heater.validate_water_heater_mode
),
@@ -145,13 +154,17 @@ async def water_heater_template_publish_to_code(
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if current_temp := config.get(CONF_CURRENT_TEMPERATURE):
template_ = await cg.templatable(current_temp, args, cg.float_)
cg.add(var.set_current_temperature(template_))
if target_temp := config.get(CONF_TARGET_TEMPERATURE):
template_ = await cg.templatable(target_temp, args, cg.float_)
cg.add(var.set_target_temperature(template_))
# A static temperature is in the unit of the water heater it is published to
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),
):
if (value := config.get(key)) is None:
continue
if isinstance(value, tuple):
value = cv.convert_temperature(*value, unit)
cg.add(setter(await cg.templatable(value, args, cg.float_)))
if mode := config.get(CONF_MODE):
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
@@ -32,6 +32,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
}
traits.set_supports_current_temperature(true);
traits.set_temperature_unit(this->temperature_unit_);
if (this->target_temperature_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
}
@@ -29,6 +29,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; }
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
this->supported_modes_ = modes;
}
@@ -54,6 +55,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
TemplateLambda<bool> away_f_;
TemplateLambda<bool> is_on_f_;
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
water_heater::WaterHeaterModeMask supported_modes_;
bool optimistic_{true};
};
+29 -3
View File
@@ -6,11 +6,13 @@ from esphome.const import (
CONF_ID,
CONF_MAX_TEMPERATURE,
CONF_MIN_TEMPERATURE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
)
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,
)
@@ -41,19 +43,35 @@ WATER_HEATER_MODES = {
}
validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True)
def _visual_temperatures_in_entity_unit(config: ConfigType) -> ConfigType:
"""Convert the visual temperatures into the entity's unit."""
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):
visual[key] = cv.convert_temperature(*value, unit)
if isinstance(value := visual.get(CONF_TARGET_TEMPERATURE_STEP), tuple):
visual[CONF_TARGET_TEMPERATURE_STEP] = cv.convert_temperature(
*value, unit, delta=True
)
return config
_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
{
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
{
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_,
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.temperature_with_unit,
}
),
}
)
_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater"))
_WATER_HEATER_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
def water_heater_schema(
@@ -61,8 +79,16 @@ def water_heater_schema(
*,
icon: str = cv.UNDEFINED,
entity_category: str = cv.UNDEFINED,
temperature_unit: bool = False,
) -> cv.Schema:
"""Build the schema of a water heater platform.
``temperature_unit`` adds ``unit_of_measurement`` for platforms whose temperatures are all
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``.
"""
schema = {cv.GenerateID(): cv.declare_id(class_)}
if temperature_unit:
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.temperature_unit
for key, default, validator in [
(CONF_ICON, icon, cv.icon),
+64 -39
View File
@@ -77,6 +77,9 @@ from esphome.const import (
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
UNIT_KELVIN,
Framework,
Toolchain,
__version__ as ESPHOME_VERSION,
@@ -1416,7 +1419,10 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False):
if match is None:
raise Invalid(f"Expected {quantity} with unit, got {value}")
mantissa = float(match.group(1))
try:
mantissa = float(match.group(1))
except ValueError as e:
raise Invalid(f"Expected {quantity} with unit, got {value}") from e
if match.group(2) not in METRIC_SUFFIXES:
raise Invalid(f"Invalid {quantity} suffix {match.group(2)}")
@@ -1435,53 +1441,72 @@ distance = float_with_unit("distance", "(m)")
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
_temperature_k = float_with_unit("temperature", "(°K|° K|K)?")
_temperature_f = float_with_unit("temperature", "(°F|° F|F)?")
_temperature_c_strict = float_with_unit("temperature", "(°C|° C|C)")
_temperature_k = float_with_unit("temperature", "(°K|° K|K)")
_temperature_f = float_with_unit("temperature", "(°F|° F|F)")
decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True)
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)
def temperature(value):
err = None
try:
return _temperature_c(value)
except Invalid as orig_err:
err = orig_err
try:
kelvin = _temperature_k(value)
return kelvin - 273.15
except Invalid:
pass
try:
fahrenheit = _temperature_f(value)
return (fahrenheit - 32) * (5 / 9)
except Invalid:
pass
raise err
def temperature_unit(value: Any) -> str:
"""Validate the unit an entity reports its temperatures in."""
return one_of(UNIT_CELSIUS, UNIT_FAHRENHEIT, UNIT_KELVIN)(value)
def temperature_delta(value):
err = None
try:
return _temperature_c(value)
except Invalid as orig_err:
err = orig_err
def temperature_with_unit(value: Any) -> tuple[float, str | None]:
"""Parse a temperature and keep the unit written with it; the unit is None when there is none."""
for validator, unit in (
(_temperature_k, UNIT_KELVIN),
(_temperature_f, UNIT_FAHRENHEIT),
(_temperature_c_strict, UNIT_CELSIUS),
):
try:
return validator(value), unit
except Invalid:
pass
return _temperature_c(value), None
try:
return _temperature_k(value)
except Invalid:
pass
try:
fahrenheit = _temperature_f(value)
return fahrenheit * (5 / 9)
except Invalid:
pass
def _temperature_to_celsius(value: float, unit: str, delta: bool) -> float:
if unit == UNIT_FAHRENHEIT:
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
raise err
def _temperature_from_celsius(value: float, unit: str, delta: bool) -> float:
if unit == UNIT_FAHRENHEIT:
return value * (9 / 5) if delta else value * (9 / 5) + 32
if unit == UNIT_KELVIN:
return value if delta else value + 273.15
return value
def convert_temperature(
value: float, unit: str | None, to_unit: str, *, delta: bool = False
) -> float:
"""Convert a temperature, or with ``delta`` a temperature difference, between units.
A value written without a unit is taken to be in ``to_unit`` already.
"""
if unit is None or unit == to_unit:
return value
return _temperature_from_celsius(
_temperature_to_celsius(value, unit, delta), to_unit, delta
)
def temperature(value: Any) -> float:
"""Parse a temperature in °C, K or °F into degrees Celsius."""
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS)
def temperature_delta(value: Any) -> float:
"""Parse a temperature difference in °C, K or °F into Celsius degrees."""
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS, delta=True)
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
+1
View File
@@ -1260,6 +1260,7 @@ UNIT_DECIBEL_MILLIWATT = "dBm"
UNIT_DEGREE_PER_SECOND = "°/s"
UNIT_DEGREES = "°"
UNIT_EMPTY = ""
UNIT_FAHRENHEIT = "°F"
UNIT_G = "G"
UNIT_GRAMS_PER_CUBIC_METER = "g/m³"
UNIT_HECTOPASCAL = "hPa"
+15 -1
View File
@@ -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.
+7
View File
@@ -1,3 +1,4 @@
from esphome.const import UNIT_CELSIUS, UNIT_FAHRENHEIT, UNIT_KELVIN
from esphome.cpp_generator import MockObj
global_ns = MockObj("", "")
@@ -42,6 +43,12 @@ InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin)
gpio_ns = esphome_ns.namespace("gpio")
gpio_Flags = gpio_ns.enum("Flags", is_class=True)
EntityCategory = esphome_ns.enum("EntityCategory")
TemperatureUnit = esphome_ns.enum("TemperatureUnit", is_class=True)
TEMPERATURE_UNITS = {
UNIT_CELSIUS: TemperatureUnit.CELSIUS,
UNIT_FAHRENHEIT: TemperatureUnit.FAHRENHEIT,
UNIT_KELVIN: TemperatureUnit.KELVIN,
}
Parented = esphome_ns.class_("Parented")
ESPTime = esphome_ns.struct("ESPTime")
StringRef = esphome_ns.class_("StringRef")
@@ -0,0 +1,81 @@
"""Tests for the climate visual temperature conversion."""
import pytest
from esphome.components.climate import (
_visual_temperatures_in_entity_unit,
visual_temperature_step,
)
from esphome.const import (
CONF_CURRENT_TEMPERATURE,
CONF_MAX_TEMPERATURE,
CONF_MIN_TEMPERATURE,
CONF_TARGET_TEMPERATURE,
CONF_TEMPERATURE_STEP,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
)
from esphome.core.entity_helpers import entity_temperature_unit
def test_visual_temperature_step_scalar_applies_to_both() -> None:
step = visual_temperature_step("1°F")
assert step == {
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
CONF_CURRENT_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
}
def test_visual_temperatures_default_to_celsius() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT),
CONF_MAX_TEMPERATURE: (30.0, None),
CONF_TEMPERATURE_STEP: visual_temperature_step("1.8°F"),
}
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
assert visual[CONF_MAX_TEMPERATURE] == 30.0
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == pytest.approx(1.0)
def test_visual_temperatures_follow_unit_of_measurement() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT,
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (60.0, None),
CONF_MAX_TEMPERATURE: (30.0, UNIT_CELSIUS),
CONF_TEMPERATURE_STEP: {
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
CONF_CURRENT_TEMPERATURE: (0.5, UNIT_CELSIUS),
},
},
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == 60.0
assert visual[CONF_MAX_TEMPERATURE] == pytest.approx(86.0)
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == 1.0
assert visual[CONF_TEMPERATURE_STEP][CONF_CURRENT_TEMPERATURE] == pytest.approx(0.9)
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 entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: "f"}) == UNIT_CELSIUS
assert (
entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT})
== UNIT_FAHRENHEIT
)
def test_visual_temperatures_converted_once() -> None:
config = {CONF_VISUAL: {CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT)}}
_visual_temperatures_in_entity_unit(config)
_visual_temperatures_in_entity_unit(config)
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
@@ -24,6 +24,8 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
CONF_UNIT_OF_MEASUREMENT,
UNIT_FAHRENHEIT,
)
from esphome.types import ConfigType
@@ -143,3 +145,14 @@ def test_initial_state_matching_two_point_accepted() -> None:
},
}
assert _validate_initial_state(config) is config
def test_initial_state_temperature_follows_unit_of_measurement() -> None:
config: ConfigType = {
CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT,
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: (21.0, "°C")},
}
_validate_initial_state(config)
assert config[CONF_INITIAL_STATE][CONF_TARGET_TEMPERATURE] == pytest.approx(69.8)
@@ -0,0 +1,49 @@
"""Tests for the water heater visual temperature conversion."""
import pytest
from esphome.components.water_heater import (
CONF_TARGET_TEMPERATURE_STEP,
_visual_temperatures_in_entity_unit,
)
from esphome.const import (
CONF_MAX_TEMPERATURE,
CONF_MIN_TEMPERATURE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
)
def test_visual_temperatures_default_to_celsius() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (100.0, UNIT_FAHRENHEIT),
CONF_MAX_TEMPERATURE: (85.0, None),
CONF_TARGET_TEMPERATURE_STEP: (1.8, UNIT_FAHRENHEIT),
}
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == pytest.approx(37.78, abs=0.01)
assert visual[CONF_MAX_TEMPERATURE] == 85.0
assert visual[CONF_TARGET_TEMPERATURE_STEP] == pytest.approx(1.0)
def test_visual_temperatures_follow_unit_of_measurement() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT,
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (100.0, None),
CONF_MAX_TEMPERATURE: (60.0, UNIT_CELSIUS),
CONF_TARGET_TEMPERATURE_STEP: (1.0, UNIT_CELSIUS),
},
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == 100.0
assert visual[CONF_MAX_TEMPERATURE] == pytest.approx(140.0)
assert visual[CONF_TARGET_TEMPERATURE_STEP] == pytest.approx(1.8)
@@ -41,6 +41,17 @@ esphome:
mode: !lambda "return climate::CLIMATE_MODE_COOL;"
target_temperature: !lambda "return 23.0f;"
- climate.template.publish:
id: template_climate_fahrenheit
current_temperature: 20°C
target_temperature: 70
- climate.control:
id: template_climate_fahrenheit
target_temperature: 22°C
- water_heater.template.publish:
id: template_water_heater_fahrenheit
current_temperature: 45°C
target_temperature: 120
- climate.template.publish:
id: template_climate_custom_modes
custom_fan_mode: "turbo"
@@ -592,6 +603,25 @@ alarm_control_panel:
- "1234"
climate:
# unit_of_measurement keeps the visual temperatures in that unit; a value written in
# another unit is converted into it
- platform: template
id: template_climate_fahrenheit
name: "Template Climate Fahrenheit"
unit_of_measurement: °F
optimistic: true
supported_modes:
- "OFF"
- HEAT
initial_state:
mode: HEAT
target_temperature: 21°C
visual:
min_temperature: 60
max_temperature: 30°C
temperature_step:
target_temperature: 1°F
current_temperature: 0.5°C
- platform: template
id: template_climate
name: "Template Climate"
@@ -684,6 +714,17 @@ climate:
temperature_step: 0.5
water_heater:
- platform: template
id: template_water_heater_fahrenheit
name: "Template Water Heater Fahrenheit"
unit_of_measurement: °F
optimistic: true
current_temperature: !lambda "return 110.0f;"
target_temperature: !lambda "return 120.0f;"
visual:
min_temperature: 100
max_temperature: 60°C
target_temperature_step: 1°C
- platform: template
id: template_water_heater
name: "Template Water Heater"
+2 -6
View File
@@ -2,15 +2,11 @@ water_heater:
- platform: template
id: my_boiler
name: "Test Boiler"
min_temperature: 10
max_temperature: 85
target_temperature_step: 0.5
current_temperature_step: 0.1
optimistic: true
current_temperature: 45.0
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
visual:
min_temperature: 10
max_temperature: 85
target_temperature_step: 0.5
current_temperature_step: 0.1
@@ -0,0 +1,2 @@
packages:
water_heater: !include common.yaml
@@ -0,0 +1,2 @@
packages:
water_heater: !include common.yaml
@@ -51,6 +51,9 @@ from esphome.const import (
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
UNIT_KELVIN,
Framework,
Toolchain,
)
@@ -2145,6 +2148,60 @@ def test_temperature_delta_invalid() -> None:
cv.temperature_delta("5x")
@pytest.mark.parametrize(
("value", "expected"),
[
("100°F", (100.0, UNIT_FAHRENHEIT)),
("100 °F", (100.0, UNIT_FAHRENHEIT)),
("100F", (100.0, UNIT_FAHRENHEIT)),
("25°C", (25.0, UNIT_CELSIUS)),
("25C", (25.0, UNIT_CELSIUS)),
("300K", (300.0, UNIT_KELVIN)),
("300°K", (300.0, UNIT_KELVIN)),
("25", (25.0, None)),
("25°", (25.0, None)),
(25, (25.0, None)),
],
)
def test_temperature_with_unit(
value: str | int, expected: tuple[float, str | None]
) -> None:
assert cv.temperature_with_unit(value) == expected
@pytest.mark.parametrize("value", ["5x", "abc°F", None])
def test_temperature_with_unit__invalid(value: str | None) -> None:
with pytest.raises(Invalid):
cv.temperature_with_unit(value)
def test_temperature_unit() -> None:
assert cv.temperature_unit("°F") == UNIT_FAHRENHEIT
with pytest.raises(Invalid):
cv.temperature_unit("F")
@pytest.mark.parametrize(
("value", "unit", "to_unit", "delta", "expected"),
[
(60.0, None, UNIT_FAHRENHEIT, False, 60.0),
(30.0, UNIT_CELSIUS, UNIT_FAHRENHEIT, False, 86.0),
(212.0, UNIT_FAHRENHEIT, UNIT_CELSIUS, False, 100.0),
(300.0, UNIT_KELVIN, UNIT_FAHRENHEIT, False, 80.33),
(86.0, UNIT_FAHRENHEIT, UNIT_KELVIN, False, 303.15),
(0.5, UNIT_CELSIUS, UNIT_FAHRENHEIT, True, 0.9),
(9.0, UNIT_FAHRENHEIT, UNIT_CELSIUS, True, 5.0),
(1.0, UNIT_KELVIN, UNIT_CELSIUS, True, 1.0),
],
)
def test_convert_temperature(
value: float, unit: str | None, to_unit: str, delta: bool, expected: float
) -> None:
assert cv.convert_temperature(value, unit, to_unit, delta=delta) == pytest.approx(
expected
)
def test_color_temperature_mireds() -> None:
assert cv.color_temperature("153 mireds") == pytest.approx(153.0)