mirror of
https://github.com/home-assistant/core.git
synced 2026-08-14 17:23:33 +01:00
2359 lines
81 KiB
Python
2359 lines
81 KiB
Python
"""Offer reusable conditions."""
|
|
|
|
import abc
|
|
import asyncio
|
|
from collections import deque
|
|
from collections.abc import Callable, Container, Coroutine, Iterable, Mapping
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, time as dt_time, timedelta
|
|
import functools as ft
|
|
import inspect
|
|
import logging
|
|
import re
|
|
import sys
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
ClassVar,
|
|
Final,
|
|
Literal,
|
|
Never,
|
|
Protocol,
|
|
TypedDict,
|
|
Unpack,
|
|
cast,
|
|
final,
|
|
overload,
|
|
override,
|
|
)
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.const import (
|
|
CONF_ABOVE,
|
|
CONF_AFTER,
|
|
CONF_ATTRIBUTE,
|
|
CONF_BEFORE,
|
|
CONF_BELOW,
|
|
CONF_CONDITION,
|
|
CONF_DEVICE_ID,
|
|
CONF_ENABLED,
|
|
CONF_ENTITY_ID,
|
|
CONF_FOR,
|
|
CONF_ID,
|
|
CONF_MATCH,
|
|
CONF_OPTIONS,
|
|
CONF_SELECTOR,
|
|
CONF_STATE,
|
|
CONF_TARGET,
|
|
CONF_VALUE_TEMPLATE,
|
|
CONF_WEEKDAY,
|
|
CONF_ZONE,
|
|
ENTITY_MATCH_ALL,
|
|
ENTITY_MATCH_ANY,
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
WEEKDAYS,
|
|
EntityStateAttribute,
|
|
)
|
|
from homeassistant.core import (
|
|
HomeAssistant,
|
|
State,
|
|
callback,
|
|
split_entity_id,
|
|
valid_entity_id,
|
|
)
|
|
from homeassistant.exceptions import (
|
|
ConditionError,
|
|
ConditionErrorContainer,
|
|
ConditionErrorIndex,
|
|
ConditionErrorMessage,
|
|
HomeAssistantError,
|
|
TemplateError,
|
|
)
|
|
from homeassistant.loader import (
|
|
Integration,
|
|
IntegrationNotFound,
|
|
async_get_integration,
|
|
async_get_integrations,
|
|
)
|
|
from homeassistant.util import dt as dt_util
|
|
from homeassistant.util.async_ import run_callback_threadsafe
|
|
from homeassistant.util.hass_dict import HassKey
|
|
from homeassistant.util.unit_conversion import BaseUnitConverter
|
|
from homeassistant.util.yaml import load_yaml_dict
|
|
|
|
from . import config_validation as cv, entity_registry as er, selector
|
|
from .automation import (
|
|
DomainSpec,
|
|
ThresholdConfig,
|
|
filter_by_domain_specs,
|
|
get_absolute_description_key,
|
|
get_relative_description_key,
|
|
move_options_fields_to_top_level,
|
|
)
|
|
from .integration_platform import async_process_integration_platforms
|
|
from .recorder import get_instance
|
|
from .selector import (
|
|
NumericThresholdMode,
|
|
NumericThresholdSelector,
|
|
NumericThresholdSelectorConfig,
|
|
NumericThresholdType,
|
|
TargetSelector,
|
|
)
|
|
from .target import (
|
|
TargetSelection,
|
|
TargetStateChangedData,
|
|
async_extract_referenced_entity_ids,
|
|
async_track_target_selector_state_change_event,
|
|
)
|
|
from .template import Template, render_complex
|
|
from .trace import (
|
|
TraceElement,
|
|
trace_append_element,
|
|
trace_path,
|
|
trace_path_get,
|
|
trace_stack_cv,
|
|
trace_stack_pop,
|
|
trace_stack_push,
|
|
trace_stack_top,
|
|
)
|
|
from .typing import UNDEFINED, ConfigType, TemplateVarsType, UndefinedType
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.components.recorder import Recorder
|
|
|
|
ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
|
|
FROM_CONFIG_FORMAT = "{}_from_config"
|
|
VALIDATE_CONFIG_FORMAT = "{}_validate_config"
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Upper bound on the best-effort recorder query used to prime `for:` durations
|
|
# at setup. If history can't be read within this window we fall back to the
|
|
# conservative live-state anchor rather than blocking condition setup.
|
|
HISTORY_PRIMING_TIMEOUT = 10
|
|
|
|
# How far back the `for:` priming query reaches. Caps the cost of the query for
|
|
# very long `for:` durations; beyond this we rely on the live-state anchor, so
|
|
# such conditions may only become true once enough time has elapsed since setup.
|
|
MAX_HISTORY_PRIMING_LOOKBACK = timedelta(hours=6)
|
|
|
|
_PLATFORM_ALIASES: dict[str | None, str | None] = {
|
|
"and": None,
|
|
"device": "device_automation",
|
|
"not": None,
|
|
"numeric_state": None,
|
|
"or": None,
|
|
"state": None,
|
|
"template": None,
|
|
"time": None,
|
|
"trigger": None,
|
|
}
|
|
|
|
INPUT_ENTITY_ID = re.compile(
|
|
r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(?<!_)$"
|
|
)
|
|
|
|
|
|
CONDITION_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey(
|
|
"condition_description_cache"
|
|
)
|
|
CONDITION_PLATFORM_SUBSCRIPTIONS: HassKey[
|
|
list[Callable[[set[str]], Coroutine[Any, Any, None]]]
|
|
] = HassKey("condition_platform_subscriptions")
|
|
CONDITIONS: HassKey[dict[str, str]] = HassKey("conditions")
|
|
|
|
|
|
# Basic schemas to sanity check the condition descriptions,
|
|
# full validation is done by hassfest.conditions
|
|
_FIELD_DESCRIPTION_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional(CONF_SELECTOR): selector.validate_selector,
|
|
},
|
|
extra=vol.ALLOW_EXTRA,
|
|
)
|
|
|
|
_CONDITION_DESCRIPTION_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional("target"): TargetSelector.CONFIG_SCHEMA,
|
|
vol.Optional("fields"): vol.Schema({str: _FIELD_DESCRIPTION_SCHEMA}),
|
|
},
|
|
extra=vol.ALLOW_EXTRA,
|
|
)
|
|
|
|
|
|
def starts_with_dot(key: str) -> str:
|
|
"""Check if key starts with dot."""
|
|
if not key.startswith("."):
|
|
raise vol.Invalid("Key does not start with .")
|
|
return key
|
|
|
|
|
|
_CONDITIONS_DESCRIPTION_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Remove(vol.All(str, starts_with_dot)): object,
|
|
cv.underscore_slug: vol.Any(None, _CONDITION_DESCRIPTION_SCHEMA),
|
|
}
|
|
)
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant) -> None:
|
|
"""Set up the condition helper."""
|
|
hass.data[CONDITION_DESCRIPTION_CACHE] = {}
|
|
hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = []
|
|
hass.data[CONDITIONS] = {}
|
|
hass.data[_DATA_HISTORY_PRIMING_MANAGER] = _HistoryPrimingManager(hass)
|
|
|
|
await async_process_integration_platforms(
|
|
hass, "condition", _register_condition_platform, wait_for_platforms=True
|
|
)
|
|
|
|
|
|
@callback
|
|
def async_subscribe_platform_events(
|
|
hass: HomeAssistant,
|
|
on_event: Callable[[set[str]], Coroutine[Any, Any, None]],
|
|
) -> Callable[[], None]:
|
|
"""Subscribe to condition platform events."""
|
|
condition_platform_event_subscriptions = hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]
|
|
|
|
def remove_subscription() -> None:
|
|
condition_platform_event_subscriptions.remove(on_event)
|
|
|
|
condition_platform_event_subscriptions.append(on_event)
|
|
return remove_subscription
|
|
|
|
|
|
async def _register_condition_platform(
|
|
hass: HomeAssistant, integration_domain: str, platform: ConditionProtocol
|
|
) -> None:
|
|
"""Register a condition platform and notify listeners.
|
|
|
|
If the condition platform does not provide any conditions,
|
|
listeners will not be notified.
|
|
"""
|
|
new_conditions: set[str] = set()
|
|
conditions = hass.data[CONDITIONS]
|
|
|
|
if hasattr(platform, "async_get_conditions"):
|
|
all_conditions = await platform.async_get_conditions(hass)
|
|
for condition_key in all_conditions:
|
|
condition_key = get_absolute_description_key(
|
|
integration_domain, condition_key
|
|
)
|
|
if condition_key not in conditions:
|
|
conditions[condition_key] = integration_domain
|
|
new_conditions.add(condition_key)
|
|
if not new_conditions:
|
|
if not all_conditions:
|
|
_LOGGER.debug(
|
|
"Integration %s returned no conditions in async_get_conditions",
|
|
integration_domain,
|
|
)
|
|
return
|
|
else:
|
|
_LOGGER.debug(
|
|
"Integration %s does not provide condition support, skipping",
|
|
integration_domain,
|
|
)
|
|
return
|
|
|
|
# We don't use gather here because gather adds additional overhead
|
|
# when wrapping each coroutine in a task, and we expect our listeners
|
|
# to call condition.async_get_all_descriptions which will only yield
|
|
# the first time it's called, after that it returns cached data.
|
|
for listener in hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]:
|
|
try:
|
|
await listener(new_conditions)
|
|
except Exception:
|
|
_LOGGER.exception("Error while notifying condition platform listener")
|
|
|
|
|
|
_CONDITION_BASE_SCHEMA = vol.Schema(
|
|
{
|
|
**cv.CONDITION_BASE_SCHEMA,
|
|
vol.Required(CONF_CONDITION): str,
|
|
}
|
|
)
|
|
_CONDITION_SCHEMA = _CONDITION_BASE_SCHEMA.extend(
|
|
{
|
|
vol.Optional(CONF_OPTIONS): object,
|
|
vol.Optional(CONF_TARGET): cv.TARGET_FIELDS,
|
|
}
|
|
)
|
|
|
|
|
|
class ConditionChecker(abc.ABC):
|
|
"""Base class for condition checkers."""
|
|
|
|
_set_up = False
|
|
_unloaded = False
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize condition checker."""
|
|
self._hass = hass
|
|
|
|
def __call__(
|
|
self, hass: HomeAssistant, variables: TemplateVarsType = None
|
|
) -> bool | None:
|
|
"""Check the condition.
|
|
|
|
`hass` parameter is for backwards compatibility only and is always ignored.
|
|
"""
|
|
return self.async_check(variables=variables)
|
|
|
|
def __del__(self) -> None:
|
|
"""Clean up when the checker is deleted."""
|
|
if self._unloaded:
|
|
return
|
|
try:
|
|
self.async_unload()
|
|
except Exception:
|
|
_LOGGER.exception("Error while unloading condition checker")
|
|
|
|
@final
|
|
async def async_setup(self) -> None:
|
|
"""Set up the condition checker.
|
|
|
|
Users of conditions do not need to call this method directly. It is called
|
|
automatically by async_from_config and async_conditions_from_config.
|
|
"""
|
|
await self._async_setup()
|
|
self._set_up = True
|
|
|
|
async def _async_setup(self) -> None: # noqa: B027
|
|
"""Set up the condition checker.
|
|
|
|
Intended to be overridden in derived classes that need to do setup.
|
|
"""
|
|
|
|
@final
|
|
def async_unload(self) -> None:
|
|
"""Clean up any resources held by the checker.
|
|
|
|
Users of conditions must call this method when they are done with the
|
|
checker to ensure resources are released.
|
|
"""
|
|
self._async_unload()
|
|
self._unloaded = True
|
|
|
|
def _async_unload(self) -> None: # noqa: B027
|
|
"""Clean up any resources held by the checker.
|
|
|
|
Intended to be overridden in derived classes that need to do unloading.
|
|
"""
|
|
|
|
@final
|
|
def async_check(
|
|
self, *, variables: TemplateVarsType = None, **kwargs: Never
|
|
) -> bool | None:
|
|
"""Check the condition."""
|
|
if not self._set_up:
|
|
raise HomeAssistantError("Condition checker is not set up")
|
|
with trace_condition(variables):
|
|
result = self._async_check(variables=variables)
|
|
condition_trace_update_result(result=result)
|
|
return result
|
|
|
|
@abc.abstractmethod
|
|
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool | None:
|
|
"""Check the condition."""
|
|
|
|
|
|
class LegacyConditionChecker(ConditionChecker):
|
|
"""Condition checker wrapping a legacy condition factory function."""
|
|
|
|
def __init__(self, hass: HomeAssistant, checker: ConditionCheckerType) -> None:
|
|
"""Initialize condition checker."""
|
|
super().__init__(hass)
|
|
self._checker = checker
|
|
|
|
@override
|
|
def _async_check(self, variables: TemplateVarsType = None, **kwargs: Any) -> bool:
|
|
return self._checker(self._hass, variables)
|
|
|
|
|
|
class DisabledConditionChecker(ConditionChecker):
|
|
"""Condition checker for disabled conditions."""
|
|
|
|
@override
|
|
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> None:
|
|
return None
|
|
|
|
|
|
class CompoundConditionChecker(ConditionChecker):
|
|
"""Base class for compound condition checkers (and/or/not)."""
|
|
|
|
def __init__(self, hass: HomeAssistant, conditions: list[ConditionChecker]) -> None:
|
|
"""Initialize condition checker."""
|
|
super().__init__(hass)
|
|
self._conditions = conditions
|
|
|
|
@override
|
|
def _async_unload(self) -> None:
|
|
"""Clean up child conditions."""
|
|
for condition in self._conditions:
|
|
condition.async_unload()
|
|
|
|
|
|
class Condition(ConditionChecker):
|
|
"""Condition class."""
|
|
|
|
@classmethod
|
|
async def async_validate_complete_config(
|
|
cls, hass: HomeAssistant, complete_config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate complete config.
|
|
|
|
The complete config includes fields that are generic to all conditions,
|
|
such as the alias.
|
|
This method should be overridden by conditions that need to migrate
|
|
from the old-style config.
|
|
"""
|
|
complete_config = _CONDITION_SCHEMA(complete_config)
|
|
|
|
specific_config: ConfigType = {}
|
|
for key in (CONF_OPTIONS, CONF_TARGET):
|
|
if key in complete_config:
|
|
specific_config[key] = complete_config.pop(key)
|
|
specific_config = await cls.async_validate_config(hass, specific_config)
|
|
|
|
for key in (CONF_OPTIONS, CONF_TARGET):
|
|
if key in specific_config:
|
|
complete_config[key] = specific_config[key]
|
|
|
|
return complete_config
|
|
|
|
@classmethod
|
|
@abc.abstractmethod
|
|
async def async_validate_config(
|
|
cls, hass: HomeAssistant, config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate config."""
|
|
|
|
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
|
"""Initialize condition."""
|
|
super().__init__(hass)
|
|
|
|
|
|
ATTR_BEHAVIOR: Final = "behavior"
|
|
BEHAVIOR_ANY: Final = "any"
|
|
BEHAVIOR_ALL: Final = "all"
|
|
|
|
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema(
|
|
{
|
|
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
vol.Required(CONF_OPTIONS, default={}): {
|
|
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
|
[BEHAVIOR_ANY, BEHAVIOR_ALL]
|
|
),
|
|
vol.Optional(CONF_FOR): cv.positive_time_period,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
_DATA_HISTORY_PRIMING_MANAGER: HassKey[_HistoryPrimingManager] = HassKey(
|
|
"condition_history_priming_manager"
|
|
)
|
|
|
|
|
|
class _HistoryPrimingManager:
|
|
"""Serialize and coalesce the recorder reads that prime condition durations.
|
|
|
|
At startup many conditions may prime at once. Letting each hit the recorder
|
|
independently would force a separate commit per condition and run every read
|
|
on the shared DB executor in parallel — a flood. So the reads run one at a
|
|
time, and a single commit flush is shared by each "generation" of conditions
|
|
that arrive while the previous flush is running.
|
|
|
|
The flush a condition relies on must begin after that condition started
|
|
tracking its entities, or the read could miss a change still queued in the
|
|
recorder and compute too generous an anchor. A condition therefore never
|
|
relies on a flush that was already running when it arrived (the lobby); it
|
|
waits that one out and joins the next, re-attempting if the flush it waited
|
|
for was cancelled before completing. This mirrors `ReloadServiceHelper`
|
|
minus its target de-duplication, which does not apply because each condition
|
|
reads its own entities.
|
|
"""
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize the manager."""
|
|
self._hass = hass
|
|
self._flush_condition = asyncio.Condition()
|
|
self._flushing = False
|
|
self._flush_ok = False
|
|
self._query_lock = asyncio.Lock()
|
|
|
|
async def async_prime[_T](
|
|
self, job: Callable[[Recorder], Coroutine[Any, Any, _T]]
|
|
) -> _T:
|
|
"""Flush the recorder, then run `job`, coordinated with other primings."""
|
|
await self._async_flush()
|
|
async with self._query_lock:
|
|
return await job(get_instance(self._hass))
|
|
|
|
async def _async_flush(self) -> None:
|
|
"""Return once a recorder flush that began no earlier than this call ends.
|
|
|
|
The first condition of a generation performs the flush; the rest rely on
|
|
it.
|
|
"""
|
|
async with self._flush_condition:
|
|
# Lobby: a flush already running began before we arrived, so it may
|
|
# not capture our entity's queued changes. Wait it out, don't rely on
|
|
# it.
|
|
if self._flushing:
|
|
await self._flush_condition.wait()
|
|
|
|
while True:
|
|
async with self._flush_condition:
|
|
if not self._flushing:
|
|
# First past the lobby this generation: we run the flush.
|
|
self._flushing = True
|
|
break
|
|
# A peer began a fresh flush after we cleared the lobby; wait for
|
|
# it.
|
|
await self._flush_condition.wait()
|
|
if self._flush_ok:
|
|
return
|
|
# The flush we waited for was cancelled before completing (its owner
|
|
# timed out): loop and start or wait for a fresh one rather than read
|
|
# against a queue that was never flushed.
|
|
|
|
instance = get_instance(self._hass)
|
|
flushed = False
|
|
try:
|
|
if (commit_future := instance.async_get_commit_future()) is not None:
|
|
await commit_future
|
|
flushed = True
|
|
finally:
|
|
async with self._flush_condition:
|
|
self._flushing = False
|
|
self._flush_ok = flushed
|
|
self._flush_condition.notify_all()
|
|
|
|
|
|
class EntityConditionBase(Condition):
|
|
"""Base class for entity conditions."""
|
|
|
|
_domain_specs: Mapping[str, DomainSpec]
|
|
_excluded_states: Final[frozenset[str]] = frozenset(
|
|
{STATE_UNAVAILABLE, STATE_UNKNOWN}
|
|
)
|
|
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
|
|
# 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: ConditionConfig) -> None:
|
|
"""Initialize condition."""
|
|
super().__init__(hass, config)
|
|
if TYPE_CHECKING:
|
|
assert config.target
|
|
assert config.options
|
|
self._target = config.target
|
|
self._target_selection = TargetSelection(config.target)
|
|
self._behavior = config.options[ATTR_BEHAVIOR]
|
|
self._duration: timedelta | None = config.options.get(CONF_FOR)
|
|
if self._behavior == BEHAVIOR_ANY:
|
|
self._matcher = self._check_any_match_state
|
|
elif self._behavior == BEHAVIOR_ALL:
|
|
self._matcher = self._check_all_match_state
|
|
self._on_unload: list[Callable[[], None]] = []
|
|
self._valid_since: dict[str, datetime] = {}
|
|
# Entities whose `for:` anchor is currently being resolved from recorder
|
|
# history. While an entity is here the live listener leaves its anchor to
|
|
# the priming, except that an invalidation removes it (the run broke, so
|
|
# the in-flight history is stale and live tracking takes over).
|
|
self._priming: set[str] = set()
|
|
|
|
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)
|
|
|
|
@property
|
|
def _needs_duration_tracking(self) -> bool:
|
|
"""Whether this condition needs active state change tracking for duration.
|
|
|
|
The base implementation intentionally defaults to always tracking
|
|
duration and should be overridden by subclasses that can safely use
|
|
state.last_changed directly. For example, conditions that are true
|
|
for a single main state value may not need active tracking, while
|
|
conditions that track attributes or match multiple states do because
|
|
last_changed does not capture those transitions.
|
|
"""
|
|
return True
|
|
|
|
def _state_valid_since(self, _state: State) -> datetime:
|
|
"""Return the datetime that anchors `for:` durations for `state`.
|
|
|
|
Override in subclasses whose `is_valid_state` reads
|
|
attributes directly without going through `value_source`.
|
|
"""
|
|
if self._domain_specs[_state.domain].value_source is None:
|
|
return _state.last_changed
|
|
return _state.last_updated
|
|
|
|
def _update_valid_since(self, entity_id: str, _state: State | None) -> None:
|
|
"""Update _valid_since tracking for an entity based on its current state.
|
|
|
|
If the entity is in a valid state and not already tracked, records
|
|
when the condition became true (via `_state_valid_since`). If the
|
|
entity is not in a valid state, removes it from tracking.
|
|
"""
|
|
if (
|
|
_state is not None
|
|
and self._should_include(_state)
|
|
and self.is_valid_state(_state)
|
|
):
|
|
# While an entity is being primed from history, leave its anchor to
|
|
# the priming: the entity stayed valid, so the run is unbroken and the
|
|
# history start (which can be earlier than this update) is accurate.
|
|
if entity_id in self._priming:
|
|
return
|
|
# Only record the time if not already tracked, to avoid
|
|
# resetting the duration on unrelated state/attribute updates.
|
|
if entity_id not in self._valid_since:
|
|
self._valid_since[entity_id] = self._state_valid_since(_state)
|
|
else:
|
|
# An invalidation breaks the run, so any history being loaded for the
|
|
# entity is now stale; stop priming it and let live tracking own it.
|
|
self._priming.discard(entity_id)
|
|
self._valid_since.pop(entity_id, None)
|
|
|
|
@override
|
|
async def _async_setup(self) -> None:
|
|
"""Set up state tracking for duration-based conditions."""
|
|
if not self._duration or not self._needs_duration_tracking:
|
|
return
|
|
|
|
@callback
|
|
def _state_change_listener(
|
|
data: TargetStateChangedData,
|
|
) -> None:
|
|
"""Track when entities enter or leave a valid state."""
|
|
event = data.state_change_event
|
|
entity_id = event.data["entity_id"]
|
|
to_state = event.data["new_state"]
|
|
|
|
self._update_valid_since(entity_id, to_state)
|
|
|
|
unsub = await async_track_target_selector_state_change_event(
|
|
self._hass,
|
|
self._target,
|
|
_state_change_listener,
|
|
self.entity_filter,
|
|
self._async_on_entities_update,
|
|
primary_entities_only=self._primary_entities_only,
|
|
)
|
|
self._on_unload.append(unsub)
|
|
|
|
async def _async_on_entities_update(
|
|
self,
|
|
added: set[str],
|
|
removed: set[str],
|
|
_entity_states: Mapping[str, State | None],
|
|
) -> None:
|
|
"""Handle changes to the tracked entity set.
|
|
|
|
Removed entities stop being tracked immediately. Added entities are only
|
|
considered by the condition once their `for:` anchor has been resolved
|
|
(see `_async_prime_valid_since`); until then they are absent from
|
|
`_valid_since`. The target tracker awaits this for the initial entity set
|
|
at setup and runs it as a background task for later registry-driven
|
|
changes.
|
|
"""
|
|
for entity_id in removed:
|
|
self._priming.discard(entity_id)
|
|
self._valid_since.pop(entity_id, None)
|
|
await self._async_prime_valid_since(added)
|
|
|
|
async def _async_prime_valid_since(self, entity_ids: set[str]) -> None:
|
|
"""Resolve and store the `for:` anchor for newly tracked entities.
|
|
|
|
For each currently-valid entity the anchor is the start of its current
|
|
continuous run of validity, read from recorder history (bounded by
|
|
`MAX_HISTORY_PRIMING_LOOKBACK`). The earlier of that and the current
|
|
state's own anchor wins, so a run that began before the lookback window
|
|
is not cut short. When the recorder is unavailable or the read fails,
|
|
the current state's anchor is used alone. An entity is added to
|
|
`_valid_since` only once this resolves, so a newly tracked entity does
|
|
not participate in the condition until its anchor is known — rather than
|
|
briefly using a conservative anchor that then changes.
|
|
|
|
While loading, an entity is held in `_priming`. A live change that keeps
|
|
it valid is ignored (the run is unbroken, history is accurate), but an
|
|
invalidation removes it from `_priming` so that we do not apply now-stale
|
|
history over the live tracking that observed the break.
|
|
"""
|
|
# Conservative anchor from the live state for each currently-valid entity.
|
|
anchors = {
|
|
entity_id: self._state_valid_since(_state)
|
|
for entity_id in entity_ids
|
|
if (_state := self._hass.states.get(entity_id)) is not None
|
|
and self._should_include(_state)
|
|
and self.is_valid_state(_state)
|
|
}
|
|
if not anchors:
|
|
return
|
|
|
|
self._priming.update(anchors)
|
|
try:
|
|
if "recorder" in self._hass.config.components:
|
|
await self._async_refine_anchors_from_history(anchors)
|
|
for entity_id, anchor in anchors.items():
|
|
# Skip entities a live change invalidated mid-load: they were
|
|
# removed from `_priming`, the run broke, and live tracking (which
|
|
# saw the break) owns them — applying this history would be stale.
|
|
if entity_id in self._priming:
|
|
self._valid_since[entity_id] = anchor
|
|
finally:
|
|
self._priming.difference_update(anchors)
|
|
|
|
async def _async_refine_anchors_from_history(
|
|
self, anchors: dict[str, datetime]
|
|
) -> None:
|
|
"""Move each anchor in `anchors` back to the true start of its run.
|
|
|
|
For each entity the anchor becomes the earlier of the recorded run start
|
|
and the existing (live) anchor; entities with no usable history keep
|
|
their existing anchor. Mutates `anchors` in place.
|
|
"""
|
|
from sqlalchemy.exc import SQLAlchemyError # noqa: PLC0415
|
|
|
|
from homeassistant.components.recorder import history # noqa: PLC0415
|
|
|
|
if TYPE_CHECKING:
|
|
assert self._duration is not None
|
|
lookback = min(self._duration, MAX_HISTORY_PRIMING_LOOKBACK)
|
|
start_time = dt_util.utcnow() - lookback
|
|
|
|
async def _read_history(
|
|
instance: Recorder,
|
|
) -> dict[str, list[State | dict[str, Any]]]:
|
|
# The history query only sees committed rows; the priming manager
|
|
# flushes the recorder queue before running this.
|
|
return await instance.async_add_executor_job(
|
|
ft.partial(
|
|
history.get_significant_states,
|
|
self._hass,
|
|
start_time,
|
|
entity_ids=list(anchors),
|
|
include_start_time_state=True,
|
|
# Mandatory: the default (True) drops attribute-only changes
|
|
# for entities outside SIGNIFICANT_DOMAINS, which are exactly
|
|
# the transitions attribute-based conditions depend on.
|
|
significant_changes_only=False,
|
|
minimal_response=False,
|
|
)
|
|
)
|
|
|
|
manager = self._hass.data[_DATA_HISTORY_PRIMING_MANAGER]
|
|
try:
|
|
# The timeout also covers waiting for our turn, so under a flood of
|
|
# primings a condition falls back to its conservative anchor rather
|
|
# than blocking on the queue indefinitely.
|
|
async with asyncio.timeout(HISTORY_PRIMING_TIMEOUT):
|
|
historical_states = await manager.async_prime(_read_history)
|
|
except (SQLAlchemyError, TimeoutError) as err:
|
|
# Best effort: keep the conservative anchors rather than failing.
|
|
_LOGGER.debug("Error priming condition durations from history: %s", err)
|
|
return
|
|
|
|
for entity_id, rows in historical_states.items():
|
|
valid_since = self._valid_since_from_history(
|
|
entity_id, cast(list[State], rows)
|
|
)
|
|
if valid_since is not None:
|
|
anchors[entity_id] = min(valid_since, anchors[entity_id])
|
|
|
|
def _valid_since_from_history(
|
|
self, entity_id: str, rows: list[State]
|
|
) -> datetime | None:
|
|
"""Return when the current continuous run of valid states began.
|
|
|
|
Walks recorded states newest-first and stops at the first one that is
|
|
not valid; the anchor is the oldest state in the unbroken run leading up
|
|
to the latest recorded state. (We can't just take the first valid state
|
|
in the window: an intervening invalid period breaks the run, so the
|
|
anchor must come from after it.) Returns None when the latest recorded
|
|
state is not valid, e.g. the recorder lags behind the live state machine.
|
|
"""
|
|
# Recorder rows are LazyState objects, which skip State.__init__ and so
|
|
# never populate the domain/object_id that the validity checks rely on.
|
|
domain, object_id = split_entity_id(entity_id)
|
|
valid_since: datetime | None = None
|
|
for _state in reversed(rows):
|
|
_state.domain = domain
|
|
_state.object_id = object_id
|
|
if not (self._should_include(_state) and self.is_valid_state(_state)):
|
|
break
|
|
valid_since = self._state_valid_since(_state)
|
|
return valid_since
|
|
|
|
@override
|
|
def _async_unload(self) -> None:
|
|
"""Unsubscribe from listeners."""
|
|
for cb in self._on_unload:
|
|
cb()
|
|
self._on_unload.clear()
|
|
|
|
def _should_include(self, _state: State) -> bool:
|
|
"""Check if an entity should participate in any/all 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 condition relies on.
|
|
"""
|
|
return _state.state not in self._excluded_states
|
|
|
|
@abc.abstractmethod
|
|
def is_valid_state(self, entity_state: State) -> bool:
|
|
"""Check if the state matches the expected state(s)."""
|
|
|
|
def _check_any_match_state(self, states: list[State]) -> bool:
|
|
"""Test if any entity matches the state."""
|
|
if not self._duration:
|
|
# Skip duration check if duration is not specified or 0
|
|
return any(self.is_valid_state(state) for state in states)
|
|
cutoff = dt_util.utcnow() - self._duration
|
|
if not self._needs_duration_tracking:
|
|
return any(
|
|
self.is_valid_state(state) and state.last_changed <= cutoff
|
|
for state in states
|
|
)
|
|
return any(
|
|
self.is_valid_state(state)
|
|
and (valid_since := self._valid_since.get(state.entity_id)) is not None
|
|
and valid_since <= cutoff
|
|
for state in states
|
|
)
|
|
|
|
def _check_all_match_state(self, states: list[State]) -> bool:
|
|
"""Test if all entities match the state."""
|
|
if not self._duration:
|
|
# Skip duration check if duration is not specified or 0
|
|
return all(self.is_valid_state(state) for state in states)
|
|
cutoff = dt_util.utcnow() - self._duration
|
|
if not self._needs_duration_tracking:
|
|
return all(
|
|
self.is_valid_state(state) and state.last_changed <= cutoff
|
|
for state in states
|
|
)
|
|
return all(
|
|
self.is_valid_state(state)
|
|
and (valid_since := self._valid_since.get(state.entity_id)) is not None
|
|
and valid_since <= cutoff
|
|
for state in states
|
|
)
|
|
|
|
@override
|
|
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
|
|
"""Test state condition."""
|
|
targeted_entities = async_extract_referenced_entity_ids(
|
|
self._hass,
|
|
self._target_selection,
|
|
expand_group=False,
|
|
primary_entities_only=self._primary_entities_only,
|
|
)
|
|
referenced_entity_ids = targeted_entities.referenced.union(
|
|
targeted_entities.indirectly_referenced
|
|
)
|
|
filtered_entity_ids = self.entity_filter(referenced_entity_ids)
|
|
entity_states = [
|
|
_state
|
|
for entity_id in filtered_entity_ids
|
|
if (_state := self._hass.states.get(entity_id))
|
|
and self._should_include(_state)
|
|
]
|
|
return self._matcher(entity_states)
|
|
|
|
|
|
class EntityStateConditionBase(EntityConditionBase):
|
|
"""State condition."""
|
|
|
|
_states: set[str | bool]
|
|
|
|
@property
|
|
@override
|
|
def _needs_duration_tracking(self) -> bool:
|
|
"""Single-state conditions with no attribute tracking can use last_changed."""
|
|
if len(self._states) != 1:
|
|
return True
|
|
return any(
|
|
spec.value_source is not None for spec in self._domain_specs.values()
|
|
)
|
|
|
|
def _get_tracked_value(self, entity_state: State) -> Any:
|
|
"""Get the tracked value from a state based on the DomainSpec."""
|
|
domain_spec = self._domain_specs[entity_state.domain]
|
|
if domain_spec.value_source is None:
|
|
return entity_state.state
|
|
return entity_state.attributes.get(domain_spec.value_source)
|
|
|
|
@override
|
|
def is_valid_state(self, entity_state: State) -> bool:
|
|
"""Check if the state matches the expected state(s)."""
|
|
return self._get_tracked_value(entity_state) in self._states
|
|
|
|
|
|
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_state_condition(
|
|
domain_specs: Mapping[str, DomainSpec] | str,
|
|
states: str | bool | set[str | bool],
|
|
*,
|
|
primary_entities_only: bool = True,
|
|
) -> type[EntityStateConditionBase]:
|
|
"""Create a condition for entity state changes to specific state(s).
|
|
|
|
domain_specs can be a string (domain name) for simple state-based conditions,
|
|
or a Mapping[str, DomainSpec] for attribute-based or multi-domain conditions.
|
|
"""
|
|
specs = _normalize_domain_specs(domain_specs)
|
|
|
|
if isinstance(states, (str, bool)):
|
|
states_set: set[str | bool] = {states}
|
|
else:
|
|
states_set = states
|
|
|
|
class CustomCondition(EntityStateConditionBase):
|
|
"""Condition for entity state."""
|
|
|
|
_domain_specs = specs
|
|
_states = states_set
|
|
_primary_entities_only = primary_entities_only
|
|
|
|
return CustomCondition
|
|
|
|
|
|
NUMERICAL_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
|
|
{
|
|
vol.Required(CONF_OPTIONS): {
|
|
vol.Required("threshold"): NumericThresholdSelector(
|
|
NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS)
|
|
),
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
class EntityNumericalConditionBase(EntityConditionBase):
|
|
"""Condition for numerical state comparisons with above/below thresholds."""
|
|
|
|
_schema = NUMERICAL_CONDITION_SCHEMA
|
|
_valid_unit: str | UndefinedType | None = UNDEFINED
|
|
|
|
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
|
"""Initialize the numerical condition."""
|
|
super().__init__(hass, config)
|
|
if TYPE_CHECKING:
|
|
assert config.options is not None
|
|
threshold_options: dict[str, Any] = config.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 condition."""
|
|
if isinstance(self._valid_unit, UndefinedType):
|
|
return True
|
|
return unit == self._valid_unit
|
|
|
|
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
|
|
"""Get threshold value from float or entity state."""
|
|
if threshold is None:
|
|
return None
|
|
if threshold.numerical:
|
|
return threshold.number
|
|
|
|
if not (entity_state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
|
|
# Entity not found
|
|
return None
|
|
if not self._is_valid_unit(
|
|
entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
|
|
):
|
|
# Entity unit does not match the expected unit
|
|
return None
|
|
try:
|
|
return float(entity_state.state)
|
|
except TypeError, ValueError:
|
|
# Entity state is not a valid number
|
|
return None
|
|
|
|
def _get_tracked_value(self, entity_state: State) -> Any:
|
|
"""Get the tracked value from a state.
|
|
|
|
Includes unit validation for state-based values.
|
|
"""
|
|
domain_spec = self._domain_specs[entity_state.domain]
|
|
if domain_spec.value_source is None:
|
|
if not self._is_valid_unit(
|
|
entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
|
|
):
|
|
return None
|
|
return entity_state.state
|
|
return entity_state.attributes.get(domain_spec.value_source)
|
|
|
|
@override
|
|
def is_valid_state(self, entity_state: State) -> bool:
|
|
"""Check if the state is within the specified range."""
|
|
try:
|
|
value = float(self._get_tracked_value(entity_state))
|
|
except TypeError, ValueError:
|
|
return False
|
|
|
|
if self._threshold_type == NumericThresholdType.ABOVE:
|
|
if (limit := self._get_threshold_value(self.threshold)) is None:
|
|
# Entity not found or invalid number, don't trigger
|
|
return False
|
|
return value > limit
|
|
if self._threshold_type == NumericThresholdType.BELOW:
|
|
if (limit := self._get_threshold_value(self.threshold)) is None:
|
|
# Entity not found or invalid number, don't trigger
|
|
return False
|
|
return value < limit
|
|
|
|
# Mode is BETWEEN or OUTSIDE
|
|
lower_limit = self._get_threshold_value(self.lower_threshold)
|
|
upper_limit = self._get_threshold_value(self.upper_threshold)
|
|
if lower_limit is None or upper_limit is None:
|
|
# Entity not found or invalid number, don't trigger
|
|
return False
|
|
between = lower_limit <= value <= upper_limit
|
|
if self._threshold_type == NumericThresholdType.BETWEEN:
|
|
return between
|
|
return not between
|
|
|
|
|
|
def make_entity_numerical_condition(
|
|
domain_specs: Mapping[str, DomainSpec] | str,
|
|
valid_unit: str | UndefinedType | None = UNDEFINED,
|
|
*,
|
|
primary_entities_only: bool = True,
|
|
) -> type[EntityNumericalConditionBase]:
|
|
"""Create a condition for numerical state comparisons."""
|
|
specs = _normalize_domain_specs(domain_specs)
|
|
|
|
class CustomCondition(EntityNumericalConditionBase):
|
|
"""Condition for numerical state."""
|
|
|
|
_domain_specs = specs
|
|
_valid_unit = valid_unit
|
|
_primary_entities_only = primary_entities_only
|
|
|
|
return CustomCondition
|
|
|
|
|
|
def _make_numerical_condition_with_unit_schema(
|
|
unit_converter: type[BaseUnitConverter],
|
|
) -> vol.Schema:
|
|
"""Factory for numerical condition schema with unit option."""
|
|
return ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
|
|
{
|
|
vol.Required(CONF_OPTIONS): {
|
|
vol.Required("threshold"): NumericThresholdSelector(
|
|
NumericThresholdSelectorConfig(
|
|
mode=NumericThresholdMode.IS,
|
|
unit_of_measurement=list(unit_converter.VALID_UNITS),
|
|
)
|
|
),
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase):
|
|
"""Condition for numerical state comparisons with unit conversion."""
|
|
|
|
_base_unit: str | None # Base unit for the tracked value
|
|
_unit_converter: type[BaseUnitConverter]
|
|
|
|
@override
|
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
"""Create a schema."""
|
|
super().__init_subclass__(**kwargs)
|
|
cls._schema = _make_numerical_condition_with_unit_schema(cls._unit_converter)
|
|
|
|
def _get_entity_unit(self, entity_state: State) -> str | None:
|
|
"""Get the unit of an entity from its state."""
|
|
return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
|
|
|
|
@override
|
|
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> 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 (entity_state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
|
|
# Entity not found
|
|
return None
|
|
try:
|
|
value = float(entity_state.state)
|
|
except TypeError, ValueError:
|
|
# Entity state is not a valid number
|
|
return None
|
|
|
|
try:
|
|
return self._unit_converter.convert(
|
|
value,
|
|
entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT),
|
|
self._base_unit,
|
|
)
|
|
except HomeAssistantError:
|
|
# Unit conversion failed (i.e. incompatible units), treat as invalid number
|
|
return None
|
|
|
|
@override
|
|
def _get_tracked_value(self, entity_state: State) -> Any:
|
|
"""Get the tracked numerical value from a state."""
|
|
domain_spec = self._domain_specs[entity_state.domain]
|
|
raw_value: Any
|
|
if domain_spec.value_source is None:
|
|
raw_value = entity_state.state
|
|
else:
|
|
raw_value = entity_state.attributes.get(domain_spec.value_source)
|
|
|
|
try:
|
|
value = float(raw_value)
|
|
except TypeError, ValueError:
|
|
return None
|
|
|
|
try:
|
|
return self._unit_converter.convert(
|
|
value, self._get_entity_unit(entity_state), self._base_unit
|
|
)
|
|
except HomeAssistantError:
|
|
return None
|
|
|
|
|
|
def make_entity_numerical_condition_with_unit(
|
|
domain_specs: Mapping[str, DomainSpec],
|
|
base_unit: str,
|
|
unit_converter: type[BaseUnitConverter],
|
|
) -> type[EntityNumericalConditionWithUnitBase]:
|
|
"""Create a condition for numerical state comparisons with unit conversion."""
|
|
|
|
class CustomCondition(EntityNumericalConditionWithUnitBase):
|
|
"""Condition for numerical state with unit conversion."""
|
|
|
|
_domain_specs = domain_specs
|
|
_base_unit = base_unit
|
|
_unit_converter = unit_converter
|
|
|
|
return CustomCondition
|
|
|
|
|
|
class ConditionProtocol(Protocol):
|
|
"""Define the format of condition modules."""
|
|
|
|
async def async_get_conditions(
|
|
self, hass: HomeAssistant
|
|
) -> dict[str, type[Condition]]:
|
|
"""Return the conditions provided by this integration."""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ConditionConfig:
|
|
"""Condition config."""
|
|
|
|
options: dict[str, Any] | None = None
|
|
target: dict[str, Any] | None = None
|
|
|
|
|
|
class ConditionCheckParams(TypedDict, total=False):
|
|
"""Condition check params."""
|
|
|
|
variables: TemplateVarsType
|
|
|
|
|
|
type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool]
|
|
type ConditionCheckerTypeOptional = Callable[
|
|
[HomeAssistant, TemplateVarsType], bool | None
|
|
]
|
|
|
|
|
|
def condition_trace_append(variables: TemplateVarsType, path: str) -> TraceElement:
|
|
"""Append a TraceElement to trace[path]."""
|
|
trace_element = TraceElement(variables, path)
|
|
trace_append_element(trace_element)
|
|
return trace_element
|
|
|
|
|
|
def condition_trace_set_result(result: bool, **kwargs: Any) -> None:
|
|
"""Set the result of TraceElement at the top of the stack."""
|
|
node = trace_stack_top(trace_stack_cv)
|
|
|
|
# The condition function may be called directly, in which case tracing
|
|
# is not setup
|
|
if not node:
|
|
return
|
|
|
|
node.set_result(result=result, **kwargs)
|
|
|
|
|
|
def condition_trace_update_result(**kwargs: Any) -> None:
|
|
"""Update the result of TraceElement at the top of the stack."""
|
|
node = trace_stack_top(trace_stack_cv)
|
|
|
|
# The condition function may be called directly, in which case tracing
|
|
# is not setup
|
|
if not node:
|
|
return
|
|
|
|
node.update_result(**kwargs)
|
|
|
|
|
|
class trace_condition:
|
|
"""Trace condition evaluation."""
|
|
|
|
__slots__ = ("_should_pop", "_trace_element", "_variables")
|
|
|
|
_should_pop: bool
|
|
_trace_element: TraceElement
|
|
|
|
def __init__(self, variables: TemplateVarsType) -> None:
|
|
"""Store the variables for the trace element."""
|
|
self._variables = variables
|
|
|
|
def __enter__(self) -> TraceElement:
|
|
"""Start tracing the condition evaluation."""
|
|
should_pop = True
|
|
trace_element = trace_stack_top(trace_stack_cv)
|
|
if trace_element and trace_element.reuse_by_child:
|
|
should_pop = False
|
|
trace_element.reuse_by_child = False
|
|
else:
|
|
trace_element = condition_trace_append(self._variables, trace_path_get())
|
|
trace_stack_push(trace_stack_cv, trace_element)
|
|
self._should_pop = should_pop
|
|
self._trace_element = trace_element
|
|
return trace_element
|
|
|
|
def __exit__(
|
|
self, exc_type: object, exc_val: BaseException | None, exc_tb: object
|
|
) -> None:
|
|
"""Finish tracing the condition evaluation."""
|
|
try:
|
|
if exc_val is not None and isinstance(exc_val, Exception):
|
|
self._trace_element.set_error(exc_val)
|
|
finally:
|
|
if self._should_pop:
|
|
trace_stack_pop(trace_stack_cv)
|
|
|
|
|
|
@overload
|
|
def trace_condition_function(
|
|
condition: ConditionCheckerType,
|
|
) -> ConditionCheckerType: ...
|
|
|
|
|
|
@overload
|
|
def trace_condition_function(
|
|
condition: ConditionCheckerTypeOptional,
|
|
) -> ConditionCheckerTypeOptional: ...
|
|
|
|
|
|
def trace_condition_function(
|
|
condition: ConditionCheckerType | ConditionCheckerTypeOptional,
|
|
) -> ConditionCheckerType | ConditionCheckerTypeOptional:
|
|
"""Wrap a condition function to enable basic tracing."""
|
|
|
|
@ft.wraps(condition)
|
|
def wrapper(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool | None:
|
|
"""Trace condition."""
|
|
with trace_condition(variables):
|
|
result = condition(hass, variables)
|
|
condition_trace_update_result(result=result)
|
|
return result
|
|
|
|
return wrapper
|
|
|
|
|
|
async def _async_get_condition_platform(
|
|
hass: HomeAssistant, condition_key: str
|
|
) -> tuple[str, ConditionProtocol | None]:
|
|
platform_and_sub_type = condition_key.split(".")
|
|
platform: str | None = platform_and_sub_type[0]
|
|
platform = _PLATFORM_ALIASES.get(platform, platform)
|
|
if platform is None:
|
|
return "", None
|
|
|
|
try:
|
|
integration = await async_get_integration(hass, platform)
|
|
except IntegrationNotFound:
|
|
raise HomeAssistantError(
|
|
f'Invalid condition "{condition_key}" specified'
|
|
) from None
|
|
try:
|
|
platform_module = await integration.async_get_platform("condition")
|
|
except ImportError:
|
|
raise HomeAssistantError(
|
|
f"Integration '{platform}' does not provide condition support"
|
|
) from None
|
|
|
|
# Ensure conditions are registered so descriptions can be loaded
|
|
await _register_condition_platform(hass, platform, platform_module)
|
|
|
|
return platform, platform_module
|
|
|
|
|
|
async def async_from_config(
|
|
hass: HomeAssistant,
|
|
config: ConfigType,
|
|
) -> ConditionChecker:
|
|
"""Turn a condition configuration into a method.
|
|
|
|
Should be run on the event loop.
|
|
"""
|
|
# Check if condition is not enabled
|
|
if CONF_ENABLED in config:
|
|
enabled = config[CONF_ENABLED]
|
|
if isinstance(enabled, Template):
|
|
try:
|
|
enabled = enabled.async_render(limited=True)
|
|
except TemplateError as err:
|
|
raise HomeAssistantError(
|
|
f"Error rendering condition enabled template: {err}"
|
|
) from err
|
|
if not enabled:
|
|
disabled_checker = DisabledConditionChecker(hass)
|
|
await disabled_checker.async_setup()
|
|
return disabled_checker
|
|
|
|
condition_key: str = config[CONF_CONDITION]
|
|
factory: Any = None
|
|
platform_domain, platform = await _async_get_condition_platform(hass, condition_key)
|
|
|
|
if platform is not None:
|
|
condition_descriptors = await platform.async_get_conditions(hass)
|
|
relative_condition_key = get_relative_description_key(
|
|
platform_domain, condition_key
|
|
)
|
|
condition_cls = condition_descriptors[relative_condition_key]
|
|
condition = condition_cls(
|
|
hass,
|
|
ConditionConfig(
|
|
options=config.get(CONF_OPTIONS),
|
|
target=config.get(CONF_TARGET),
|
|
),
|
|
)
|
|
await condition.async_setup()
|
|
return condition
|
|
|
|
for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT):
|
|
factory = getattr(sys.modules[__name__], fmt.format(condition_key), None)
|
|
|
|
if factory:
|
|
break
|
|
|
|
# Check for partials to properly determine if coroutine function
|
|
check_factory = factory
|
|
while isinstance(check_factory, ft.partial):
|
|
check_factory = check_factory.func
|
|
|
|
checker: ConditionChecker | ConditionCheckerType
|
|
if inspect.iscoroutinefunction(check_factory):
|
|
checker = await factory(hass, config)
|
|
else:
|
|
checker = factory(config)
|
|
if not isinstance(checker, ConditionChecker):
|
|
checker = LegacyConditionChecker(hass, checker)
|
|
await checker.async_setup()
|
|
return checker
|
|
|
|
|
|
async def async_and_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionChecker:
|
|
"""Create multi condition matcher using 'AND'."""
|
|
checks = [await async_from_config(hass, entry) for entry in config["conditions"]]
|
|
return AndConditionChecker(hass, checks)
|
|
|
|
|
|
class AndConditionChecker(CompoundConditionChecker):
|
|
"""Condition checker for 'and' compound conditions."""
|
|
|
|
@callback
|
|
@override
|
|
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
|
|
"""Test and condition."""
|
|
errors = []
|
|
for index, condition in enumerate(self._conditions):
|
|
try:
|
|
with trace_path(["conditions", str(index)]):
|
|
if condition.async_check(**kwargs) is False:
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex(
|
|
"and", index=index, total=len(self._conditions), error=ex
|
|
)
|
|
)
|
|
|
|
# Raise the errors if no check was false
|
|
if errors:
|
|
raise ConditionErrorContainer("and", errors=errors)
|
|
|
|
return True
|
|
|
|
|
|
async def async_or_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionChecker:
|
|
"""Create multi condition matcher using 'OR'."""
|
|
checks = [await async_from_config(hass, entry) for entry in config["conditions"]]
|
|
return OrConditionChecker(hass, checks)
|
|
|
|
|
|
class OrConditionChecker(CompoundConditionChecker):
|
|
"""Condition checker for 'or' compound conditions."""
|
|
|
|
@callback
|
|
@override
|
|
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
|
|
"""Test or condition."""
|
|
errors = []
|
|
for index, condition in enumerate(self._conditions):
|
|
try:
|
|
with trace_path(["conditions", str(index)]):
|
|
if condition.async_check(**kwargs) is True:
|
|
return True
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex(
|
|
"or", index=index, total=len(self._conditions), error=ex
|
|
)
|
|
)
|
|
|
|
# Raise the errors if no check was true
|
|
if errors:
|
|
raise ConditionErrorContainer("or", errors=errors)
|
|
|
|
return False
|
|
|
|
|
|
async def async_not_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionChecker:
|
|
"""Create multi condition matcher using 'NOT'."""
|
|
checks = [await async_from_config(hass, entry) for entry in config["conditions"]]
|
|
return NotConditionChecker(hass, checks)
|
|
|
|
|
|
class NotConditionChecker(CompoundConditionChecker):
|
|
"""Condition checker for 'not' compound conditions."""
|
|
|
|
@callback
|
|
@override
|
|
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
|
|
"""Test not condition."""
|
|
errors = []
|
|
for index, condition in enumerate(self._conditions):
|
|
try:
|
|
with trace_path(["conditions", str(index)]):
|
|
if condition.async_check(**kwargs):
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex(
|
|
"not", index=index, total=len(self._conditions), error=ex
|
|
)
|
|
)
|
|
|
|
# Raise the errors if no check was true
|
|
if errors:
|
|
raise ConditionErrorContainer("not", errors=errors)
|
|
|
|
return True
|
|
|
|
|
|
def numeric_state(
|
|
hass: HomeAssistant,
|
|
entity: str | State | None,
|
|
below: float | str | None = None,
|
|
above: float | str | None = None,
|
|
value_template: Template | None = None,
|
|
variables: TemplateVarsType = None,
|
|
) -> bool:
|
|
"""Test a numeric state condition."""
|
|
return run_callback_threadsafe(
|
|
hass.loop,
|
|
async_numeric_state,
|
|
hass,
|
|
entity,
|
|
below,
|
|
above,
|
|
value_template,
|
|
variables,
|
|
).result()
|
|
|
|
|
|
def async_numeric_state(
|
|
hass: HomeAssistant,
|
|
entity: str | State | None,
|
|
below: float | str | None = None,
|
|
above: float | str | None = None,
|
|
value_template: Template | None = None,
|
|
variables: TemplateVarsType = None,
|
|
attribute: str | None = None,
|
|
) -> bool:
|
|
"""Test a numeric state condition."""
|
|
if entity is None:
|
|
raise ConditionErrorMessage("numeric_state", "no entity specified")
|
|
|
|
if isinstance(entity, str):
|
|
entity_id = entity
|
|
|
|
if (entity := hass.states.get(entity)) is None:
|
|
raise ConditionErrorMessage("numeric_state", f"unknown entity {entity_id}")
|
|
else:
|
|
entity_id = entity.entity_id
|
|
|
|
if attribute is not None and attribute not in entity.attributes:
|
|
condition_trace_set_result(
|
|
False,
|
|
message=f"attribute '{attribute}' of entity {entity_id} does not exist",
|
|
)
|
|
return False
|
|
|
|
value: Any = None
|
|
if value_template is None:
|
|
if attribute is None:
|
|
value = entity.state
|
|
else:
|
|
value = entity.attributes.get(attribute)
|
|
else:
|
|
variables = dict(variables or {})
|
|
variables["state"] = entity
|
|
try:
|
|
value = value_template.async_render(variables)
|
|
except TemplateError as ex:
|
|
raise ConditionErrorMessage(
|
|
"numeric_state", f"template error: {ex}"
|
|
) from ex
|
|
|
|
# Known states or attribute values that never match the numeric condition
|
|
if value in (None, STATE_UNAVAILABLE, STATE_UNKNOWN):
|
|
condition_trace_set_result(
|
|
False,
|
|
message=f"value '{value}' is non-numeric and treated as False",
|
|
)
|
|
return False
|
|
|
|
try:
|
|
fvalue = float(value)
|
|
except (ValueError, TypeError) as ex:
|
|
raise ConditionErrorMessage(
|
|
"numeric_state",
|
|
f"entity {entity_id} state '{value}' cannot be processed as a number",
|
|
) from ex
|
|
|
|
if below is not None:
|
|
if isinstance(below, str):
|
|
if not (below_entity := hass.states.get(below)):
|
|
raise ConditionErrorMessage(
|
|
"numeric_state", f"unknown 'below' entity {below}"
|
|
)
|
|
if below_entity.state in (
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
):
|
|
return False
|
|
try:
|
|
if fvalue >= float(below_entity.state):
|
|
condition_trace_set_result(
|
|
False,
|
|
state=fvalue,
|
|
wanted_state_below=float(below_entity.state),
|
|
)
|
|
return False
|
|
except (ValueError, TypeError) as ex:
|
|
raise ConditionErrorMessage(
|
|
"numeric_state",
|
|
(
|
|
f"the 'below' entity {below} state '{below_entity.state}'"
|
|
" cannot be processed as a number"
|
|
),
|
|
) from ex
|
|
elif fvalue >= below:
|
|
condition_trace_set_result(False, state=fvalue, wanted_state_below=below)
|
|
return False
|
|
|
|
if above is not None:
|
|
if isinstance(above, str):
|
|
if not (above_entity := hass.states.get(above)):
|
|
raise ConditionErrorMessage(
|
|
"numeric_state", f"unknown 'above' entity {above}"
|
|
)
|
|
if above_entity.state in (
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
):
|
|
return False
|
|
try:
|
|
if fvalue <= float(above_entity.state):
|
|
condition_trace_set_result(
|
|
False,
|
|
state=fvalue,
|
|
wanted_state_above=float(above_entity.state),
|
|
)
|
|
return False
|
|
except (ValueError, TypeError) as ex:
|
|
raise ConditionErrorMessage(
|
|
"numeric_state",
|
|
(
|
|
f"the 'above' entity {above} state '{above_entity.state}'"
|
|
" cannot be processed as a number"
|
|
),
|
|
) from ex
|
|
elif fvalue <= above:
|
|
condition_trace_set_result(False, state=fvalue, wanted_state_above=above)
|
|
return False
|
|
|
|
condition_trace_set_result(True, state=fvalue)
|
|
return True
|
|
|
|
|
|
def async_numeric_state_from_config(config: ConfigType) -> ConditionCheckerType:
|
|
"""Wrap action method with state based condition."""
|
|
entity_ids = config.get(CONF_ENTITY_ID, [])
|
|
attribute = config.get(CONF_ATTRIBUTE)
|
|
below = config.get(CONF_BELOW)
|
|
above = config.get(CONF_ABOVE)
|
|
value_template = config.get(CONF_VALUE_TEMPLATE)
|
|
|
|
def if_numeric_state(
|
|
hass: HomeAssistant, variables: TemplateVarsType = None
|
|
) -> bool:
|
|
"""Test numeric state condition."""
|
|
errors = []
|
|
for index, entity_id in enumerate(entity_ids):
|
|
try:
|
|
with trace_path(["entity_id", str(index)]), trace_condition(variables):
|
|
if not async_numeric_state(
|
|
hass,
|
|
entity_id,
|
|
below,
|
|
above,
|
|
value_template,
|
|
variables,
|
|
attribute,
|
|
):
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex(
|
|
"numeric_state", index=index, total=len(entity_ids), error=ex
|
|
)
|
|
)
|
|
|
|
# Raise the errors if no check was false
|
|
if errors:
|
|
raise ConditionErrorContainer("numeric_state", errors=errors)
|
|
|
|
return True
|
|
|
|
return if_numeric_state
|
|
|
|
|
|
def state(
|
|
hass: HomeAssistant,
|
|
entity: str | State | None,
|
|
req_state: Any,
|
|
for_period: timedelta | None = None,
|
|
attribute: str | None = None,
|
|
variables: TemplateVarsType = None,
|
|
) -> bool:
|
|
"""Test if state matches requirements.
|
|
|
|
Async friendly.
|
|
"""
|
|
if entity is None:
|
|
raise ConditionErrorMessage("state", "no entity specified")
|
|
|
|
if isinstance(entity, str):
|
|
entity_id = entity
|
|
|
|
if (entity := hass.states.get(entity)) is None:
|
|
raise ConditionErrorMessage("state", f"unknown entity {entity_id}")
|
|
else:
|
|
entity_id = entity.entity_id
|
|
|
|
if attribute is not None and attribute not in entity.attributes:
|
|
condition_trace_set_result(
|
|
False,
|
|
message=f"attribute '{attribute}' of entity {entity_id} does not exist",
|
|
)
|
|
return False
|
|
|
|
assert isinstance(entity, State)
|
|
|
|
if attribute is None:
|
|
value: Any = entity.state
|
|
else:
|
|
value = entity.attributes.get(attribute)
|
|
|
|
if not isinstance(req_state, list):
|
|
req_state = [req_state]
|
|
|
|
is_state = False
|
|
state_value: Any = None
|
|
for req_state_value in req_state:
|
|
state_value = req_state_value
|
|
if (
|
|
isinstance(req_state_value, str)
|
|
and INPUT_ENTITY_ID.match(req_state_value) is not None
|
|
):
|
|
if not (state_entity := hass.states.get(req_state_value)):
|
|
raise ConditionErrorMessage(
|
|
"state", f"the 'state' entity {req_state_value} is unavailable"
|
|
)
|
|
state_value = state_entity.state
|
|
is_state = value == state_value
|
|
if is_state:
|
|
break
|
|
|
|
if for_period is None or not is_state:
|
|
condition_trace_set_result(is_state, state=value, wanted_state=state_value)
|
|
return is_state
|
|
|
|
try:
|
|
for_period = cv.positive_time_period(render_complex(for_period, variables))
|
|
except TemplateError as ex:
|
|
raise ConditionErrorMessage("state", f"template error: {ex}") from ex
|
|
except vol.Invalid as ex:
|
|
raise ConditionErrorMessage("state", f"schema error: {ex}") from ex
|
|
|
|
duration = dt_util.utcnow() - cast(timedelta, for_period)
|
|
duration_ok = duration > entity.last_changed
|
|
condition_trace_set_result(duration_ok, state=value, duration=duration)
|
|
return duration_ok
|
|
|
|
|
|
def state_from_config(config: ConfigType) -> ConditionCheckerType:
|
|
"""Wrap action method with state based condition."""
|
|
entity_ids = config.get(CONF_ENTITY_ID, [])
|
|
req_states: str | list[str] = config.get(CONF_STATE, [])
|
|
for_period = config.get(CONF_FOR)
|
|
attribute = config.get(CONF_ATTRIBUTE)
|
|
match = config.get(CONF_MATCH, ENTITY_MATCH_ALL)
|
|
|
|
if not isinstance(req_states, list):
|
|
req_states = [req_states]
|
|
|
|
def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
|
"""Test if condition."""
|
|
errors = []
|
|
result: bool = match != ENTITY_MATCH_ANY
|
|
for index, entity_id in enumerate(entity_ids):
|
|
try:
|
|
with trace_path(["entity_id", str(index)]), trace_condition(variables):
|
|
if state(
|
|
hass, entity_id, req_states, for_period, attribute, variables
|
|
):
|
|
result = True
|
|
elif match == ENTITY_MATCH_ALL:
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex(
|
|
"state", index=index, total=len(entity_ids), error=ex
|
|
)
|
|
)
|
|
|
|
# Raise the errors if no check was false
|
|
if errors:
|
|
raise ConditionErrorContainer("state", errors=errors)
|
|
|
|
return result
|
|
|
|
return if_state
|
|
|
|
|
|
def template(
|
|
hass: HomeAssistant, value_template: Template, variables: TemplateVarsType = None
|
|
) -> bool:
|
|
"""Test if template condition matches."""
|
|
return run_callback_threadsafe(
|
|
hass.loop, async_template, hass, value_template, variables
|
|
).result()
|
|
|
|
|
|
def async_template(
|
|
hass: HomeAssistant,
|
|
value_template: Template,
|
|
variables: TemplateVarsType = None,
|
|
trace_result: bool = True,
|
|
) -> bool:
|
|
"""Test if template condition matches."""
|
|
try:
|
|
info = value_template.async_render_to_info(variables, parse_result=False)
|
|
value = info.result()
|
|
except TemplateError as ex:
|
|
raise ConditionErrorMessage("template", str(ex)) from ex
|
|
|
|
result = value.lower() == "true"
|
|
if trace_result:
|
|
condition_trace_set_result(result, entities=list(info.entities))
|
|
return result
|
|
|
|
|
|
def async_template_from_config(config: ConfigType) -> ConditionCheckerType:
|
|
"""Wrap action method with state based condition."""
|
|
value_template = cast(Template, config.get(CONF_VALUE_TEMPLATE))
|
|
|
|
def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
|
"""Validate template based if-condition."""
|
|
return async_template(hass, value_template, variables)
|
|
|
|
return template_if
|
|
|
|
|
|
def time(
|
|
hass: HomeAssistant,
|
|
before: dt_time | str | None = None,
|
|
after: dt_time | str | None = None,
|
|
weekday: str | Container[str] | None = None,
|
|
) -> bool:
|
|
"""Test if local time condition matches.
|
|
|
|
Handle the fact that time is continuous and we may be testing for
|
|
a period that crosses midnight. In that case it is easier to test
|
|
for the opposite. "(23:59 <= now < 00:01)" would be the same as
|
|
"not (00:01 <= now < 23:59)".
|
|
"""
|
|
from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415
|
|
|
|
now = dt_util.now()
|
|
now_time = now.time()
|
|
|
|
if after is None:
|
|
after = dt_time(0)
|
|
elif isinstance(after, str):
|
|
if not (after_entity := hass.states.get(after)):
|
|
raise ConditionErrorMessage("time", f"unknown 'after' entity {after}")
|
|
if after_entity.domain == "input_datetime":
|
|
after = dt_time(
|
|
after_entity.attributes.get("hour", 23),
|
|
after_entity.attributes.get("minute", 59),
|
|
after_entity.attributes.get("second", 59),
|
|
)
|
|
elif after_entity.domain == "time" and after_entity.state not in (
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
):
|
|
after = datetime.strptime(after_entity.state, "%H:%M:%S").time()
|
|
elif (
|
|
after_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
|
|
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
|
|
) and after_entity.state not in (
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
):
|
|
after_datetime = dt_util.parse_datetime(after_entity.state)
|
|
if after_datetime is None:
|
|
return False
|
|
after = dt_util.as_local(after_datetime).time()
|
|
else:
|
|
return False
|
|
|
|
if before is None:
|
|
before = dt_time(23, 59, 59, 999999)
|
|
elif isinstance(before, str):
|
|
if not (before_entity := hass.states.get(before)):
|
|
raise ConditionErrorMessage("time", f"unknown 'before' entity {before}")
|
|
if before_entity.domain == "input_datetime":
|
|
before = dt_time(
|
|
before_entity.attributes.get("hour", 23),
|
|
before_entity.attributes.get("minute", 59),
|
|
before_entity.attributes.get("second", 59),
|
|
)
|
|
elif before_entity.domain == "time":
|
|
try:
|
|
before = datetime.strptime(before_entity.state, "%H:%M:%S").time()
|
|
except ValueError:
|
|
return False
|
|
elif (
|
|
before_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
|
|
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
|
|
) and before_entity.state not in (
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
):
|
|
before_timedatime = dt_util.parse_datetime(before_entity.state)
|
|
if before_timedatime is None:
|
|
return False
|
|
before = dt_util.as_local(before_timedatime).time()
|
|
else:
|
|
return False
|
|
|
|
if after < before:
|
|
condition_trace_update_result(after=after, now_time=now_time, before=before)
|
|
if not after <= now_time < before:
|
|
return False
|
|
else:
|
|
condition_trace_update_result(after=after, now_time=now_time, before=before)
|
|
if before <= now_time < after:
|
|
return False
|
|
|
|
if weekday is not None:
|
|
now_weekday = WEEKDAYS[now.weekday()]
|
|
|
|
condition_trace_update_result(weekday=weekday, now_weekday=now_weekday)
|
|
if (
|
|
isinstance(weekday, str) and weekday != now_weekday
|
|
) or now_weekday not in weekday:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def time_from_config(config: ConfigType) -> ConditionCheckerType:
|
|
"""Wrap action method with time based condition."""
|
|
before = config.get(CONF_BEFORE)
|
|
after = config.get(CONF_AFTER)
|
|
weekday = config.get(CONF_WEEKDAY)
|
|
|
|
def time_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
|
"""Validate time based if-condition."""
|
|
return time(hass, before, after, weekday)
|
|
|
|
return time_if
|
|
|
|
|
|
async def async_trigger_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionCheckerType:
|
|
"""Test a trigger condition."""
|
|
trigger_id = config[CONF_ID]
|
|
|
|
def trigger_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
|
"""Validate trigger based if-condition."""
|
|
return (
|
|
variables is not None
|
|
and "trigger" in variables
|
|
and variables["trigger"].get("id") in trigger_id
|
|
)
|
|
|
|
return trigger_if
|
|
|
|
|
|
def numeric_state_validate_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate numeric_state condition config."""
|
|
|
|
registry = er.async_get(hass)
|
|
config = dict(config)
|
|
config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
|
|
registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID])
|
|
)
|
|
return config
|
|
|
|
|
|
def state_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType:
|
|
"""Validate state condition config."""
|
|
|
|
registry = er.async_get(hass)
|
|
config = dict(config)
|
|
config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
|
|
registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID])
|
|
)
|
|
return config
|
|
|
|
|
|
async def async_validate_condition_config(
|
|
hass: HomeAssistant, config: ConfigType | str
|
|
) -> ConfigType:
|
|
"""Validate config."""
|
|
if isinstance(config, str):
|
|
config = {
|
|
CONF_CONDITION: "template",
|
|
CONF_VALUE_TEMPLATE: cv.dynamic_template(config),
|
|
}
|
|
condition_key: str = config[CONF_CONDITION]
|
|
|
|
if condition_key in ("and", "not", "or"):
|
|
conditions = []
|
|
for sub_cond in config["conditions"]:
|
|
sub_cond = await async_validate_condition_config(hass, sub_cond)
|
|
conditions.append(sub_cond)
|
|
config["conditions"] = conditions
|
|
return config
|
|
|
|
platform_domain, platform = await _async_get_condition_platform(hass, condition_key)
|
|
|
|
if platform is not None:
|
|
condition_descriptors = await platform.async_get_conditions(hass)
|
|
relative_condition_key = get_relative_description_key(
|
|
platform_domain, condition_key
|
|
)
|
|
if not (condition_class := condition_descriptors.get(relative_condition_key)):
|
|
raise vol.Invalid(f"Invalid condition '{condition_key}' specified")
|
|
return await condition_class.async_validate_complete_config(hass, config)
|
|
|
|
config = move_options_fields_to_top_level(config, _CONDITION_BASE_SCHEMA)
|
|
|
|
if condition_key in ("numeric_state", "state"):
|
|
validator = cast(
|
|
Callable[[HomeAssistant, ConfigType], ConfigType],
|
|
getattr(
|
|
sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition_key)
|
|
),
|
|
)
|
|
return validator(hass, config)
|
|
|
|
return config
|
|
|
|
|
|
async def async_validate_conditions_config(
|
|
hass: HomeAssistant, conditions: list[ConfigType]
|
|
) -> list[ConfigType | Template]:
|
|
"""Validate config."""
|
|
# No gather here because async_validate_condition_config is unlikely
|
|
# to suspend and the overhead of creating many tasks is not worth it
|
|
return [await async_validate_condition_config(hass, cond) for cond in conditions]
|
|
|
|
|
|
async def async_conditions_from_config(
|
|
hass: HomeAssistant,
|
|
condition_configs: list[ConfigType],
|
|
logger: logging.Logger,
|
|
name: str,
|
|
) -> ConditionsChecker:
|
|
"""AND all conditions."""
|
|
checks = [
|
|
await async_from_config(hass, condition_config)
|
|
for condition_config in condition_configs
|
|
]
|
|
return ConditionsChecker(checks, logger, name)
|
|
|
|
|
|
class ConditionsChecker:
|
|
"""Condition checker that ANDs multiple conditions.
|
|
|
|
Used by automations and template entities. Unlike AndConditionChecker,
|
|
this logs warnings on errors instead of raising, and uses "condition"
|
|
as the trace path prefix.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
conditions: list[ConditionChecker],
|
|
logger: logging.Logger,
|
|
name: str,
|
|
) -> None:
|
|
"""Initialize condition checker."""
|
|
self._conditions = conditions
|
|
self._logger = logger
|
|
self._name = name
|
|
self._unloaded = False
|
|
|
|
def __call__(self, variables: TemplateVarsType = None) -> bool:
|
|
"""Check all conditions."""
|
|
return self.async_check(variables=variables)
|
|
|
|
def __del__(self) -> None:
|
|
"""Clean up when the checker is deleted."""
|
|
if self._unloaded:
|
|
return
|
|
try:
|
|
self.async_unload()
|
|
except Exception:
|
|
_LOGGER.exception("Error while unloading condition checker")
|
|
|
|
def async_unload(self) -> None:
|
|
"""Clean up child conditions."""
|
|
self._unloaded = True
|
|
for condition in self._conditions:
|
|
condition.async_unload()
|
|
|
|
def async_check(
|
|
self, *, variables: TemplateVarsType = None, **kwargs: Never
|
|
) -> bool:
|
|
"""AND all conditions."""
|
|
errors: list[ConditionErrorIndex] = []
|
|
for index, condition in enumerate(self._conditions):
|
|
try:
|
|
with trace_path(["condition", str(index)]):
|
|
if condition.async_check(variables=variables, **kwargs) is False:
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex(
|
|
"condition", index=index, total=len(self._conditions), error=ex
|
|
)
|
|
)
|
|
|
|
if errors:
|
|
self._logger.warning(
|
|
"Error evaluating condition in '%s':\n%s",
|
|
self._name,
|
|
ConditionErrorContainer("condition", errors=errors),
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
@callback
|
|
def async_extract_entities(config: ConfigType | Template) -> set[str]:
|
|
"""Extract entities from a condition."""
|
|
referenced: set[str] = set()
|
|
to_process = deque([config])
|
|
|
|
while to_process:
|
|
config = to_process.popleft()
|
|
if isinstance(config, Template):
|
|
continue
|
|
|
|
condition = config[CONF_CONDITION]
|
|
|
|
if condition in ("and", "not", "or"):
|
|
to_process.extend(config["conditions"])
|
|
continue
|
|
|
|
if condition == "time":
|
|
# The before and after options can be a time or an entity id.
|
|
for key in (CONF_AFTER, CONF_BEFORE):
|
|
if isinstance(value := config.get(key), str) and valid_entity_id(value):
|
|
referenced.add(value)
|
|
continue
|
|
|
|
if condition == "zone":
|
|
options = config.get(CONF_OPTIONS, {})
|
|
referenced.update(options.get(CONF_ENTITY_ID, []))
|
|
referenced.update(options.get(CONF_ZONE, []))
|
|
|
|
elif condition in (
|
|
"zone.in_zone",
|
|
"zone.not_in_zone",
|
|
"zone.occupancy_is_detected",
|
|
"zone.occupancy_is_not_detected",
|
|
):
|
|
if zone_entity_id := config.get(CONF_OPTIONS, {}).get(CONF_ZONE):
|
|
referenced.add(zone_entity_id)
|
|
|
|
entity_ids = config.get(CONF_ENTITY_ID)
|
|
|
|
if isinstance(entity_ids, str):
|
|
entity_ids = [entity_ids]
|
|
|
|
if entity_ids is not None:
|
|
referenced.update(entity_ids)
|
|
|
|
if target_entities := _get_targets_from_condition_config(
|
|
config, CONF_ENTITY_ID
|
|
):
|
|
referenced.update(target_entities)
|
|
|
|
return referenced
|
|
|
|
|
|
@callback
|
|
def async_extract_devices(config: ConfigType | Template) -> set[str]:
|
|
"""Extract devices from a condition."""
|
|
referenced: set[str] = set()
|
|
to_process = deque([config])
|
|
|
|
while to_process:
|
|
config = to_process.popleft()
|
|
if isinstance(config, Template):
|
|
continue
|
|
|
|
condition = config[CONF_CONDITION]
|
|
|
|
if condition in ("and", "not", "or"):
|
|
to_process.extend(config["conditions"])
|
|
continue
|
|
|
|
if condition == "device":
|
|
if (device_id := config.get(CONF_DEVICE_ID)) is not None:
|
|
referenced.add(device_id)
|
|
continue
|
|
|
|
if target_devices := _get_targets_from_condition_config(config, CONF_DEVICE_ID):
|
|
referenced.update(target_devices)
|
|
|
|
return referenced
|
|
|
|
|
|
@callback
|
|
def async_extract_targets(
|
|
config: ConfigType | Template,
|
|
target_type: Literal["area_id", "floor_id", "label_id"],
|
|
) -> set[str]:
|
|
"""Extract targets from a condition."""
|
|
referenced: set[str] = set()
|
|
to_process = deque([config])
|
|
|
|
while to_process:
|
|
config = to_process.popleft()
|
|
if isinstance(config, Template):
|
|
continue
|
|
|
|
condition = config[CONF_CONDITION]
|
|
|
|
if condition in ("and", "not", "or"):
|
|
to_process.extend(config["conditions"])
|
|
continue
|
|
|
|
if targets := _get_targets_from_condition_config(config, target_type):
|
|
referenced.update(targets)
|
|
|
|
return referenced
|
|
|
|
|
|
@callback
|
|
def _get_targets_from_condition_config(
|
|
config: ConfigType,
|
|
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
|
|
) -> list[str]:
|
|
"""Extract targets from a condition target config."""
|
|
if not (target_conf := config.get(CONF_TARGET)):
|
|
return []
|
|
if not (targets := target_conf.get(target)):
|
|
return []
|
|
|
|
return [targets] if isinstance(targets, str) else targets
|
|
|
|
|
|
def _load_conditions_file(integration: Integration) -> dict[str, Any]:
|
|
"""Load conditions file for an integration."""
|
|
try:
|
|
return cast(
|
|
dict[str, Any],
|
|
_CONDITIONS_DESCRIPTION_SCHEMA(
|
|
load_yaml_dict(str(integration.file_path / "conditions.yaml"))
|
|
),
|
|
)
|
|
except FileNotFoundError:
|
|
_LOGGER.warning(
|
|
"Unable to find conditions.yaml for the %s integration", integration.domain
|
|
)
|
|
return {}
|
|
except (HomeAssistantError, vol.Invalid) as ex:
|
|
_LOGGER.warning(
|
|
"Unable to parse conditions.yaml for the %s integration: %s",
|
|
integration.domain,
|
|
ex,
|
|
)
|
|
return {}
|
|
|
|
|
|
def _load_conditions_files(
|
|
integrations: Iterable[Integration],
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Load condition files for multiple integrations."""
|
|
return {
|
|
integration.domain: {
|
|
get_absolute_description_key(integration.domain, key): value
|
|
for key, value in _load_conditions_file(integration).items()
|
|
}
|
|
for integration in integrations
|
|
}
|
|
|
|
|
|
async def async_get_all_descriptions(
|
|
hass: HomeAssistant,
|
|
) -> dict[str, dict[str, Any] | None]:
|
|
"""Return descriptions (i.e. user documentation) for all conditions."""
|
|
descriptions_cache = hass.data[CONDITION_DESCRIPTION_CACHE]
|
|
|
|
conditions = hass.data[CONDITIONS]
|
|
# See if there are new conditions not seen before.
|
|
# Any condition that we saw before already has an entry in description_cache.
|
|
all_conditions = set(conditions)
|
|
previous_all_conditions = set(descriptions_cache)
|
|
# If the conditions are the same, we can return the cache
|
|
if previous_all_conditions == all_conditions:
|
|
return descriptions_cache
|
|
|
|
# Files we loaded for missing descriptions
|
|
new_conditions_descriptions: dict[str, dict[str, Any]] = {}
|
|
# We try to avoid making a copy in the event the cache is good,
|
|
# but now we must make a copy in case new conditions get added
|
|
# while we are loading the missing ones so we do not
|
|
# add the new ones to the cache without their descriptions
|
|
conditions = conditions.copy()
|
|
|
|
if missing_conditions := all_conditions.difference(descriptions_cache):
|
|
domains_with_missing_conditions = {
|
|
conditions[missing_condition] for missing_condition in missing_conditions
|
|
}
|
|
ints_or_excs = await async_get_integrations(
|
|
hass, domains_with_missing_conditions
|
|
)
|
|
integrations: list[Integration] = []
|
|
for domain, int_or_exc in ints_or_excs.items():
|
|
if type(int_or_exc) is Integration and int_or_exc.has_conditions:
|
|
integrations.append(int_or_exc)
|
|
continue
|
|
if TYPE_CHECKING:
|
|
assert isinstance(int_or_exc, Exception)
|
|
_LOGGER.debug(
|
|
"Failed to load conditions.yaml for integration: %s",
|
|
domain,
|
|
exc_info=int_or_exc,
|
|
)
|
|
|
|
if integrations:
|
|
new_conditions_descriptions = await hass.async_add_executor_job(
|
|
_load_conditions_files, integrations
|
|
)
|
|
|
|
# Make a copy of the old cache and add missing descriptions to it
|
|
new_descriptions_cache = descriptions_cache.copy()
|
|
for missing_condition in missing_conditions:
|
|
domain = conditions[missing_condition]
|
|
if (
|
|
yaml_description := new_conditions_descriptions.get(domain, {}).get(
|
|
missing_condition
|
|
)
|
|
) is None:
|
|
_LOGGER.debug(
|
|
"No condition descriptions found for condition %s, skipping",
|
|
missing_condition,
|
|
)
|
|
new_descriptions_cache[missing_condition] = None
|
|
continue
|
|
|
|
description = {"fields": yaml_description.get("fields", {})}
|
|
if (target := yaml_description.get("target")) is not None:
|
|
description["target"] = target
|
|
|
|
new_descriptions_cache[missing_condition] = description
|
|
|
|
hass.data[CONDITION_DESCRIPTION_CACHE] = new_descriptions_cache
|
|
return new_descriptions_cache
|