1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +01:00

Add numerical climate conditions (#166309)

This commit is contained in:
Erik Montnemery
2026-03-24 11:38:14 +01:00
committed by GitHub
parent 4c802fba7e
commit 580ae1e81b
10 changed files with 1161 additions and 36 deletions

View File

@@ -1,10 +1,31 @@
"""Provides conditions for climates."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
"""Mixin for climate target temperature conditions with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -28,6 +49,11 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
}

View File

@@ -1,9 +1,9 @@
.condition_common: &condition_common
target:
target: &condition_climate_target
entity:
domain: climate
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
@@ -13,8 +13,76 @@
- all
- any
.number_or_entity_humidity: &number_or_entity_humidity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "%"
- domain: sensor
device_class: humidity
- domain: number
device_class: humidity
translation_key: number_or_entity
.number_or_entity_temperature: &number_or_entity_temperature
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "°C"
- "°F"
- domain: sensor
device_class: temperature
- domain: number
device_class: temperature
translation_key: number_or_entity
.condition_unit_temperature: &condition_unit_temperature
required: false
selector:
select:
options:
- "°C"
- "°F"
is_off: *condition_common
is_on: *condition_common
is_cooling: *condition_common
is_drying: *condition_common
is_heating: *condition_common
target_humidity:
target: *condition_climate_target
fields:
behavior: *condition_behavior
above: *number_or_entity_humidity
below: *number_or_entity_humidity
target_temperature:
target: *condition_climate_target
fields:
behavior: *condition_behavior
above: *number_or_entity_temperature
below: *number_or_entity_temperature
unit: *condition_unit_temperature

View File

