From 580ae1e81b6deb06ab124931c8ec14d2a7c7c9d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Mar 2026 11:38:14 +0100 Subject: [PATCH] Add numerical climate conditions (#166309) --- homeassistant/components/climate/condition.py | 34 +- .../components/climate/conditions.yaml | 72 ++- homeassistant/components/climate/icons.json | 6 + homeassistant/components/climate/strings.json | 40 ++ homeassistant/helpers/automation.py | 22 +- homeassistant/helpers/condition.py | 126 ++++- homeassistant/helpers/trigger.py | 27 +- tests/components/climate/test_condition.py | 153 ++++++ tests/components/common.py | 217 ++++++++ tests/helpers/test_condition.py | 500 +++++++++++++++++- 10 files changed, 1161 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 8535890bd5e..4f3c4bf1f6c 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -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, } diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml index 284e128583b..db40d7e444c 100644 --- a/homeassistant/components/climate/conditions.yaml +++ b/homeassistant/components/climate/conditions.yaml @@ -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 diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index ebc8333cca2..3300deb17e9 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -14,6 +14,12 @@ }, "is_on": { "condition": "mdi:power-on" + }, + "target_humidity": { + "condition": "mdi:water-percent" + }, + "target_temperature": { + "condition": "mdi:thermometer" } }, "entity_component": { diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index e4c1bc49adb..062aa520c2a 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -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": { diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index bf3d33f9fd2..318e920fb1c 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -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 diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5e6767cb1b5..fabad1e36e7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -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.""" diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index a25ef59357a..c0423b02d3d 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -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 ), ) diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 16eb718055a..40f8407dfe5 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -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}, + ), + ], + ) diff --git a/tests/components/common.py b/tests/components/common.py index 8661d98bf89..36197e0d117 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -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 diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 4912dd5f070..15aae7356cc 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -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