diff --git a/CODEOWNERS b/CODEOWNERS index 03bafdd0b38..d1890a91b49 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1703,6 +1703,8 @@ build.json @home-assistant/supervisor /tests/components/tellduslive/ @fredrike /homeassistant/components/teltonika/ @karlbeecken /tests/components/teltonika/ @karlbeecken +/homeassistant/components/temperature/ @home-assistant/core +/tests/components/temperature/ @home-assistant/core /homeassistant/components/template/ @Petro31 @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 8590bc8fdfd..28a5ab31019 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -247,6 +247,7 @@ DEFAULT_INTEGRATIONS = { "humidity", "motion", "occupancy", + "temperature", "window", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index c4695b81d68..a7b51785c1a 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -169,6 +169,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "select", "siren", "switch", + "temperature", "text", "update", "vacuum", diff --git a/homeassistant/components/temperature/__init__.py b/homeassistant/components/temperature/__init__.py new file mode 100644 index 00000000000..4479fdbefc7 --- /dev/null +++ b/homeassistant/components/temperature/__init__.py @@ -0,0 +1,17 @@ +"""Integration for temperature triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "temperature" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/temperature/icons.json b/homeassistant/components/temperature/icons.json new file mode 100644 index 00000000000..a7a88db9973 --- /dev/null +++ b/homeassistant/components/temperature/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "changed": { + "trigger": "mdi:thermometer" + }, + "crossed_threshold": { + "trigger": "mdi:thermometer" + } + } +} diff --git a/homeassistant/components/temperature/manifest.json b/homeassistant/components/temperature/manifest.json new file mode 100644 index 00000000000..690896259ba --- /dev/null +++ b/homeassistant/components/temperature/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "temperature", + "name": "Temperature", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/temperature", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/temperature/strings.json b/homeassistant/components/temperature/strings.json new file mode 100644 index 00000000000..36e241fb805 --- /dev/null +++ b/homeassistant/components/temperature/strings.json @@ -0,0 +1,76 @@ +{ + "common": { + "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "selector": { + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + }, + "trigger_threshold_type": { + "options": { + "above": "Above", + "below": "Below", + "between": "Between", + "outside": "Outside" + } + } + }, + "title": "Temperature", + "triggers": { + "changed": { + "description": "Triggers when the temperature changes.", + "fields": { + "above": { + "description": "Only trigger when temperature is above this value.", + "name": "Above" + }, + "below": { + "description": "Only trigger when temperature is below this value.", + "name": "Below" + }, + "unit": { + "description": "All values will be converted to this unit when evaluating the trigger.", + "name": "Unit of measurement" + } + }, + "name": "Temperature changed" + }, + "crossed_threshold": { + "description": "Triggers when the temperature crosses a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::temperature::common::trigger_behavior_description%]", + "name": "[%key:component::temperature::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "The lower limit of the threshold.", + "name": "Lower limit" + }, + "threshold_type": { + "description": "The type of threshold to use.", + "name": "Threshold type" + }, + "unit": { + "description": "[%key:component::temperature::triggers::changed::fields::unit::description%]", + "name": "[%key:component::temperature::triggers::changed::fields::unit::name%]" + }, + "upper_limit": { + "description": "The upper limit of the threshold.", + "name": "Upper limit" + } + }, + "name": "Temperature crossed threshold" + } + } +} diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py new file mode 100644 index 00000000000..c255d39d129 --- /dev/null +++ b/homeassistant/components/temperature/trigger.py @@ -0,0 +1,83 @@ +"""Provides triggers for temperature.""" + +from __future__ import annotations + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.water_heater import ( + ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, +) +from homeassistant.components.weather import ( + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + DOMAIN as WEATHER_DOMAIN, +) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerWithUnitBase, + EntityNumericalStateCrossedThresholdTriggerWithUnitBase, + EntityNumericalStateTriggerWithUnitBase, + Trigger, +) +from homeassistant.util.unit_conversion import TemperatureConverter + +TEMPERATURE_DOMAIN_SPECS = { + CLIMATE_DOMAIN: NumericalDomainSpec( + value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, + ), + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.TEMPERATURE, + ), + WATER_HEATER_DOMAIN: NumericalDomainSpec( + value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE + ), + WEATHER_DOMAIN: NumericalDomainSpec( + value_source=ATTR_WEATHER_TEMPERATURE, + ), +} + + +class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase): + """Mixin for temperature triggers providing entity filtering, value extraction, and unit conversion.""" + + _base_unit = UnitOfTemperature.CELSIUS + _domain_specs = TEMPERATURE_DOMAIN_SPECS + _unit_converter = TemperatureConverter + + def _get_entity_unit(self, state: State) -> str | None: + """Get the temperature unit of an entity from its state.""" + if state.domain == SENSOR_DOMAIN: + return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if state.domain == WEATHER_DOMAIN: + return state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT) + # Climate and water_heater: show_temp converts to system unit + return self._hass.config.units.temperature_unit + + +class TemperatureChangedTrigger( + _TemperatureTriggerMixin, EntityNumericalStateChangedTriggerWithUnitBase +): + """Trigger for temperature value changes across multiple domains.""" + + +class TemperatureCrossedThresholdTrigger( + _TemperatureTriggerMixin, EntityNumericalStateCrossedThresholdTriggerWithUnitBase +): + """Trigger for temperature value crossing a threshold across multiple domains.""" + + +TRIGGERS: dict[str, type[Trigger]] = { + "changed": TemperatureChangedTrigger, + "crossed_threshold": TemperatureCrossedThresholdTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for temperature.""" + return TRIGGERS diff --git a/homeassistant/components/temperature/triggers.yaml b/homeassistant/components/temperature/triggers.yaml new file mode 100644 index 00000000000..a0bdaf9d9ad --- /dev/null +++ b/homeassistant/components/temperature/triggers.yaml @@ -0,0 +1,77 @@ +.trigger_common_fields: + behavior: &trigger_behavior + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +.number_or_entity: &number_or_entity + 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 + +.trigger_threshold_type: &trigger_threshold_type + required: true + default: above + selector: + select: + options: + - above + - below + - between + - outside + translation_key: trigger_threshold_type + +.trigger_unit: &trigger_unit + required: false + selector: + select: + options: + - "°C" + - "°F" +.trigger_target: &trigger_target + entity: + - domain: sensor + device_class: temperature + - domain: climate + - domain: water_heater + - domain: weather + +changed: + target: *trigger_target + fields: + above: *number_or_entity + below: *number_or_entity + unit: *trigger_unit + +crossed_threshold: + target: *trigger_target + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity + upper_limit: *number_or_entity + unit: *trigger_unit diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index f928331b99a..f1b90051374 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -39,7 +39,7 @@ class DomainSpec: class NumericalDomainSpec(DomainSpec): """DomainSpec with an optional value converter for numerical triggers.""" - value_converter: Callable[[Any], float] | None = None + value_converter: Callable[[float], float] | None = None """Optional converter for numerical values (e.g. uint8 → percentage).""" diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 6e0d4af2a90..009234f2294 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -26,6 +26,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, CONF_ABOVE, CONF_ALIAS, CONF_BELOW, @@ -64,6 +65,7 @@ from homeassistant.loader import ( ) from homeassistant.util.async_ import create_eager_task 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, selector @@ -519,7 +521,7 @@ def _validate_range[_T: dict[str, Any]]( ) -> Callable[[_T], _T]: """Generate range validator.""" - def _validate_range(value: _T) -> _T: + def _validate_range_impl(value: _T) -> _T: above = value.get(lower_limit) below = value.get(upper_limit) @@ -539,7 +541,28 @@ def _validate_range[_T: dict[str, Any]]( return value - return _validate_range + 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 _NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema( @@ -576,38 +599,107 @@ NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( ) -def _get_numerical_value( - hass: HomeAssistant, entity_or_float: float | str -) -> float | None: - """Get numerical value from float or entity state.""" - if isinstance(entity_or_float, str): - if not (state := hass.states.get(entity_or_float)): - # Entity not found - return None - try: - return float(state.state) - except TypeError, ValueError: - # Entity state is not a valid number - return None - return entity_or_float - - class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]): """Base class for numerical state and state attribute triggers.""" - def _get_tracked_value(self, state: State) -> Any: + 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, str): + if not (state := self._hass.states.get(entity_or_float)): + # Entity not found + return None + try: + return float(state.state) + except TypeError, ValueError: + # Entity state is not a valid number + return None + return entity_or_float + + def _get_tracked_value(self, state: State) -> float | None: """Get the tracked numerical value from a state.""" domain_spec = self._domain_specs[state.domain] + raw_value: Any if domain_spec.value_source is None: - return state.state - return state.attributes.get(domain_spec.value_source) + raw_value = state.state + else: + raw_value = state.attributes.get(domain_spec.value_source) - def _get_converter(self, state: State) -> Callable[[Any], float]: + try: + return float(raw_value) + except TypeError, ValueError: + # Entity state is not a valid number + return None + + def _get_converter(self, state: State) -> Callable[[float], float]: """Get the value converter for an entity.""" domain_spec = self._domain_specs[state.domain] if domain_spec.value_converter is not None: return domain_spec.value_converter - return float + return lambda x: x + + +class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): + """Base class for numerical state and state attribute triggers.""" + + _base_unit: str # 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: TriggerConfig) -> None: + """Initialize the trigger.""" + super().__init__(hass, config) + self._manual_limit_unit = self._options.get(CONF_UNIT) + + def _get_entity_unit(self, state: State) -> str | None: + """Get the unit of an entity from its state.""" + return 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)): + # Entity not found + return None + try: + value = float(state.state) + except TypeError, ValueError: + # Entity state is not a valid number + return None + + try: + return self._unit_converter.convert( + value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit + ) + except HomeAssistantError: + # Unit conversion failed (i.e. incompatible units), treat as invalid number + return None + + def _get_tracked_value(self, state: State) -> float | None: + """Get the tracked numerical value from a state.""" + domain_spec = self._domain_specs[state.domain] + raw_value: Any + if domain_spec.value_source is None: + raw_value = state.state + else: + raw_value = state.attributes.get(domain_spec.value_source) + + try: + value = float(raw_value) + except TypeError, ValueError: + # Entity state is not a valid number + return None + + try: + return self._unit_converter.convert( + value, self._get_entity_unit(state), self._base_unit + ) + except HomeAssistantError: + # Unit conversion failed (i.e. incompatible units), treat as invalid number + return None class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase): @@ -629,7 +721,7 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase): if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return False - return self._get_tracked_value(from_state) != self._get_tracked_value(to_state) # type: ignore[no-any-return] + return self._get_tracked_value(from_state) != self._get_tracked_value(to_state) def is_valid_state(self, state: State) -> bool: """Check if the new state or state attribute matches the expected one.""" @@ -637,14 +729,10 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase): if (_attribute_value := self._get_tracked_value(state)) is None: return False - try: - current_value = self._get_converter(state)(_attribute_value) - except TypeError, ValueError: - # Value is not a valid number, don't trigger - return False + current_value = self._get_converter(state)(_attribute_value) if self._above is not None: - if (above := _get_numerical_value(self._hass, self._above)) is None: + if (above := self._get_numerical_value(self._above)) is None: # Entity not found or invalid number, don't trigger return False if current_value <= above: @@ -652,7 +740,7 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase): return False if self._below is not None: - if (below := _get_numerical_value(self._hass, self._below)) is None: + if (below := self._get_numerical_value(self._below)) is None: # Entity not found or invalid number, don't trigger return False if current_value >= below: @@ -662,6 +750,37 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase): return True +def make_numerical_state_changed_with_unit_schema( + unit_converter: type[BaseUnitConverter], +) -> vol.Schema: + """Factory for numerical state trigger schema with unit option.""" + return ENTITY_STATE_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_OPTIONS, default={}): vol.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), + }, + _validate_range(CONF_ABOVE, CONF_BELOW), + _validate_unit_set_if_range_numerical(CONF_ABOVE, CONF_BELOW), + ) + } + ) + + +class EntityNumericalStateChangedTriggerWithUnitBase( + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateTriggerWithUnitBase, +): + """Trigger for numerical state and state attribute changes.""" + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Create a schema.""" + super().__init_subclass__(**kwargs) + cls._schema = make_numerical_state_changed_with_unit_schema(cls._unit_converter) + + CONF_LOWER_LIMIT = "lower_limit" CONF_UPPER_LIMIT = "upper_limit" CONF_THRESHOLD_TYPE = "threshold_type" @@ -744,16 +863,12 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge def is_valid_state(self, state: State) -> bool: """Check if the new state attribute matches the expected one.""" if self._lower_limit is not None: - if ( - lower_limit := _get_numerical_value(self._hass, self._lower_limit) - ) is None: + if (lower_limit := self._get_numerical_value(self._lower_limit)) is None: # Entity not found or invalid number, don't trigger return False if self._upper_limit is not None: - if ( - upper_limit := _get_numerical_value(self._hass, self._upper_limit) - ) is None: + if (upper_limit := self._get_numerical_value(self._upper_limit)) is None: # Entity not found or invalid number, don't trigger return False @@ -761,11 +876,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge if (_attribute_value := self._get_tracked_value(state)) is None: return False - try: - current_value = self._get_converter(state)(_attribute_value) - except TypeError, ValueError: - # Value is not a valid number, don't trigger - return False + current_value = self._get_converter(state)(_attribute_value) # Note: We do not need to check for lower_limit/upper_limit being None here # because of the validation done in the schema. @@ -781,6 +892,50 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge return not between +def make_numerical_state_crossed_threshold_with_unit_schema( + unit_converter: type[BaseUnitConverter], +) -> vol.Schema: + """Trigger for numerical state and state attribute changes. + + This trigger only fires when the observed attribute changes from not within to within + the defined threshold. + """ + return ENTITY_STATE_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_OPTIONS, default={}): vol.All( + { + vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( + [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] + ), + vol.Optional(CONF_LOWER_LIMIT): _number_or_entity, + vol.Optional(CONF_UPPER_LIMIT): _number_or_entity, + vol.Required(CONF_THRESHOLD_TYPE): vol.Coerce(ThresholdType), + vol.Optional(CONF_UNIT): vol.In(unit_converter.VALID_UNITS), + }, + _validate_range(CONF_LOWER_LIMIT, CONF_UPPER_LIMIT), + _validate_limits_for_threshold_type, + _validate_unit_set_if_range_numerical( + CONF_LOWER_LIMIT, CONF_UPPER_LIMIT + ), + ) + } + ) + + +class EntityNumericalStateCrossedThresholdTriggerWithUnitBase( + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateTriggerWithUnitBase, +): + """Trigger for numerical state and state attribute changes.""" + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Create a schema.""" + super().__init_subclass__(**kwargs) + cls._schema = make_numerical_state_crossed_threshold_with_unit_schema( + cls._unit_converter + ) + + def _normalize_domain_specs( domain_specs: Mapping[str, DomainSpec] | str, ) -> Mapping[str, DomainSpec]: diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index fb241dfc73c..b971a3d20cb 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -120,6 +120,7 @@ NO_IOT_CLASS = [ "system_health", "system_log", "tag", + "temperature", "timer", "trace", "web_rtc", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 28c5067c6b4..109c70415c6 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2149,6 +2149,7 @@ NO_QUALITY_SCALE = [ "system_health", "system_log", "tag", + "temperature", "timer", "trace", "usage_prediction", diff --git a/tests/components/common.py b/tests/components/common.py index 45806423c18..b2cd6873457 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -518,54 +518,75 @@ def parametrize_trigger_states( def parametrize_numerical_attribute_changed_trigger_states( - trigger: str, state: str, attribute: str + trigger: str, + state: str, + attribute: str, + *, + trigger_options: dict[str, Any] | None = None, + required_filter_attributes: dict | None = None, + unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical changed triggers.""" + trigger_options = trigger_options or {} + unit_attributes = unit_attributes or {} + return [ *parametrize_trigger_states( trigger=trigger, - trigger_options={}, + trigger_options={**trigger_options}, target_states=[ - (state, {attribute: 0}), - (state, {attribute: 50}), - (state, {attribute: 100}), + (state, {attribute: 0} | unit_attributes), + (state, {attribute: 50} | unit_attributes), + (state, {attribute: 100} | unit_attributes), ], - other_states=[(state, {attribute: None})], + other_states=[(state, {attribute: None} | unit_attributes)], + required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, ), *parametrize_trigger_states( trigger=trigger, - trigger_options={CONF_ABOVE: 10}, + trigger_options={CONF_ABOVE: 10, **trigger_options}, target_states=[ - (state, {attribute: 50}), - (state, {attribute: 100}), + (state, {attribute: 50} | unit_attributes), + (state, {attribute: 100} | unit_attributes), ], other_states=[ - (state, {attribute: None}), - (state, {attribute: 0}), + (state, {attribute: None} | unit_attributes), + (state, {attribute: 0} | unit_attributes), ], + required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, ), *parametrize_trigger_states( trigger=trigger, - trigger_options={CONF_BELOW: 90}, + trigger_options={CONF_BELOW: 90, **trigger_options}, target_states=[ - (state, {attribute: 0}), - (state, {attribute: 50}), + (state, {attribute: 0} | unit_attributes), + (state, {attribute: 50} | unit_attributes), ], other_states=[ - (state, {attribute: None}), - (state, {attribute: 100}), + (state, {attribute: None} | unit_attributes), + (state, {attribute: 100} | unit_attributes), ], + required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, ), ] def parametrize_numerical_attribute_crossed_threshold_trigger_states( - trigger: str, state: str, attribute: str + trigger: str, + state: str, + attribute: str, + *, + trigger_options: dict[str, Any] | None = None, + required_filter_attributes: dict | None = None, + unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical crossed threshold triggers.""" + trigger_options = trigger_options or {} + unit_attributes = unit_attributes or {} + return [ *parametrize_trigger_states( trigger=trigger, @@ -573,16 +594,18 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: 90, + **trigger_options, }, target_states=[ - (state, {attribute: 50}), - (state, {attribute: 60}), + (state, {attribute: 50} | unit_attributes), + (state, {attribute: 60} | unit_attributes), ], other_states=[ - (state, {attribute: None}), - (state, {attribute: 0}), - (state, {attribute: 100}), + (state, {attribute: None} | unit_attributes), + (state, {attribute: 0} | unit_attributes), + (state, {attribute: 100} | unit_attributes), ], + required_filter_attributes=required_filter_attributes, ), *parametrize_trigger_states( trigger=trigger, @@ -590,52 +613,62 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: 90, + **trigger_options, }, target_states=[ - (state, {attribute: 0}), - (state, {attribute: 100}), + (state, {attribute: 0} | unit_attributes), + (state, {attribute: 100} | unit_attributes), ], other_states=[ - (state, {attribute: None}), - (state, {attribute: 50}), - (state, {attribute: 60}), + (state, {attribute: None} | unit_attributes), + (state, {attribute: 50} | unit_attributes), + (state, {attribute: 60} | unit_attributes), ], + required_filter_attributes=required_filter_attributes, ), *parametrize_trigger_states( trigger=trigger, trigger_options={ CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: 10, + **trigger_options, }, target_states=[ - (state, {attribute: 50}), - (state, {attribute: 100}), + (state, {attribute: 50} | unit_attributes), + (state, {attribute: 100} | unit_attributes), ], other_states=[ - (state, {attribute: None}), - (state, {attribute: 0}), + (state, {attribute: None} | unit_attributes), + (state, {attribute: 0} | unit_attributes), ], + required_filter_attributes=required_filter_attributes, ), *parametrize_trigger_states( trigger=trigger, trigger_options={ CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: 90, + **trigger_options, }, target_states=[ - (state, {attribute: 0}), - (state, {attribute: 50}), + (state, {attribute: 0} | unit_attributes), + (state, {attribute: 50} | unit_attributes), ], other_states=[ - (state, {attribute: None}), - (state, {attribute: 100}), + (state, {attribute: None} | unit_attributes), + (state, {attribute: 100} | unit_attributes), ], + required_filter_attributes=required_filter_attributes, ), ] def parametrize_numerical_state_value_changed_trigger_states( - trigger: str, device_class: str + trigger: str, + *, + device_class: str, + trigger_options: dict[str, Any] | None = None, + unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical state-value changed triggers. @@ -646,30 +679,37 @@ def parametrize_numerical_state_value_changed_trigger_states( from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415 required_filter_attributes = {ATTR_DEVICE_CLASS: device_class} + trigger_options = trigger_options or {} + unit_attributes = unit_attributes or {} + return [ *parametrize_trigger_states( trigger=trigger, - trigger_options={}, - target_states=["0", "50", "100"], - other_states=["none"], + trigger_options=trigger_options, + target_states=[ + ("0", unit_attributes), + ("50", unit_attributes), + ("100", unit_attributes), + ], + other_states=[("none", unit_attributes)], required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, trigger_from_none=False, ), *parametrize_trigger_states( trigger=trigger, - trigger_options={CONF_ABOVE: 10}, - target_states=["50", "100"], - other_states=["none", "0"], + trigger_options={CONF_ABOVE: 10} | trigger_options, + target_states=[("50", unit_attributes), ("100", unit_attributes)], + other_states=[("none", unit_attributes), ("0", unit_attributes)], required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, trigger_from_none=False, ), *parametrize_trigger_states( trigger=trigger, - trigger_options={CONF_BELOW: 90}, - target_states=["0", "50"], - other_states=["none", "100"], + trigger_options={CONF_BELOW: 90} | trigger_options, + target_states=[("0", unit_attributes), ("50", unit_attributes)], + other_states=[("none", unit_attributes), ("100", unit_attributes)], required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, trigger_from_none=False, @@ -678,7 +718,11 @@ def parametrize_numerical_state_value_changed_trigger_states( def parametrize_numerical_state_value_crossed_threshold_trigger_states( - trigger: str, device_class: str + trigger: str, + *, + device_class: str, + trigger_options: dict[str, Any] | None = None, + unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical state-value crossed threshold triggers. @@ -689,6 +733,9 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states( from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415 required_filter_attributes = {ATTR_DEVICE_CLASS: device_class} + trigger_options = trigger_options or {} + unit_attributes = unit_attributes or {} + return [ *parametrize_trigger_states( trigger=trigger, @@ -696,9 +743,14 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states( CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: 90, + **trigger_options, }, - target_states=["50", "60"], - other_states=["none", "0", "100"], + target_states=[("50", unit_attributes), ("60", unit_attributes)], + other_states=[ + ("none", unit_attributes), + ("0", unit_attributes), + ("100", unit_attributes), + ], required_filter_attributes=required_filter_attributes, trigger_from_none=False, ), @@ -708,9 +760,14 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states( CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: 90, + **trigger_options, }, - target_states=["0", "100"], - other_states=["none", "50", "60"], + target_states=[("0", unit_attributes), ("100", unit_attributes)], + other_states=[ + ("none", unit_attributes), + ("50", unit_attributes), + ("60", unit_attributes), + ], required_filter_attributes=required_filter_attributes, trigger_from_none=False, ), @@ -719,9 +776,10 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states( trigger_options={ CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: 10, + **trigger_options, }, - target_states=["50", "100"], - other_states=["none", "0"], + target_states=[("50", unit_attributes), ("100", unit_attributes)], + other_states=[("none", unit_attributes), ("0", unit_attributes)], required_filter_attributes=required_filter_attributes, trigger_from_none=False, ), @@ -730,9 +788,10 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states( trigger_options={ CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: 90, + **trigger_options, }, - target_states=["0", "50"], - other_states=["none", "100"], + target_states=[("0", unit_attributes), ("50", unit_attributes)], + other_states=[("none", unit_attributes), ("100", unit_attributes)], required_filter_attributes=required_filter_attributes, trigger_from_none=False, ), diff --git a/tests/components/humidity/test_trigger.py b/tests/components/humidity/test_trigger.py index 9f7d0d16552..1b4a1e6789f 100644 --- a/tests/components/humidity/test_trigger.py +++ b/tests/components/humidity/test_trigger.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall @@ -81,10 +82,10 @@ async def test_humidity_triggers_gated_by_labs_flag( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_state_value_changed_trigger_states( - "humidity.changed", "humidity" + "humidity.changed", device_class=SensorDeviceClass.HUMIDITY ), *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "humidity.crossed_threshold", "humidity" + "humidity.crossed_threshold", device_class=SensorDeviceClass.HUMIDITY ), ], ) @@ -122,7 +123,7 @@ async def test_humidity_trigger_sensor_behavior_any( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "humidity.crossed_threshold", "humidity" + "humidity.crossed_threshold", device_class=SensorDeviceClass.HUMIDITY ), ], ) @@ -160,7 +161,7 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_first( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "humidity.crossed_threshold", "humidity" + "humidity.crossed_threshold", device_class=SensorDeviceClass.HUMIDITY ), ], ) diff --git a/tests/components/temperature/__init__.py b/tests/components/temperature/__init__.py new file mode 100644 index 00000000000..a8811188a9a --- /dev/null +++ b/tests/components/temperature/__init__.py @@ -0,0 +1 @@ +"""Tests for the temperature integration.""" diff --git a/tests/components/temperature/test_trigger.py b/tests/components/temperature/test_trigger.py new file mode 100644 index 00000000000..c0ae4b4b480 --- /dev/null +++ b/tests/components/temperature/test_trigger.py @@ -0,0 +1,931 @@ +"""Test temperature trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, + HVACMode, +) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.water_heater import ( + ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, +) +from homeassistant.components.weather import ( + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components.common import ( + TriggerStateDescription, + arm_trigger, + assert_trigger_behavior_any, + assert_trigger_behavior_first, + assert_trigger_behavior_last, + assert_trigger_gated_by_labs_flag, + parametrize_numerical_attribute_changed_trigger_states, + parametrize_numerical_attribute_crossed_threshold_trigger_states, + parametrize_numerical_state_value_changed_trigger_states, + parametrize_numerical_state_value_crossed_threshold_trigger_states, + parametrize_target_entities, + target_entities, +) + +_TEMPERATURE_TRIGGER_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} +_SENSOR_UNIT_ATTRIBUTES = { + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, +} +_WEATHER_UNIT_ATTRIBUTES = { + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.CELSIUS, +} + + +@pytest.fixture +async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple sensor entities associated with different targets.""" + return await target_entities(hass, "sensor") + + +@pytest.fixture +async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple climate entities associated with different targets.""" + return await target_entities(hass, "climate") + + +@pytest.fixture +async def target_water_heaters(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple water_heater entities associated with different targets.""" + return await target_entities(hass, "water_heater") + + +@pytest.fixture +async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple weather entities associated with different targets.""" + return await target_entities(hass, "weather") + + +@pytest.mark.parametrize( + "trigger_key", + [ + "temperature.changed", + "temperature.crossed_threshold", + ], +) +async def test_temperature_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the temperature triggers are gated by the labs flag.""" + await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) + + +# --- Sensor domain tests (value in state.state) --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_changed_trigger_states( + "temperature.changed", + device_class=SensorDeviceClass.TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_SENSOR_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + device_class=SensorDeviceClass.TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_SENSOR_UNIT_ATTRIBUTES, + ), + ], +) +async def test_temperature_trigger_sensor_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature trigger fires for sensor entities with device_class temperature.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + device_class=SensorDeviceClass.TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_SENSOR_UNIT_ATTRIBUTES, + ), + ], +) +async def test_temperature_trigger_sensor_crossed_threshold_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires on the first sensor state change.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + device_class=SensorDeviceClass.TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_SENSOR_UNIT_ATTRIBUTES, + ), + ], +) +async def test_temperature_trigger_sensor_crossed_threshold_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires when the last sensor changes state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +# --- Climate domain tests (value in current_temperature attribute) --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("climate"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_changed_trigger_states( + "temperature.changed", + HVACMode.AUTO, + CLIMATE_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + HVACMode.AUTO, + CLIMATE_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_temperature_trigger_climate_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_climates: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature trigger fires for climate entities.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_climates, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("climate"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + HVACMode.AUTO, + CLIMATE_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_temperature_trigger_climate_crossed_threshold_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_climates: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires on the first climate state change.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_climates, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("climate"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + HVACMode.AUTO, + CLIMATE_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_temperature_trigger_climate_crossed_threshold_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_climates: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires when the last climate changes state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_climates, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +# --- Water heater domain tests (value in current_temperature attribute) --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_changed_trigger_states( + "temperature.changed", + "eco", + WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + "eco", + WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_temperature_trigger_water_heater_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature trigger fires for water_heater entities.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + "eco", + WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires on the first water_heater state change.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + "eco", + WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires when the last water_heater changes state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +# --- Weather domain tests (value in temperature attribute) --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("weather"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_changed_trigger_states( + "temperature.changed", + "sunny", + ATTR_WEATHER_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + ), + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + "sunny", + ATTR_WEATHER_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_temperature_trigger_weather_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_weathers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature trigger fires for weather entities.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_weathers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("weather"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + "sunny", + ATTR_WEATHER_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_temperature_trigger_weather_crossed_threshold_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_weathers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires on the first weather state change.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_weathers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("weather"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "temperature.crossed_threshold", + "sunny", + ATTR_WEATHER_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + ), + ], +) +async def test_temperature_trigger_weather_crossed_threshold_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_weathers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test temperature crossed_threshold trigger fires when the last weather changes state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_weathers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +# --- Device class exclusion test --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ( + "trigger_key", + "trigger_options", + "sensor_initial", + "sensor_target", + ), + [ + ( + "temperature.changed", + {}, + "20", + "25", + ), + ( + "temperature.crossed_threshold", + {"threshold_type": "above", "lower_limit": 10, "unit": "°C"}, + "5", + "20", + ), + ], +) +async def test_temperature_trigger_excludes_non_temperature_sensor( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + trigger_options: dict[str, Any], + sensor_initial: str, + sensor_target: str, +) -> None: + """Test temperature trigger does not fire for sensor entities without device_class temperature.""" + entity_id_temperature = "sensor.test_temperature" + entity_id_humidity = "sensor.test_humidity" + + temp_attrs = { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + } + humidity_attrs = {ATTR_DEVICE_CLASS: "humidity"} + + # Set initial states + hass.states.async_set(entity_id_temperature, sensor_initial, temp_attrs) + hass.states.async_set(entity_id_humidity, sensor_initial, humidity_attrs) + await hass.async_block_till_done() + + await arm_trigger( + hass, + trigger_key, + trigger_options, + { + CONF_ENTITY_ID: [ + entity_id_temperature, + entity_id_humidity, + ] + }, + ) + + # Temperature sensor changes - should trigger + hass.states.async_set(entity_id_temperature, sensor_target, temp_attrs) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_temperature + service_calls.clear() + + # Humidity sensor changes - should NOT trigger (wrong device class) + hass.states.async_set(entity_id_humidity, sensor_target, humidity_attrs) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +# --- Unit conversion tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( + hass: HomeAssistant, + service_calls: list[ServiceCall], +) -> None: + """Test temperature trigger converts sensor value from °C to °F for threshold comparison.""" + entity_id = "sensor.test_temp" + + # Sensor reports in °C, trigger configured in °F with threshold above 70°F + hass.states.async_set( + entity_id, + "20", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "temperature.crossed_threshold", + { + "threshold_type": "above", + "lower_limit": 70, + "unit": "°F", + }, + {CONF_ENTITY_ID: [entity_id]}, + ) + + # 20°C = 68°F, which is below 70°F - should NOT trigger + hass.states.async_set( + entity_id, + "20", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # 22°C = 71.6°F, which is above 70°F - should trigger + hass.states.async_set( + entity_id, + "22", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( + hass: HomeAssistant, + service_calls: list[ServiceCall], +) -> None: + """Test temperature trigger converts sensor value from °F to °C for threshold comparison.""" + entity_id = "sensor.test_temp" + + # Sensor reports in °F, trigger configured in °C with threshold above 25°C + hass.states.async_set( + entity_id, + "70", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "temperature.crossed_threshold", + { + "threshold_type": "above", + "lower_limit": 25, + "unit": "°C", + }, + {CONF_ENTITY_ID: [entity_id]}, + ) + + # 70°F = 21.1°C, which is below 25°C - should NOT trigger + hass.states.async_set( + entity_id, + "70", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # 80°F = 26.7°C, which is above 25°C - should trigger + hass.states.async_set( + entity_id, + "80", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_temperature_trigger_unit_conversion_changed( + hass: HomeAssistant, + service_calls: list[ServiceCall], +) -> None: + """Test temperature changed trigger with unit conversion and above/below limits.""" + entity_id = "sensor.test_temp" + + # Sensor reports in °C, trigger configured in °F: above 68°F (20°C), below 77°F (25°C) + hass.states.async_set( + entity_id, + "18", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "temperature.changed", + { + "above": 68, + "below": 77, + "unit": "°F", + }, + {CONF_ENTITY_ID: [entity_id]}, + ) + + # 18°C = 64.4°F, below 68°F - should NOT trigger + hass.states.async_set( + entity_id, + "19", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # 22°C = 71.6°F, between 68°F and 77°F - should trigger + hass.states.async_set( + entity_id, + "22", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() + + # 26°C = 78.8°F, above 77°F - should NOT trigger + hass.states.async_set( + entity_id, + "26", + { + ATTR_DEVICE_CLASS: "temperature", + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_temperature_trigger_unit_conversion_weather( + hass: HomeAssistant, + service_calls: list[ServiceCall], +) -> None: + """Test temperature trigger with unit conversion for weather entities.""" + entity_id = "weather.test" + + # Weather reports temperature in °F, trigger configured in °C with threshold above 25°C + hass.states.async_set( + entity_id, + "sunny", + { + ATTR_WEATHER_TEMPERATURE: 70, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "temperature.crossed_threshold", + { + "threshold_type": "above", + "lower_limit": 25, + "unit": "°C", + }, + {CONF_ENTITY_ID: [entity_id]}, + ) + + # 70°F = 21.1°C, below 25°C - should NOT trigger + hass.states.async_set( + entity_id, + "sunny", + { + ATTR_WEATHER_TEMPERATURE: 70, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # 80°F = 26.7°C, above 25°C - should trigger + hass.states.async_set( + entity_id, + "sunny", + { + ATTR_WEATHER_TEMPERATURE: 80, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index a0905cd8267..ddb45ba90a1 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -17,6 +17,7 @@ from homeassistant.components.tag import DOMAIN as TAG_DOMAIN from homeassistant.components.text import DOMAIN as TEXT_DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, CONF_ABOVE, CONF_BELOW, CONF_ENTITY_ID, @@ -25,6 +26,7 @@ from homeassistant.const import ( CONF_TARGET, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfTemperature, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -45,8 +47,11 @@ from homeassistant.helpers.automation import ( from homeassistant.helpers.trigger import ( CONF_LOWER_LIMIT, CONF_THRESHOLD_TYPE, + CONF_UNIT, CONF_UPPER_LIMIT, DATA_PLUGGABLE_ACTIONS, + EntityNumericalStateChangedTriggerWithUnitBase, + EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityTriggerBase, PluggableAction, Trigger, @@ -64,6 +69,7 @@ from homeassistant.helpers.trigger import ( from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.yaml.loader import parse_yaml from tests.common import MockModule, MockPlatform, mock_integration, mock_platform @@ -1174,31 +1180,38 @@ async def test_subscribe_triggers_no_triggers( ("trigger_options", "expected_result"), [ # Test validating climate.target_temperature_changed - # Valid configurations + # Valid: no limits at all ( {}, does_not_raise(), ), + # Valid: numerical limits ( {CONF_ABOVE: 10}, does_not_raise(), ), - ( - {CONF_ABOVE: "sensor.test"}, - does_not_raise(), - ), ( {CONF_BELOW: 90}, does_not_raise(), ), + ( + {CONF_ABOVE: 10, CONF_BELOW: 90}, + does_not_raise(), + ), + # Valid: entity references + ( + {CONF_ABOVE: "sensor.test"}, + does_not_raise(), + ), ( {CONF_BELOW: "sensor.test"}, does_not_raise(), ), ( - {CONF_ABOVE: 10, CONF_BELOW: 90}, + {CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"}, does_not_raise(), ), + # Valid: Mix of numerical limits and entity references ( {CONF_ABOVE: "sensor.test", CONF_BELOW: 90}, does_not_raise(), @@ -1207,10 +1220,6 @@ async def test_subscribe_triggers_no_triggers( {CONF_ABOVE: 10, CONF_BELOW: "sensor.test"}, does_not_raise(), ), - ( - {CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"}, - does_not_raise(), - ), # Test verbose choose selector options ( {CONF_ABOVE: {"active_choice": "entity", "entity": "sensor.test"}}, @@ -1276,6 +1285,147 @@ async def test_numerical_state_attribute_changed_trigger_config_validation( ) +def _make_with_unit_changed_trigger_class() -> type[ + EntityNumericalStateChangedTriggerWithUnitBase +]: + """Create a concrete WithUnit changed trigger class for testing.""" + + class _TestChangedTrigger( + EntityNumericalStateChangedTriggerWithUnitBase, + ): + _base_unit = UnitOfTemperature.CELSIUS + _domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")} + _unit_converter = TemperatureConverter + + return _TestChangedTrigger + + +@pytest.mark.parametrize( + ("trigger_options", "expected_result"), + [ + # Valid: no limits at all + ( + {}, + does_not_raise(), + ), + # Valid: unit provided with numerical limits + ( + {CONF_ABOVE: 10, CONF_UNIT: UnitOfTemperature.CELSIUS}, + does_not_raise(), + ), + ( + {CONF_BELOW: 90, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, + does_not_raise(), + ), + ( + { + CONF_ABOVE: 10, + CONF_BELOW: 90, + CONF_UNIT: UnitOfTemperature.CELSIUS, + }, + does_not_raise(), + ), + # Valid: no unit needed when using entity references + ( + {CONF_ABOVE: "sensor.test"}, + does_not_raise(), + ), + ( + {CONF_BELOW: "sensor.test"}, + does_not_raise(), + ), + ( + {CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"}, + does_not_raise(), + ), + # Valid: unit only needed for numerical limits, not entity references + ( + { + CONF_ABOVE: "sensor.test", + CONF_BELOW: 90, + CONF_UNIT: UnitOfTemperature.CELSIUS, + }, + does_not_raise(), + ), + ( + { + CONF_ABOVE: 10, + CONF_BELOW: "sensor.test", + CONF_UNIT: UnitOfTemperature.CELSIUS, + }, + does_not_raise(), + ), + # Invalid: numerical limit without unit + ( + {CONF_ABOVE: 10}, + pytest.raises(vol.Invalid), + ), + ( + {CONF_BELOW: 90}, + pytest.raises(vol.Invalid), + ), + ( + {CONF_ABOVE: 10, CONF_BELOW: 90}, + pytest.raises(vol.Invalid), + ), + # Invalid: one numerical limit without unit (other is entity) + ( + {CONF_ABOVE: 10, CONF_BELOW: "sensor.test"}, + pytest.raises(vol.Invalid), + ), + ( + {CONF_ABOVE: "sensor.test", CONF_BELOW: 90}, + pytest.raises(vol.Invalid), + ), + # Invalid: invalid unit value + ( + {CONF_ABOVE: 10, CONF_UNIT: "invalid_unit"}, + pytest.raises(vol.Invalid), + ), + # Invalid: Must use valid entity id + ( + {CONF_ABOVE: "cat", CONF_BELOW: "dog"}, + pytest.raises(vol.Invalid), + ), + # Invalid: above must be smaller than below + ( + {CONF_ABOVE: 90, CONF_BELOW: 10, CONF_UNIT: UnitOfTemperature.CELSIUS}, + pytest.raises(vol.Invalid), + ), + # Invalid: invalid choose selector option + ( + {CONF_BELOW: {"active_choice": "cat", "cat": 90}}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_numerical_state_attribute_changed_with_unit_trigger_config_validation( + hass: HomeAssistant, + trigger_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test numerical state attribute change with unit trigger config validation.""" + trigger_cls = _make_with_unit_changed_trigger_class() + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return {"test_trigger": trigger_cls} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + with expected_result: + await async_validate_trigger_config( + hass, + [ + { + "platform": "test.test_trigger", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + CONF_OPTIONS: trigger_options, + } + ], + ) + + async def test_numerical_state_attribute_changed_error_handling( hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: @@ -1389,6 +1539,302 @@ async def test_numerical_state_attribute_changed_error_handling( assert len(service_calls) == 0 +async def test_numerical_state_attribute_changed_with_unit_error_handling( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test numerical state attribute change with unit conversion error handling.""" + trigger_cls = _make_with_unit_changed_trigger_class() + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return {"attribute_changed": trigger_cls} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + # Entity reports in °F, trigger configured in °C with above 20°C, below 30°C + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 68, # 68°F = 20°C + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "test.attribute_changed", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + CONF_OPTIONS: { + CONF_ABOVE: 20, + CONF_BELOW: 30, + CONF_UNIT: UnitOfTemperature.CELSIUS, + }, + }, + "action": { + "service": "test.numerical_automation", + "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, + }, + }, + { + "trigger": { + CONF_PLATFORM: "test.attribute_changed", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + CONF_OPTIONS: { + CONF_ABOVE: "sensor.above", + CONF_BELOW: "sensor.below", + }, + }, + "action": { + "service": "test.entity_automation", + "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, + }, + }, + ] + }, + ) + + assert len(service_calls) == 0 + + # 77°F = 25°C, within range (above 20, below 30) - should trigger numerical + # Entity automation won't trigger because sensor.above/below don't exist yet + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 77, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].service == "numerical_automation" + service_calls.clear() + + # 59°F = 15°C, below 20°C - should NOT trigger + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 59, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # 95°F = 35°C, above 30°C - should NOT trigger + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 95, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Set up entity limits referencing sensors that report in °F + hass.states.async_set( + "sensor.above", + "68", # 68°F = 20°C + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + hass.states.async_set( + "sensor.below", + "86", # 86°F = 30°C + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + + # 77°F = 25°C, between 20°C and 30°C - should trigger both automations + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 77, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 2 + assert {call.service for call in service_calls} == { + "numerical_automation", + "entity_automation", + } + service_calls.clear() + + # Test the trigger does not fire when the attribute value is missing + hass.states.async_set("test.test_entity", "on", {}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the attribute value is invalid + for value in ("cat", None): + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": value, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the unit is incompatible + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 50, + ATTR_UNIT_OF_MEASUREMENT: "invalid_unit", + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the above sensor does not exist + hass.states.async_remove("sensor.above") + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": None, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + hass.states.async_set( + "test.test_entity", + "on", + {"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the above sensor state is not numeric + for invalid_value in ("cat", None): + hass.states.async_set( + "sensor.above", + invalid_value, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": None, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 50, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the above sensor's unit is incompatible + hass.states.async_set( + "sensor.above", + "68", # 68°F = 20°C + {ATTR_UNIT_OF_MEASUREMENT: "invalid_unit"}, + ) + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": None, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + hass.states.async_set( + "test.test_entity", + "on", + {"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Reset the above sensor state to a valid numeric value + hass.states.async_set( + "sensor.above", + "68", # 68°F = 20°C + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + + # Test the trigger does not fire when the below sensor does not exist + hass.states.async_remove("sensor.below") + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": None, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + hass.states.async_set( + "test.test_entity", + "on", + {"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the below sensor state is not numeric + for invalid_value in ("cat", None): + hass.states.async_set("sensor.below", invalid_value) + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": None, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 50, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the below sensor's unit is incompatible + hass.states.async_set( + "sensor.below", + "68", # 68°F = 20°C + {ATTR_UNIT_OF_MEASUREMENT: "invalid_unit"}, + ) + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": None, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + hass.states.async_set( + "test.test_entity", + "on", + {"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + @pytest.mark.parametrize( ("trigger_options", "expected_result"), [ @@ -1586,6 +2032,367 @@ async def test_numerical_state_attribute_crossed_threshold_trigger_config_valida ) +def _make_with_unit_crossed_threshold_trigger_class() -> type[ + EntityNumericalStateCrossedThresholdTriggerWithUnitBase +]: + """Create a concrete WithUnit crossed threshold trigger class for testing.""" + + class _TestCrossedThresholdTrigger( + EntityNumericalStateCrossedThresholdTriggerWithUnitBase, + ): + _base_unit = UnitOfTemperature.CELSIUS + _domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")} + _unit_converter = TemperatureConverter + + return _TestCrossedThresholdTrigger + + +@pytest.mark.parametrize( + ("trigger_options", "expected_result"), + [ + # Valid: unit provided with numerical limits + ( + { + CONF_THRESHOLD_TYPE: "above", + CONF_LOWER_LIMIT: 10, + CONF_UNIT: UnitOfTemperature.CELSIUS, + }, + does_not_raise(), + ), + ( + { + CONF_THRESHOLD_TYPE: "below", + CONF_UPPER_LIMIT: 90, + CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + does_not_raise(), + ), + ( + { + CONF_THRESHOLD_TYPE: "between", + CONF_LOWER_LIMIT: 10, + CONF_UPPER_LIMIT: 90, + CONF_UNIT: UnitOfTemperature.CELSIUS, + }, + does_not_raise(), + ), + # Valid: no unit needed when using entity references + ( + { + CONF_THRESHOLD_TYPE: "above", + CONF_LOWER_LIMIT: "sensor.test", + }, + does_not_raise(), + ), + ( + { + CONF_THRESHOLD_TYPE: "between", + CONF_LOWER_LIMIT: "sensor.test", + CONF_UPPER_LIMIT: "sensor.test", + }, + does_not_raise(), + ), + # Invalid: numerical limit without unit + ( + {CONF_THRESHOLD_TYPE: "above", CONF_LOWER_LIMIT: 10}, + pytest.raises(vol.Invalid), + ), + ( + { + CONF_THRESHOLD_TYPE: "between", + CONF_LOWER_LIMIT: 10, + CONF_UPPER_LIMIT: 90, + }, + pytest.raises(vol.Invalid), + ), + # Invalid: one numerical limit without unit (other is entity) + ( + { + CONF_THRESHOLD_TYPE: "between", + CONF_LOWER_LIMIT: 10, + CONF_UPPER_LIMIT: "sensor.test", + }, + pytest.raises(vol.Invalid), + ), + # Invalid: invalid unit value + ( + { + CONF_THRESHOLD_TYPE: "above", + CONF_LOWER_LIMIT: 10, + CONF_UNIT: "invalid_unit", + }, + pytest.raises(vol.Invalid), + ), + # Invalid: missing threshold type (shared validation) + ( + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_numerical_state_attribute_crossed_threshold_with_unit_trigger_config_validation( + hass: HomeAssistant, + trigger_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test numerical state attribute crossed threshold with unit trigger config validation.""" + trigger_cls = _make_with_unit_crossed_threshold_trigger_class() + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return {"test_trigger": trigger_cls} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + with expected_result: + await async_validate_trigger_config( + hass, + [ + { + "platform": "test.test_trigger", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + CONF_OPTIONS: trigger_options, + } + ], + ) + + +async def test_numerical_state_attribute_crossed_threshold_error_handling( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test numerical state attribute crossed threshold error handling.""" + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return { + "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {"test": NumericalDomainSpec(value_source="test_attribute")} + ), + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + hass.states.async_set("test.test_entity", "on", {"test_attribute": 0}) + + options = { + CONF_OPTIONS: { + CONF_THRESHOLD_TYPE: "between", + CONF_LOWER_LIMIT: "sensor.lower", + CONF_UPPER_LIMIT: "sensor.upper", + }, + } + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: "test.crossed_threshold", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + } + | options, + "action": { + "service": "test.automation", + "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, + }, + } + }, + ) + + assert len(service_calls) == 0 + + # Test the trigger works + hass.states.async_set("sensor.lower", "10") + hass.states.async_set("sensor.upper", "90") + hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() + + # Test the trigger does not fire again when still within limits + hass.states.async_set("test.test_entity", "on", {"test_attribute": 51}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + service_calls.clear() + + # Test the trigger does not fire when the from-state is unknown or unavailable + for from_state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + hass.states.async_set("test.test_entity", from_state) + hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does fire when the attribute value is changing from None + hass.states.async_set("test.test_entity", "on", {"test_attribute": None}) + hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() + + # Test the trigger does not fire when the attribute value is outside the limits + for value in (5, 95): + hass.states.async_set("test.test_entity", "on", {"test_attribute": value}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the attribute value is missing + hass.states.async_set("test.test_entity", "on", {}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the attribute value is invalid + for value in ("cat", None): + hass.states.async_set("test.test_entity", "on", {"test_attribute": value}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the lower sensor does not exist + hass.states.async_remove("sensor.lower") + hass.states.async_set("test.test_entity", "on", {"test_attribute": None}) + hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the lower sensor state is not numeric + for invalid_value in ("cat", None): + hass.states.async_set("sensor.lower", invalid_value) + hass.states.async_set("test.test_entity", "on", {"test_attribute": None}) + hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Reset the lower sensor state to a valid numeric value + hass.states.async_set("sensor.lower", "10") + + # Test the trigger does not fire when the upper sensor does not exist + hass.states.async_remove("sensor.upper") + hass.states.async_set("test.test_entity", "on", {"test_attribute": None}) + hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Test the trigger does not fire when the upper sensor state is not numeric + for invalid_value in ("cat", None): + hass.states.async_set("sensor.upper", invalid_value) + hass.states.async_set("test.test_entity", "on", {"test_attribute": None}) + hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +async def test_numerical_state_attribute_crossed_threshold_with_unit_error_handling( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test numerical state attribute crossed threshold with unit conversion.""" + trigger_cls = _make_with_unit_crossed_threshold_trigger_class() + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return {"crossed_threshold": trigger_cls} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + # Entity reports in °F, trigger configured in °C: above 25°C + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 68, # 68°F = 20°C, below threshold + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + + options = { + CONF_OPTIONS: { + CONF_THRESHOLD_TYPE: "above", + CONF_LOWER_LIMIT: 25, + CONF_UNIT: UnitOfTemperature.CELSIUS, + }, + } + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: "test.crossed_threshold", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + } + | options, + "action": { + "service": "test.automation", + "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, + }, + } + }, + ) + + assert len(service_calls) == 0 + + # 80.6°F = 27°C, above 25°C threshold - should trigger + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 80.6, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() + + # Still above threshold - should NOT trigger (already crossed) + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 82, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Drop below threshold and cross again + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 68, # 20°C, below 25°C + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 80.6, # 27°C, above 25°C again + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + service_calls.clear() + + # Test with incompatible unit - should NOT trigger + hass.states.async_set( + "test.test_entity", + "on", + { + "test_attribute": 50, + ATTR_UNIT_OF_MEASUREMENT: "invalid_unit", + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + def _make_trigger( hass: HomeAssistant, domain_specs: Mapping[str, DomainSpec] ) -> EntityTriggerBase: diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index e93ccbc23d6..78384913587 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -86,6 +86,7 @@ 'system_health', 'system_log', 'tag', + 'temperature', 'text', 'time', 'timer', @@ -190,6 +191,7 @@ 'system_health', 'system_log', 'tag', + 'temperature', 'text', 'time', 'timer',