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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
},
|
||||
"is_on": {
|
||||
"condition": "mdi:power-on"
|
||||
},
|
||||
"target_humidity": {
|
||||
"condition": "mdi:water-percent"
|
||||
},
|
||||
"target_temperature": {
|
||||
"condition": "mdi:thermometer"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user