@@ -14,6 +14,12 @@
},
"is_on": {
"condition": "mdi:power-on"
},
"target_humidity": {
"condition": "mdi:water-percent"
},
"target_temperature": {
"condition": "mdi:thermometer"
}
},
"entity_component": {

View File

@@ -55,6 +55,46 @@
}
},
"name": "Climate-control device is on"
},
"target_humidity": {
"description": "Tests the humidity setpoint of one or more climate-control devices.",
"fields": {
"above": {
"description": "Require the target humidity to be above this value.",
"name": "Above"
},
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"below": {
"description": "Require the target humidity to be below this value.",
"name": "Below"
}
},
"name": "Climate-control device target humidity"
},
"target_temperature": {
"description": "Tests the temperature setpoint of one or more climate-control devices.",
"fields": {
"above": {
"description": "Require the target temperature to be above this value.",
"name": "Above"
},
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"below": {
"description": "Require the target temperature to be below this value.",
"name": "Below"
},
"unit": {
"description": "All values will be converted to this unit when evaluating the condition.",
"name": "Unit of measurement"
}
},
"name": "Climate-control device target temperature"
}
},
"device_automation": {

View File

@@ -3,7 +3,7 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from enum import Enum
from typing import Any
from typing import Any, Final
import voluptuous as vol
@@ -14,6 +14,8 @@ from . import config_validation as cv
from .entity import get_device_class_or_undefined
from .typing import ConfigType
CONF_UNIT: Final = "unit"
class AnyDeviceClassType(Enum):
"""Singleton type for matching any device class."""
@@ -163,3 +165,21 @@ def _validate_number_or_entity(value: dict | float | str) -> float | str:
number_or_entity = vol.All(
_validate_number_or_entity, vol.Any(vol.Coerce(float), cv.entity_id)
)
def validate_unit_set_if_range_numerical[_T: dict[str, Any]](
lower_limit: str, upper_limit: str
) -> Callable[[_T], _T]:
"""Validate that unit is set if upper or lower limit is numerical."""
def _validate_unit_set_if_range_numerical_impl(options: _T) -> _T:
if (
any(
opt in options and not isinstance(options[opt], str)
for opt in (lower_limit, upper_limit)
)
) and CONF_UNIT not in options:
raise vol.Invalid("Unit must be specified when using numerical thresholds.")
return options
return _validate_unit_set_if_range_numerical_impl

View File

@@ -73,16 +73,19 @@ from homeassistant.loader import (
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.unit_conversion import BaseUnitConverter
from homeassistant.util.yaml import load_yaml_dict
from . import config_validation as cv, entity_registry as er, selector
from .automation import (
CONF_UNIT,
DomainSpec,
filter_by_domain_specs,
get_absolute_description_key,
get_relative_description_key,
move_options_fields_to_top_level,
number_or_entity,
validate_unit_set_if_range_numerical,
)
from .integration_platform import async_process_integration_platforms
from .selector import TargetSelector
@@ -524,13 +527,19 @@ class EntityNumericalConditionBase(EntityConditionBase):
return None
return entity_or_float
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked value from a state, with unit validation for state-based values."""
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
if not self._is_valid_unit(
entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
):
return None
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state is within the specified range."""
if not self._is_valid_unit(
entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
):
return False
try:
value = float(self._get_tracked_value(entity_state))
except TypeError, ValueError:
@@ -565,6 +574,113 @@ def make_entity_numerical_condition(
return CustomCondition
def _make_numerical_condition_with_unit_schema(
unit_converter: type[BaseUnitConverter],
) -> vol.Schema:
"""Factory for numerical condition schema with unit option."""
return vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): vol.All(
{
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
vol.Optional(CONF_ABOVE): number_or_entity,
vol.Optional(CONF_BELOW): number_or_entity,
vol.Optional(CONF_UNIT): vol.In(unit_converter.VALID_UNITS),
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
_validate_above_below,
validate_unit_set_if_range_numerical(CONF_ABOVE, CONF_BELOW),
),
}
)
class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase):
"""Condition for numerical state comparisons with unit conversion."""
_base_unit: str | None # Base unit for the tracked value
_manual_limit_unit: str | None # Unit of above/below limits when numbers
_unit_converter: type[BaseUnitConverter]
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the numerical condition with unit conversion."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._manual_limit_unit = config.options.get(CONF_UNIT)
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Create a schema."""
super().__init_subclass__(**kwargs)
cls._schema = _make_numerical_condition_with_unit_schema(cls._unit_converter)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the unit of an entity from its state."""
return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
def _get_numerical_value(self, entity_or_float: float | str) -> float | None:
"""Get numerical value from float or entity state."""
if isinstance(entity_or_float, (int, float)):
return self._unit_converter.convert(
entity_or_float, self._manual_limit_unit, self._base_unit
)
if not (_state := self._hass.states.get(entity_or_float)):
return None
try:
value = float(_state.state)
except TypeError, ValueError:
return None
try:
return self._unit_converter.convert(
value, _state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit
)
except HomeAssistantError:
return None
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked numerical value from a state."""
domain_spec = self._domain_specs[entity_state.domain]
raw_value: Any
if domain_spec.value_source is None:
raw_value = entity_state.state
else:
raw_value = entity_state.attributes.get(domain_spec.value_source)
try:
value = float(raw_value)
except TypeError, ValueError:
return None
try:
return self._unit_converter.convert(
value, self._get_entity_unit(entity_state), self._base_unit
)
except HomeAssistantError:
return None
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state is within the specified range."""
if (value := self._get_tracked_value(entity_state)) is None:
return False
if self._above is not None:
if (above := self._get_numerical_value(self._above)) is None:
return False
if value <= above:
return False
if self._below is not None:
if (below := self._get_numerical_value(self._below)) is None:
return False
if value >= below:
return False
return True
class ConditionProtocol(Protocol):
"""Define the format of condition modules."""

View File

