Files
core/homeassistant/helpers/trigger/entity_trigger.py
T

1093 lines
39 KiB
Python

"""Entity state trigger helpers."""
from collections.abc import Iterable, Mapping
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, cast, override
import probatio
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_FOR,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityStateAttribute,
)
from homeassistant.core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
State,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import (
DomainSpec,
ThresholdConfig,
filter_by_domain_specs,
)
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.selector import (
NumericThresholdMode,
NumericThresholdSelector,
NumericThresholdSelectorConfig,
NumericThresholdType,
)
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from homeassistant.util.unit_conversion import BaseUnitConverter
from .models import (
NotTriggeredInfo,
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
)
ATTR_BEHAVIOR: Final = "behavior"
BEHAVIOR_FIRST: Final = "first"
BEHAVIOR_ALL: Final = "all"
BEHAVIOR_EACH: Final = "each"
def _create_deprecated_behavior_issue(deprecated: str, replacement: str) -> None:
"""Inform the user a renamed trigger behavior value is still in use."""
# Returns None when called from the wrong thread or before hass is set up
# (e.g. a `check_config` run), in which case there's nothing to report to.
if (hass := async_get_hass_or_none()) is None:
return
from homeassistant.helpers.issue_registry import ( # noqa: PLC0415
IssueSeverity,
async_create_issue,
)
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_trigger_behavior_{deprecated}",
breaks_in_ha_version="2027.1",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_trigger_behavior",
translation_placeholders={
"deprecated_behavior": deprecated,
"new_behavior": replacement,
},
)
def _backwards_compatible_behavior(value: Any) -> Any:
"""Convert legacy behavior values to new ones."""
if value == "any":
_create_deprecated_behavior_issue("any", BEHAVIOR_EACH)
return BEHAVIOR_EACH
if value == "last":
_create_deprecated_behavior_issue("last", BEHAVIOR_ALL)
return BEHAVIOR_ALL
return value
ENTITY_STATE_TRIGGER_SCHEMA = probatio.Schema(
{
probatio.Required(CONF_TARGET): cv.TARGET_FIELDS,
probatio.Required(CONF_OPTIONS, default={}): {},
}
)
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
probatio.Required(CONF_OPTIONS, default={}): {
probatio.Required(ATTR_BEHAVIOR, default=BEHAVIOR_EACH): probatio.All(
_backwards_compatible_behavior,
probatio.In([BEHAVIOR_FIRST, BEHAVIOR_ALL, BEHAVIOR_EACH]),
),
probatio.Optional(CONF_FOR): cv.positive_time_period,
},
}
)
class NotTriggeredReasonReporter(Protocol):
"""Reports why an evaluated change did not fire an entity trigger."""
def __call__(self, reason: str, /, **data: Any) -> None:
"""Report, with diagnostic data, why the change did not fire."""
def _report_not_triggered_noop(reason: str, /, **data: Any) -> None:
"""Swallow a not-triggered report; used when diagnostics are not wanted."""
class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
_domain_specs: Mapping[str, DomainSpec]
# States filtered from the to_state pre-filter (and `_should_include`).
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
# States filtered from the from_state pre-filter. Defaults to
# `_excluded_states`. Subclasses can override to relax the origin
# check.
_excluded_from_states: ClassVar[frozenset[str]] = _excluded_states
_schema: probatio.Schema = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
_primary_entities_only: ClassVar[bool] = True
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, cls._schema(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._options = config.options or {}
self._duration: timedelta | None = self._options.get(CONF_FOR)
self._target = config.target
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities matching any of the domain specs."""
return filter_by_domain_specs(self._hass, self._domain_specs, entities)
def _get_tracked_value(self, state: State) -> Any:
"""Get the tracked value from a state based on the DomainSpec."""
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return state.state
return state.attributes.get(domain_spec.value_source)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the transition should fire the trigger.
Called only after `from_state.state` has been filtered against
`_excluded_from_states` and `to_state.state` against
`_excluded_states`, so subclasses don't need to repeat those
checks. Default: any state change. Override to add semantics
(specific from/to states, value changed across a threshold,
etc.).
"""
return from_state.state != to_state.state
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the state is a target state for the trigger.
Called only after `state.state` has been filtered against
`_excluded_states`, so subclasses don't need to repeat that
check. Default: any non-excluded state is a target. Override
to restrict (specific to_states, value within a threshold,
etc.).
When the state cannot fire the trigger, subclasses may use
`report_not_triggered` to record an interesting reason - e.g. a
non-numeric value or an unsupported unit - in the automation trace.
Callers that don't collect diagnostics (e.g. `count_matches`) pass
`_report_not_triggered_noop`.
"""
return True
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the trigger relies on (e.g. a missing volume_level attribute).
"""
return state.state not in self._excluded_states
def count_matches(
self,
entity_ids: Iterable[str],
states: Mapping[str, State | None] | None = None,
) -> tuple[int, int]:
"""Return (matches, included) for the entity set.
`matches` is the number of entities that pass `_should_include` AND
`is_valid_state`. `included` is the number that pass
`_should_include` (i.e. are visible to the all/count check at all).
Callers can use the pair to distinguish vacuous truth
(`included == 0`) from a genuine all-match
(`matches == included > 0`).
Entity states are read from `states` when provided, otherwise from
the live state machine. Pass the targeted entity states received
with a state change event to evaluate the event against the states
as they were when the event fired.
"""
matches = 0
included = 0
for entity_id in entity_ids:
if states is not None:
state = states[entity_id]
else:
state = self._hass.states.get(entity_id)
if state is None or not self._should_include(state):
continue
included += 1
if self.is_valid_state(state, _report_not_triggered_noop):
matches += 1
return matches, included
@callback
def _cancel_invalidated_timers(
self,
behavior: str,
pending_timers: dict[str, CALLBACK_TYPE],
target_state_change_data: TargetStateChangedData,
) -> None:
"""Cancel pending duration timers invalidated by a state change.
Runs on every delivered state change, before the trigger's own
validity checks: an event which cannot fire the trigger, e.g. an
entity becoming unavailable, may still invalidate a pending timer.
The targeted entity states have already been updated with this
event, so the first/all check can simply recount.
"""
event = target_state_change_data.state_change_event
if behavior == BEHAVIOR_EACH:
entity_id = event.data["entity_id"]
if entity_id not in pending_timers:
return
to_state = event.data["new_state"]
if (
to_state is None
or to_state.state in self._excluded_states
or not self.is_valid_state(to_state, _report_not_triggered_noop)
):
pending_timers.pop(entity_id)()
return
if behavior not in pending_timers:
return
if not self._combined_state_still_valid(
behavior,
target_state_change_data.targeted_entity_ids,
target_state_change_data.targeted_entity_states,
):
pending_timers.pop(behavior)()
def _combined_state_still_valid(
self,
behavior: str,
entity_ids: Iterable[str],
states: Mapping[str, State | None],
) -> bool:
"""Check the combined first/all state for a pending duration timer."""
matches, included = self.count_matches(entity_ids, states)
if behavior == BEHAVIOR_FIRST:
return matches >= 1
# Require at least one included entity to avoid keeping the timer
# alive when every targeted entity has been filtered out since it
# started — a vacuous all-match (`included == 0`) would otherwise
# let the action fire after `for:` even though no entity still
# matches.
return included > 0 and matches == included
@override
async def async_attach_runner(
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
# Pending `for:` duration timers, keyed by entity_id for behavior
# each and by the behavior for first/all.
pending_timers: dict[str, CALLBACK_TYPE] = {}
@callback
def handle_entities_update(
added: set[str],
removed: set[str],
entity_states: Mapping[str, State | None],
) -> None:
"""Re-validate pending duration timers on target changes.
Timers of entities no longer targeted are cancelled, and the
combined first/all condition is recounted over the updated
target: e.g. a non-matching entity added to the target breaks a
pending all-match.
"""
for entity_id in removed:
if (cancel := pending_timers.pop(entity_id, None)) is not None:
cancel()
if behavior not in pending_timers:
return
if not self._combined_state_still_valid(
behavior, entity_states.keys(), entity_states
):
pending_timers.pop(behavior)()
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
if pending_timers:
self._cancel_invalidated_timers(
behavior, pending_timers, target_state_change_data
)
if not from_state or not to_state:
return
if to_state.state in self._excluded_states:
return
@callback
def report_not_triggered(reason: str, /, **data: Any) -> None:
"""Report why this evaluated change did not fire the trigger."""
if did_not_trigger is None:
return
did_not_trigger(
NotTriggeredInfo(reason=reason, data=data), event.context
)
if not self.is_valid_state(to_state, report_not_triggered):
return
if (
from_state.state in self._excluded_from_states
or not self.is_valid_transition(from_state, to_state)
):
return
# Count against the targeted entity states as of this event, not
# the live state machine: state change events are dispatched one
# event loop iteration after the state machine is updated, so the
# state machine may already contain later changes to other
# targeted entities.
if behavior == BEHAVIOR_ALL:
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids,
target_state_change_data.targeted_entity_states,
)
if matches != included:
return
elif behavior == BEHAVIOR_FIRST:
# Note: It's enough to test for exactly 1 match here because if there
# were previously 2 matches the transition would not be valid and we
# would have returned already.
matches, _ = self.count_matches(
target_state_change_data.targeted_entity_ids,
target_state_change_data.targeted_entity_states,
)
if matches != 1:
return
@callback
def call_action() -> None:
"""Call action with right context."""
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
"for": self._duration,
},
f"state of {entity_id}",
event.context,
)
if not self._duration:
call_action()
return
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
if (
previous_timer := pending_timers.pop(subscription_key, None)
) is not None:
previous_timer()
@callback
def fire_after_duration(_now: datetime) -> None:
"""Fire the action once the state has held for the duration."""
del pending_timers[subscription_key]
call_action()
pending_timers[subscription_key] = async_call_later(
self._hass, self._duration, fire_after_duration
)
unsub = await async_track_target_selector_state_change_event(
self._hass,
self._target,
state_change_listener,
self.entity_filter,
handle_entities_update if self._duration else None,
primary_entities_only=self._primary_entities_only,
)
@callback
def async_remove() -> None:
"""Remove state listeners async."""
unsub()
for cancel_timer in pending_timers.values():
cancel_timer()
pending_timers.clear()
return async_remove
class EntityTargetStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes to a specific state.
Uses _get_tracked_value to extract the value, so it works for both
state-based and attribute-based triggers depending on the DomainSpec.
"""
_to_states: set[str]
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check the value changed and the origin was not already a target state."""
from_value = self._get_tracked_value(from_state)
return (
from_value != self._get_tracked_value(to_state)
and from_value not in self._to_states
)
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state."""
return self._get_tracked_value(state) in self._to_states
class EntityTransitionTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes between specific states."""
_from_states: set[str | bool]
_to_states: set[str | bool]
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state matches the expected ones."""
from_value = self._get_tracked_value(from_state)
return (
from_value != self._get_tracked_value(to_state)
and from_value in self._from_states
)
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected states."""
return self._get_tracked_value(state) in self._to_states
class EntityOriginStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes from a specific state."""
_from_state: str
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if origin state matches expected and that the state changed."""
return bool(
self._get_tracked_value(from_state) == self._from_state
and self._get_tracked_value(to_state) != self._from_state
)
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the new state is different from the origin state."""
return bool(self._get_tracked_value(state) != self._from_state)
class StatelessEntityTriggerBase(EntityTriggerBase):
"""Trigger for entities that don't carry meaningful state.
Used for stateless entities (buttons, scenes, doorbells, events)
whose `state.state` is just a timestamp of the last activation.
`STATE_UNKNOWN` is a legitimate prior state — the first activation
after startup must still fire the trigger.
"""
_schema: probatio.Schema = ENTITY_STATE_TRIGGER_SCHEMA
_excluded_from_states: ClassVar[frozenset[str]] = frozenset({STATE_UNAVAILABLE})
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
probatio.Required(CONF_OPTIONS, default={}): probatio.All(
{
probatio.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.CHANGED)
)
},
)
}
)
class EntityNumericalStateTriggerBase(EntityTriggerBase):
"""Base class for numerical state and state attribute triggers."""
_valid_unit: str | UndefinedType | None = UNDEFINED
_threshold_type: NumericThresholdType
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
threshold_options: dict[str, Any] = self._options["threshold"]
self.threshold = ThresholdConfig.from_config(threshold_options.get("value"))
self.lower_threshold = ThresholdConfig.from_config(
threshold_options.get("value_min")
)
self.upper_threshold = ThresholdConfig.from_config(
threshold_options.get("value_max")
)
self._threshold_type = threshold_options["type"]
def _is_valid_unit(self, unit: str | None) -> bool:
"""Check if the given unit is valid for this trigger."""
if isinstance(self._valid_unit, UndefinedType):
return True
return unit == self._valid_unit
def _get_threshold_value(
self,
threshold: ThresholdConfig | None,
report_not_triggered: NotTriggeredReasonReporter,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
if threshold.numerical:
return threshold.number
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
# Entity not found
report_not_triggered(
"threshold_entity_not_found",
entity_id=threshold.entity,
)
return None
unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
# Entity unit does not match the expected unit
report_not_triggered(
"threshold_unit_not_supported",
entity_id=threshold.entity,
unit=unit,
)
return None
try:
return float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
report_not_triggered(
"threshold_value_not_numeric",
entity_id=threshold.entity,
value=state.state,
)
return None
@override
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:
if not self._is_valid_unit(
state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
):
return None
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
try:
return float(raw_value)
except TypeError, ValueError:
# Entity state is not a valid number
return None
def _report_tracked_value_problem(
self, state: State, report_not_triggered: NotTriggeredReasonReporter
) -> None:
"""Report why `_get_tracked_value` rejected this state.
Called only when the tracked value is invalid. It mirrors the failure
modes of `_get_tracked_value` - which integrations override, so the
reason is derived here rather than reported inline: a state-sourced
value with an unsupported unit, otherwise a value that is not a number.
"""
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
report_not_triggered(
"entity_unit_not_supported",
entity_id=state.entity_id,
unit=unit,
)
return
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
report_not_triggered(
"entity_value_not_numeric",
entity_id=state.entity_id,
value=raw_value,
)
@override
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state or state attribute matches the expected one."""
# Handle missing or None value case first to avoid expensive exceptions
if (current_value := self._get_tracked_value(state)) is None:
self._report_tracked_value_problem(state, report_not_triggered)
return False
if self._threshold_type == NumericThresholdType.ANY:
# If the threshold type is "any" we always trigger on valid state
# changes
return True
if self._threshold_type == NumericThresholdType.ABOVE:
if (
limit := self._get_threshold_value(self.threshold, report_not_triggered)
) is None:
# Entity not found or invalid number, don't trigger
return False
return current_value > limit
if self._threshold_type == NumericThresholdType.BELOW:
if (
limit := self._get_threshold_value(self.threshold, report_not_triggered)
) is None:
# Entity not found or invalid number, don't trigger
return False
return current_value < limit
# Mode is BETWEEN or OUTSIDE. Evaluate the lower limit first so at most
# one not-triggered reason is reported per change.
lower_limit = self._get_threshold_value(
self.lower_threshold, report_not_triggered
)
if lower_limit is None:
# Entity not found or invalid number, don't trigger
return False
upper_limit = self._get_threshold_value(
self.upper_threshold, report_not_triggered
)
if upper_limit is None:
# Entity not found or invalid number, don't trigger
return False
between = lower_limit <= current_value <= upper_limit
if self._threshold_type == NumericThresholdType.BETWEEN:
return between
return not between
class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
"""Base class for numerical state and state attribute triggers."""
_base_unit: str | None # Base unit for the tracked value
_unit_converter: type[BaseUnitConverter]
def _get_entity_unit(self, state: State) -> str | None:
"""Get the unit of an entity from its state."""
return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
@override
def _report_tracked_value_problem(
self, state: State, report_not_triggered: NotTriggeredReasonReporter
) -> None:
"""Report why `_get_tracked_value` rejected this state.
Mirrors the with-unit failure modes: a value that is not a number,
otherwise a unit that cannot be converted to the base unit.
"""
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:
float(raw_value)
except TypeError, ValueError:
report_not_triggered(
"entity_value_not_numeric",
entity_id=state.entity_id,
value=raw_value,
)
return
report_not_triggered(
"entity_unit_not_supported",
entity_id=state.entity_id,
unit=self._get_entity_unit(state),
)
@override
def _get_threshold_value(
self,
threshold: ThresholdConfig | None,
report_not_triggered: NotTriggeredReasonReporter,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
if threshold.numerical:
return self._unit_converter.convert(
threshold.number, # type: ignore[arg-type]
threshold.unit, # type: ignore[arg-type]
self._base_unit,
)
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
# Entity not found
report_not_triggered(
"threshold_entity_not_found",
entity_id=threshold.entity,
)
return None
try:
value = float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
report_not_triggered(
"threshold_value_not_numeric",
entity_id=threshold.entity,
value=state.state,
)
return None
unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
try:
return self._unit_converter.convert(value, unit, self._base_unit)
except HomeAssistantError:
# Unit conversion failed (i.e. incompatible units), treat as invalid number
report_not_triggered(
"threshold_unit_not_supported",
entity_id=threshold.entity,
unit=unit,
)
return None
@override
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):
"""Trigger for numerical state and state attribute changes."""
_schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the tracked numeric value has changed."""
return self._get_tracked_value(from_state) != self._get_tracked_value(to_state)
def make_numerical_state_changed_with_unit_schema(
unit_converter: type[BaseUnitConverter],
) -> probatio.Schema:
"""Factory for numerical state trigger schema with unit option."""
return ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
probatio.Required(CONF_OPTIONS, default={}): probatio.All(
{
probatio.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(
mode=NumericThresholdMode.CHANGED,
unit_of_measurement=list(unit_converter.VALID_UNITS),
)
)
},
)
}
)
class EntityNumericalStateChangedTriggerWithUnitBase(
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
):
"""Trigger for numerical state and state attribute changes."""
@override
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)
NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
probatio.Required(CONF_OPTIONS): {
probatio.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.CROSSED)
),
},
}
)
)
class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTriggerBase):
"""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.
"""
_schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the tracked value crossed into the threshold range."""
return not self.is_valid_state(from_state, _report_not_triggered_noop)
def _make_numerical_state_crossed_threshold_with_unit_schema(
unit_converter: type[BaseUnitConverter],
) -> probatio.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_WITH_BEHAVIOR.extend(
{
probatio.Required(CONF_OPTIONS, default={}): {
probatio.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(
mode=NumericThresholdMode.CROSSED,
unit_of_measurement=list(unit_converter.VALID_UNITS),
)
),
},
}
)
class EntityNumericalStateCrossedThresholdTriggerWithUnitBase(
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
):
"""Trigger for numerical state and state attribute changes."""
@override
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]:
"""Normalize domain_specs argument to a Mapping."""
if isinstance(domain_specs, str):
return {domain_specs: DomainSpec()}
return domain_specs
def make_entity_target_state_trigger(
domain_specs: Mapping[str, DomainSpec] | str,
to_states: str | set[str],
*,
primary_entities_only: bool = True,
) -> type[EntityTargetStateTriggerBase]:
"""Create a trigger for entity state changes to specific state(s).
domain_specs can be a string (domain name) for simple state-based triggers,
or a Mapping[str, DomainSpec] for attribute-based or multi-domain triggers.
"""
specs = _normalize_domain_specs(domain_specs)
if isinstance(to_states, str):
to_states_set = {to_states}
else:
to_states_set = to_states
class CustomTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain_specs = specs
_to_states = to_states_set
_primary_entities_only = primary_entities_only
return CustomTrigger
def make_entity_transition_trigger(
domain_specs: Mapping[str, DomainSpec] | str,
*,
from_states: set[str | bool],
to_states: set[str | bool],
) -> type[EntityTransitionTriggerBase]:
"""Create a trigger for entity state changes between specific states.
domain_specs can be a string (domain name) for simple state-based triggers,
or a Mapping[str, DomainSpec] for attribute-based or multi-domain triggers.
"""
specs = _normalize_domain_specs(domain_specs)
class CustomTrigger(EntityTransitionTriggerBase):
"""Trigger for conditional entity state changes."""
_domain_specs = specs
_from_states = from_states
_to_states = to_states
return CustomTrigger
def make_entity_origin_state_trigger(
domain_specs: Mapping[str, DomainSpec] | str,
*,
from_state: str,
) -> type[EntityOriginStateTriggerBase]:
"""Create a trigger for entity state changes from a specific state.
domain_specs can be a string (domain name) for simple state-based triggers,
or a Mapping[str, DomainSpec] for attribute-based or multi-domain triggers.
"""
specs = _normalize_domain_specs(domain_specs)
class CustomTrigger(EntityOriginStateTriggerBase):
"""Trigger for entity "from state" changes."""
_domain_specs = specs
_from_state = from_state
return CustomTrigger
def make_entity_numerical_state_changed_trigger(
domain_specs: Mapping[str, DomainSpec],
valid_unit: str | UndefinedType | None = UNDEFINED,
*,
primary_entities_only: bool = True,
) -> type[EntityNumericalStateChangedTriggerBase]:
"""Create a trigger for numerical state value change."""
class CustomTrigger(EntityNumericalStateChangedTriggerBase):
"""Trigger for numerical state value changes."""
_domain_specs = domain_specs
_valid_unit = valid_unit
_primary_entities_only = primary_entities_only
return CustomTrigger
def make_entity_numerical_state_crossed_threshold_trigger(
domain_specs: Mapping[str, DomainSpec],
valid_unit: str | UndefinedType | None = UNDEFINED,
*,
primary_entities_only: bool = True,
) -> type[EntityNumericalStateCrossedThresholdTriggerBase]:
"""Create a trigger for numerical state value crossing a threshold."""
class CustomTrigger(EntityNumericalStateCrossedThresholdTriggerBase):
"""Trigger for numerical state value crossing a threshold."""
_domain_specs = domain_specs
_valid_unit = valid_unit
_primary_entities_only = primary_entities_only
return CustomTrigger
def make_entity_numerical_state_changed_with_unit_trigger(
domain_specs: Mapping[str, DomainSpec],
base_unit: str,
unit_converter: type[BaseUnitConverter],
) -> type[EntityNumericalStateChangedTriggerWithUnitBase]:
"""Create a trigger for numerical state value change."""
class CustomTrigger(EntityNumericalStateChangedTriggerWithUnitBase):
"""Trigger for numerical state value changes."""
_domain_specs = domain_specs
_base_unit = base_unit
_unit_converter = unit_converter
return CustomTrigger
def make_entity_numerical_state_crossed_threshold_with_unit_trigger(
domain_specs: Mapping[str, DomainSpec],
base_unit: str,
unit_converter: type[BaseUnitConverter],
) -> type[EntityNumericalStateCrossedThresholdTriggerWithUnitBase]:
"""Create a trigger for numerical state value crossing a threshold."""
class CustomTrigger(EntityNumericalStateCrossedThresholdTriggerWithUnitBase):
"""Trigger for numerical state value crossing a threshold."""
_domain_specs = domain_specs
_base_unit = base_unit
_unit_converter = unit_converter
return CustomTrigger