mirror of
https://github.com/home-assistant/core.git
synced 2026-09-17 14:08:42 +01:00
1093 lines
39 KiB
Python
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
|