@@ -70,6 +70,7 @@ from homeassistant.util.yaml import load_yaml_dict
from . import config_validation as cv, selector
from .automation import (
CONF_UNIT,
DomainSpec,
NumericalDomainSpec,
filter_by_domain_specs,
@@ -77,6 +78,7 @@ from .automation import (
get_relative_description_key,
move_options_fields_to_top_level,
number_or_entity,
validate_unit_set_if_range_numerical,
)
from .integration_platform import async_process_integration_platforms
from .selector import TargetSelector
@@ -545,27 +547,6 @@ def _validate_range[_T: dict[str, Any]](
return _validate_range_impl
CONF_UNIT: Final = "unit"
def _validate_unit_set_if_range_numerical[_T: dict[str, Any]](
lower_limit: str, upper_limit: str
) -> Callable[[_T], _T]:
"""Validate that unit is set if upper or lower limit is numerical."""
def _validate_unit_set_if_range_numerical_impl(options: _T) -> _T:
if (
any(
opt in options and not isinstance(options[opt], str)
for opt in (lower_limit, upper_limit)
)
) and options.get(CONF_UNIT) is None:
raise vol.Invalid("Unit must be specified when using numerical thresholds.")
return options
return _validate_unit_set_if_range_numerical_impl
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): vol.All(
@@ -756,7 +737,7 @@ def make_numerical_state_changed_with_unit_schema(
vol.Optional(CONF_UNIT): vol.In(unit_converter.VALID_UNITS),
},
_validate_range(CONF_ABOVE, CONF_BELOW),
_validate_unit_set_if_range_numerical(CONF_ABOVE, CONF_BELOW),
validate_unit_set_if_range_numerical(CONF_ABOVE, CONF_BELOW),
)
}
)
@@ -907,7 +888,7 @@ def make_numerical_state_crossed_threshold_with_unit_schema(
},
_validate_range(CONF_LOWER_LIMIT, CONF_UPPER_LIMIT),
_validate_limits_for_threshold_type,
_validate_unit_set_if_range_numerical(
validate_unit_set_if_range_numerical(
CONF_LOWER_LIMIT, CONF_UPPER_LIMIT
),
)

View File

@@ -5,10 +5,18 @@ from typing import Any
import pytest
from homeassistant.components.climate.const import (
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
HVACAction,
HVACMode,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ABOVE,
CONF_BELOW,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from tests.components.common import (
@@ -16,13 +24,18 @@ from tests.components.common import (
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
assert_numerical_condition_unit_conversion,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_numerical_attribute_condition_above_below_all,
parametrize_numerical_attribute_condition_above_below_any,
parametrize_target_entities,
target_entities,
)
_TEMPERATURE_CONDITION_OPTIONS = {"unit": UnitOfTemperature.CELSIUS}
@pytest.fixture
async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
@@ -38,6 +51,8 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
"climate.is_cooling",
"climate.is_drying",
"climate.is_heating",
"climate.target_humidity",
"climate.target_temperature",
],
)
async def test_climate_conditions_gated_by_labs_flag(
@@ -241,3 +256,141 @@ async def test_climate_attribute_condition_behavior_all(
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_numerical_attribute_condition_above_below_any(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
),
*parametrize_numerical_attribute_condition_above_below_any(
"climate.target_temperature",
HVACMode.AUTO,
ATTR_TEMPERATURE,
condition_options=_TEMPERATURE_CONDITION_OPTIONS,
),
],
)
async def test_climate_numerical_condition_behavior_any(
hass: HomeAssistant,
target_climates: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the climate numerical condition with the 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_climates,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_numerical_attribute_condition_above_below_all(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
),
*parametrize_numerical_attribute_condition_above_below_all(
"climate.target_temperature",
HVACMode.AUTO,
ATTR_TEMPERATURE,
condition_options=_TEMPERATURE_CONDITION_OPTIONS,
),
],
)
async def test_climate_numerical_condition_behavior_all(
hass: HomeAssistant,
target_climates: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the climate numerical condition with the 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_climates,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_climate_numerical_condition_unit_conversion(hass: HomeAssistant) -> None:
"""Test that the climate numerical condition converts units correctly."""
_unit_celsius = {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}
_unit_fahrenheit = {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}
_unit_invalid = {ATTR_UNIT_OF_MEASUREMENT: "not_a_valid_unit"}
await assert_numerical_condition_unit_conversion(
hass,
condition="climate.target_temperature",
entity_id="climate.test",
pass_states=[{"state": HVACMode.AUTO, "attributes": {ATTR_TEMPERATURE: 25}}],
fail_states=[
{
"state": HVACMode.AUTO,
"attributes": {ATTR_TEMPERATURE: 20},
}
],
numerical_condition_options=[
{CONF_ABOVE: 75, CONF_BELOW: 90, "unit": UnitOfTemperature.FAHRENHEIT},
{CONF_ABOVE: 24, CONF_BELOW: 30, "unit": UnitOfTemperature.CELSIUS},
],
limit_entity_condition_options={
CONF_ABOVE: "sensor.above",
CONF_BELOW: "sensor.below",
},
limit_entities=("sensor.above", "sensor.below"),
limit_entity_states=[
(
{"state": "75", "attributes": _unit_fahrenheit}, # ≈23.9°C
{"state": "90", "attributes": _unit_fahrenheit}, # ≈32.2°C
),
(
{"state": "24", "attributes": _unit_celsius},
{"state": "30", "attributes": _unit_celsius},
),
],
invalid_limit_entity_states=[
(
{"state": "75", "attributes": _unit_invalid},
{"state": "90", "attributes": _unit_invalid},
),
(
{"state": "24", "attributes": _unit_invalid},
{"state": "30", "attributes": _unit_invalid},
),
],
)

View File

@@ -1300,6 +1300,138 @@ def parametrize_numerical_condition_above_below_all(
]
def parametrize_numerical_attribute_condition_above_below_any(
condition: str,
state: str,
attribute: str,
*,
condition_options: dict[str, Any] | None = None,
required_filter_attributes: dict | None = None,
unit_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below threshold test cases for attribute-based numerical conditions.
Returns a list of tuples with (condition, condition_options, states).
"""
condition_options = condition_options or {}
unit_attributes = unit_attributes or {}
return [
*parametrize_condition_states_any(
condition=condition,
condition_options={CONF_ABOVE: 20, **condition_options},
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 10} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_any(
condition=condition,
condition_options={CONF_BELOW: 80, **condition_options},
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
],
other_states=[
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 90} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_any(
condition=condition,
condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options},
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
]
def parametrize_numerical_attribute_condition_above_below_all(
condition: str,
state: str,
attribute: str,
*,
condition_options: dict[str, Any] | None = None,
required_filter_attributes: dict | None = None,
unit_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below threshold test cases for attribute-based numerical conditions with 'all' behavior.
Returns a list of tuples with (condition, condition_options, states).
"""
condition_options = condition_options or {}
unit_attributes = unit_attributes or {}
return [
*parametrize_condition_states_all(
condition=condition,
condition_options={CONF_ABOVE: 20, **condition_options},
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 10} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_all(
condition=condition,
condition_options={CONF_BELOW: 80, **condition_options},
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
],
other_states=[
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 90} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_all(
condition=condition,
condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options},
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
]
async def assert_trigger_ignores_limit_entities_with_wrong_unit(
hass: HomeAssistant,
*,
@@ -1373,3 +1505,88 @@ async def assert_trigger_ignores_limit_entities_with_wrong_unit(
else:
# All limits fixed - should fire
assert len(service_calls) == 1
async def assert_numerical_condition_unit_conversion(
hass: HomeAssistant,
*,
condition: str,
entity_id: str,
pass_states: list[StateDescription],
fail_states: list[StateDescription],
numerical_condition_options: list[dict[str, Any]],
limit_entity_condition_options: dict[str, Any],
limit_entities: tuple[str, str],
limit_entity_states: list[tuple[StateDescription, StateDescription]],
invalid_limit_entity_states: list[tuple[StateDescription, StateDescription]],
) -> None:
"""Test unit conversion of a numerical condition.
Verifies that a numerical condition correctly converts between units, both
when limits are specified as numbers (with explicit units) and when limits
come from entity references. Also verifies that the condition rejects limit
entities whose unit_of_measurement is invalid (not convertible).
Args:
condition: The condition key (e.g. "climate.target_temperature").
entity_id: The entity being evaluated by the condition.
pass_states: Entity states that should make the condition pass.
fail_states: Entity states that should make the condition fail.
numerical_condition_options: List of condition option dicts, each
specifying above/below thresholds with a unit. Every combination
is tested against pass_states and fail_states.
limit_entity_condition_options: Condition options dict using entity
references for above/below (e.g. {CONF_ABOVE: "sensor.above"}).
limit_entities: Tuple of (above_entity_id, below_entity_id) referenced
by limit_entity_condition_options.
limit_entity_states: List of (above_state, below_state) tuples, each
providing valid states for the limit entities. Every combination
is tested against pass_states and fail_states.
invalid_limit_entity_states: Like limit_entity_states, but with invalid
units. The condition should always fail regardless of entity state.
"""
# Test limits set as number
for condition_options in numerical_condition_options:
cond = await create_target_condition(
hass,
condition=condition,
target={CONF_ENTITY_ID: [entity_id]},
behavior="any",
condition_options=condition_options,
)
for state in pass_states:
set_or_remove_state(hass, entity_id, state)
assert cond(hass) is True
for state in fail_states:
set_or_remove_state(hass, entity_id, state)
assert cond(hass) is False
# Test limits set by entity
cond = await create_target_condition(
hass,
condition=condition,
target={CONF_ENTITY_ID: [entity_id]},
behavior="any",
condition_options=limit_entity_condition_options,
)
for limit_states in limit_entity_states:
set_or_remove_state(hass, limit_entities[0], limit_states[0])
set_or_remove_state(hass, limit_entities[1], limit_states[1])
for state in pass_states:
set_or_remove_state(hass, entity_id, state)
assert cond(hass) is True
for state in fail_states:
set_or_remove_state(hass, entity_id, state)
assert cond(hass) is False
# Test invalid unit
for limit_states in invalid_limit_entity_states:
set_or_remove_state(hass, limit_entities[0], limit_states[0])
set_or_remove_state(hass, limit_entities[1], limit_states[1])
for state in pass_states:
set_or_remove_state(hass, entity_id, state)
assert cond(hass) is False
for state in fail_states:
set_or_remove_state(hass, entity_id, state)
assert cond(hass) is False

View File

@@ -31,8 +31,9 @@ from homeassistant.const import (
CONF_TARGET,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ConditionError, HomeAssistantError
from homeassistant.helpers import (
condition,
@@ -42,14 +43,17 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.automation import (
DomainSpec,
NumericalDomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ALL,
BEHAVIOR_ANY,
CONF_UNIT,
Condition,
ConditionChecker,
EntityNumericalConditionWithUnitBase,
async_validate_condition_config,
make_entity_numerical_condition,
)
@@ -58,6 +62,7 @@ from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from homeassistant.loader import Integration, async_get_integration
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter
from homeassistant.util.yaml.loader import parse_yaml
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
@@ -3127,6 +3132,32 @@ async def test_numerical_condition_attribute_value_source(
assert test(hass) is False
async def test_numerical_condition_attribute_value_source_skips_unit_check(
hass: HomeAssistant,
) -> None:
"""Test numerical condition with attribute value_source skips entity unit check.
When value_source is set, the entity itself may not have ATTR_UNIT_OF_MEASUREMENT
(e.g., climate target humidity). The valid_unit check should only apply to
state-based entities, not attribute-based ones.
"""
test = await _setup_numerical_condition(
hass,
domain_specs={"test": DomainSpec(value_source="humidity")},
condition_options={CONF_ABOVE: 50},
entity_ids="test.entity_1",
valid_unit="%",
)
# Entity has no ATTR_UNIT_OF_MEASUREMENT but has the attribute value
# The unit check should be skipped for attribute-based value sources
hass.states.async_set("test.entity_1", "auto", {"humidity": 75})
assert test(hass) is True
hass.states.async_set("test.entity_1", "auto", {"humidity": 25})
assert test(hass) is False
@pytest.mark.parametrize(
("valid_unit", "entity_unit", "expected"),
[
@@ -3252,3 +3283,470 @@ async def test_numerical_condition_schema_above_must_be_less_than_below(
}
with pytest.raises(vol.Invalid, match="can never be above"):
await async_validate_condition_config(hass, config)
def make_entity_numerical_condition_with_unit(
domain_specs: Mapping[str, DomainSpec],
base_unit: str,
unit_converter: type[BaseUnitConverter],
) -> type[EntityNumericalConditionWithUnitBase]:
"""Create a condition for numerical state comparisons with unit conversion."""
class CustomCondition(EntityNumericalConditionWithUnitBase):
"""Condition for numerical state with unit conversion."""
_domain_specs = domain_specs
_base_unit = base_unit
_unit_converter = unit_converter
return CustomCondition
async def _setup_numerical_condition_with_unit(
hass: HomeAssistant,
condition_options: dict[str, Any],
entity_ids: str | list[str],
domain_specs: Mapping[str, DomainSpec] | None = None,
base_unit: str = UnitOfTemperature.CELSIUS,
unit_converter: type = TemperatureConverter,
) -> condition.ConditionCheckerType:
"""Set up a numerical condition with unit conversion via a mock platform."""
condition_cls = make_entity_numerical_condition_with_unit(
domain_specs or _DEFAULT_DOMAIN_SPECS, base_unit, unit_converter
)
async def async_get_conditions(
hass: HomeAssistant,
) -> dict[str, type[Condition]]:
return {"_": condition_cls}
mock_integration(hass, MockModule("test"))
mock_platform(
hass, "test.condition", Mock(async_get_conditions=async_get_conditions)
)
if isinstance(entity_ids, str):
entity_ids = [entity_ids]
config: dict[str, Any] = {
CONF_CONDITION: "test",
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
CONF_OPTIONS: condition_options,
}
config = await async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
assert test is not None
return test
@pytest.mark.parametrize(
("condition_options", "state_value", "expected"),
[
# above in °F, state in °C (base unit)
# 75°F ≈ 23.89°C, so 25°C > 23.89°C → True
({CONF_ABOVE: 75, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "25", True),
# 75°F ≈ 23.89°C, so 20°C < 23.89°C → False
({CONF_ABOVE: 75, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "20", False),
# below in °F, state in °C
# 70°F ≈ 21.11°C, so 20°C < 21.11°C → True
({CONF_BELOW: 70, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "20", True),
# 70°F ≈ 21.11°C, so 25°C > 21.11°C → False
({CONF_BELOW: 70, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "25", False),
# above in °C (same as base), state in °C
({CONF_ABOVE: 20, CONF_UNIT: UnitOfTemperature.CELSIUS}, "25", True),
({CONF_ABOVE: 20, CONF_UNIT: UnitOfTemperature.CELSIUS}, "15", False),
# range with unit conversion
# 60°F ≈ 15.56°C, 80°F ≈ 26.67°C
(
{
CONF_ABOVE: 60,
CONF_BELOW: 80,
CONF_UNIT: UnitOfTemperature.FAHRENHEIT,
},
"20",
True,
),
(
{
CONF_ABOVE: 60,
CONF_BELOW: 80,
CONF_UNIT: UnitOfTemperature.FAHRENHEIT,
},
"10",
False,
),
(
{
CONF_ABOVE: 60,
CONF_BELOW: 80,
CONF_UNIT: UnitOfTemperature.FAHRENHEIT,
},
"30",
False,
),
],
)
async def test_numerical_condition_with_unit_thresholds(
hass: HomeAssistant,
condition_options: dict[str, Any],
state_value: str,
expected: bool,
) -> None:
"""Test numerical condition with unit conversion for numeric thresholds."""
test = await _setup_numerical_condition_with_unit(
hass,
condition_options=condition_options,
entity_ids="test.entity_1",
)
hass.states.async_set(
"test.entity_1",
state_value,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
assert test(hass) is expected
async def test_numerical_condition_with_unit_entity_reference(
hass: HomeAssistant,
) -> None:
"""Test numerical condition with unit conversion for entity reference limits."""
test = await _setup_numerical_condition_with_unit(
hass,
condition_options={
CONF_ABOVE: "sensor.temp_limit",
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
entity_ids="test.entity_1",
)
# Entity reference in °F → converted to °C for comparison
# 75°F ≈ 23.89°C, 25°C > 23.89°C → True
hass.states.async_set(
"test.entity_1",
"25",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
hass.states.async_set(
"sensor.temp_limit",
"75",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
assert test(hass) is True
# 75°F ≈ 23.89°C, 20°C < 23.89°C → False
hass.states.async_set(
"test.entity_1",
"20",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
assert test(hass) is False
async def test_numerical_condition_with_unit_entity_reference_incompatible_unit(
hass: HomeAssistant,
) -> None:
"""Test numerical condition returns false when entity reference has incompatible unit."""
test = await _setup_numerical_condition_with_unit(
hass,
condition_options={
CONF_ABOVE: "sensor.bad_limit",
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
entity_ids="test.entity_1",
)
hass.states.async_set(
"test.entity_1",
"25",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
# "%" is not a temperature unit → conversion fails → condition false
hass.states.async_set(
"sensor.bad_limit",
"75",
{ATTR_UNIT_OF_MEASUREMENT: "%"},
)
assert test(hass) is False
async def test_numerical_condition_with_unit_tracked_value_conversion(
hass: HomeAssistant,
) -> None:
"""Test that tracked entity values are converted from entity unit to base unit."""
test = await _setup_numerical_condition_with_unit(
hass,
condition_options={
CONF_ABOVE: 20,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
entity_ids="test.entity_1",
)
# Entity reports in °F: 80°F ≈ 26.67°C > 20°C → True
hass.states.async_set(
"test.entity_1",
"80",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
assert test(hass) is True
# Entity reports in °F: 50°F ≈ 10°C < 20°C → False
hass.states.async_set(
"test.entity_1",
"50",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
assert test(hass) is False
async def test_numerical_condition_with_unit_attribute_value_source(
hass: HomeAssistant,
) -> None:
"""Test numerical condition with unit conversion reads from attribute."""
test = await _setup_numerical_condition_with_unit(
hass,
domain_specs={
"test": NumericalDomainSpec(value_source="temperature"),
},
condition_options={
CONF_ABOVE: 75,
CONF_UNIT: UnitOfTemperature.FAHRENHEIT,
},
entity_ids="test.entity_1",
)
# 75°F ≈ 23.89°C, attribute=25°C > 23.89°C → True
hass.states.async_set(
"test.entity_1",
"on",
{
"temperature": 25,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
assert test(hass) is True
# 75°F ≈ 23.89°C, attribute=20°C < 23.89°C → False
hass.states.async_set(
"test.entity_1",
"on",
{
"temperature": 20,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
assert test(hass) is False
# Missing attribute → False
hass.states.async_set("test.entity_1", "on", {})
assert test(hass) is False
async def test_numerical_condition_with_unit_get_entity_unit_override(
hass: HomeAssistant,
) -> None:
"""Test that _get_entity_unit can be overridden for custom unit resolution."""
class CustomCondition(EntityNumericalConditionWithUnitBase):
"""Condition that always reports entities as °F regardless of attributes."""
_domain_specs = {"test": NumericalDomainSpec(value_source="temperature")}
_base_unit = UnitOfTemperature.CELSIUS
_unit_converter = TemperatureConverter
def _get_entity_unit(self, entity_state: State) -> str | None:
return UnitOfTemperature.FAHRENHEIT
async def async_get_conditions(
hass: HomeAssistant,
) -> dict[str, type[Condition]]:
return {"_": CustomCondition}
mock_integration(hass, MockModule("test"))
mock_platform(
hass, "test.condition", Mock(async_get_conditions=async_get_conditions)
)
config: dict[str, Any] = {
CONF_CONDITION: "test",
CONF_TARGET: {CONF_ENTITY_ID: ["test.entity_1"]},
CONF_OPTIONS: {
CONF_ABOVE: 20,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
}
config = await async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
assert test is not None
# Entity attribute is 80 — _get_entity_unit returns °F,
# so 80°F ≈ 26.67°C > 20°C → True
hass.states.async_set("test.entity_1", "on", {"temperature": 80})
assert test(hass) is True
# Entity attribute is 50 — 50°F ≈ 10°C < 20°C → False
hass.states.async_set("test.entity_1", "on", {"temperature": 50})
assert test(hass) is False
async def test_numerical_condition_with_unit_schema_accepts_valid_units(
hass: HomeAssistant,
) -> None:
"""Test that the schema accepts valid temperature units."""
condition_cls = make_entity_numerical_condition_with_unit(
{"test": DomainSpec()}, UnitOfTemperature.CELSIUS, TemperatureConverter
)
async def async_get_conditions(
hass: HomeAssistant,
) -> dict[str, type[Condition]]:
return {"_": condition_cls}
mock_integration(hass, MockModule("test"))
mock_platform(
hass, "test.condition", Mock(async_get_conditions=async_get_conditions)
)
# Valid unit
config: dict[str, Any] = {
CONF_CONDITION: "test",
CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"},
CONF_OPTIONS: {
CONF_ABOVE: 20,
CONF_UNIT: UnitOfTemperature.FAHRENHEIT,
},
}
result = await async_validate_condition_config(hass, config)
assert result is not None
async def test_numerical_condition_with_unit_schema_rejects_invalid_units(
hass: HomeAssistant,
) -> None:
"""Test that the schema rejects invalid temperature units."""
condition_cls = make_entity_numerical_condition_with_unit(
{"test": DomainSpec()}, UnitOfTemperature.CELSIUS, TemperatureConverter
)
async def async_get_conditions(
hass: HomeAssistant,
) -> dict[str, type[Condition]]:
return {"_": condition_cls}
mock_integration(hass, MockModule("test"))
mock_platform(
hass, "test.condition", Mock(async_get_conditions=async_get_conditions)
)
# Invalid unit
config: dict[str, Any] = {
CONF_CONDITION: "test",
CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"},
CONF_OPTIONS: {
CONF_ABOVE: 20,
CONF_UNIT: "%",
},
}
with pytest.raises(vol.Invalid):
await async_validate_condition_config(hass, config)
@pytest.mark.parametrize(
"state_value",
["cat", STATE_UNAVAILABLE, STATE_UNKNOWN],
)
async def test_numerical_condition_with_unit_invalid_state(
hass: HomeAssistant, state_value: str
) -> None:
"""Test numerical condition with unit returns false for non-numeric state values."""
test = await _setup_numerical_condition_with_unit(
hass,
condition_options={
CONF_ABOVE: 50,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
entity_ids="test.entity_1",
)
hass.states.async_set(
"test.entity_1",
state_value,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
assert test(hass) is False
async def test_numerical_condition_with_unit_missing_entity_reference(
hass: HomeAssistant,
) -> None:
"""Test numerical condition returns false when entity reference does not exist."""
test = await _setup_numerical_condition_with_unit(
hass,
condition_options={
CONF_ABOVE: "sensor.nonexistent",
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
entity_ids="test.entity_1",
)
hass.states.async_set(
"test.entity_1",
"25",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
assert test(hass) is False
@pytest.mark.parametrize(
("behavior", "one_match_expected"),
[
(BEHAVIOR_ANY, True),
(BEHAVIOR_ALL, False),
],
)
async def test_numerical_condition_with_unit_behavior(
hass: HomeAssistant,
behavior: str,
one_match_expected: bool,
) -> None:
"""Test numerical condition with unit conversion respects any/all behavior."""
test = await _setup_numerical_condition_with_unit(
hass,
condition_options={
CONF_ABOVE: 50,
ATTR_BEHAVIOR: behavior,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
entity_ids=["test.entity_1", "test.entity_2"],
)
# Both above → True for any and all
hass.states.async_set(
"test.entity_1",
"75",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
hass.states.async_set(
"test.entity_2",
"80",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
assert test(hass) is True
# Only one above → depends on behavior
hass.states.async_set(
"test.entity_2",
"25",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
assert test(hass) is one_match_expected
# Neither above → False for any and all
hass.states.async_set(
"test.entity_1",
"25",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
)
assert test(hass) is False