mirror of https://github.com/home-assistant/core
1160 lines
37 KiB
Python
1160 lines
37 KiB
Python
"""Offer reusable conditions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections import deque
|
|
from collections.abc import Callable, Container, Generator
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, time as dt_time, timedelta
|
|
import functools as ft
|
|
import logging
|
|
import re
|
|
import sys
|
|
from typing import Any, Protocol, cast
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components import zone as zone_cmp
|
|
from homeassistant.components.sensor import SensorDeviceClass
|
|
from homeassistant.const import (
|
|
ATTR_DEVICE_CLASS,
|
|
ATTR_GPS_ACCURACY,
|
|
ATTR_LATITUDE,
|
|
ATTR_LONGITUDE,
|
|
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_STATE,
|
|
CONF_VALUE_TEMPLATE,
|
|
CONF_WEEKDAY,
|
|
CONF_ZONE,
|
|
ENTITY_MATCH_ALL,
|
|
ENTITY_MATCH_ANY,
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
SUN_EVENT_SUNRISE,
|
|
SUN_EVENT_SUNSET,
|
|
WEEKDAYS,
|
|
)
|
|
from homeassistant.core import HomeAssistant, State, callback
|
|
from homeassistant.exceptions import (
|
|
ConditionError,
|
|
ConditionErrorContainer,
|
|
ConditionErrorIndex,
|
|
ConditionErrorMessage,
|
|
HomeAssistantError,
|
|
TemplateError,
|
|
)
|
|
from homeassistant.loader import IntegrationNotFound, async_get_integration
|
|
from homeassistant.util.async_ import run_callback_threadsafe
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
from . import config_validation as cv, entity_registry as er
|
|
from .sun import get_astral_event_date
|
|
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 ConfigType, TemplateVarsType
|
|
|
|
ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
|
|
FROM_CONFIG_FORMAT = "{}_from_config"
|
|
VALIDATE_CONFIG_FORMAT = "{}_validate_config"
|
|
|
|
_PLATFORM_ALIASES = {
|
|
"and": None,
|
|
"device": "device_automation",
|
|
"not": None,
|
|
"numeric_state": None,
|
|
"or": None,
|
|
"state": None,
|
|
"sun": None,
|
|
"template": None,
|
|
"time": None,
|
|
"trigger": None,
|
|
"zone": None,
|
|
}
|
|
|
|
INPUT_ENTITY_ID = re.compile(
|
|
r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(?<!_)$"
|
|
)
|
|
|
|
|
|
class ConditionProtocol(Protocol):
|
|
"""Define the format of device_condition modules.
|
|
|
|
Each module must define either CONDITION_SCHEMA or async_validate_condition_config.
|
|
"""
|
|
|
|
CONDITION_SCHEMA: vol.Schema
|
|
|
|
async def async_validate_condition_config(
|
|
self, hass: HomeAssistant, config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate config."""
|
|
|
|
def async_condition_from_config(
|
|
self, hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionCheckerType:
|
|
"""Evaluate state based on configuration."""
|
|
|
|
|
|
type ConditionCheckerType = 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)
|
|
|
|
|
|
@contextmanager
|
|
def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement]:
|
|
"""Trace 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(variables, trace_path_get())
|
|
trace_stack_push(trace_stack_cv, trace_element)
|
|
try:
|
|
yield trace_element
|
|
except Exception as ex:
|
|
trace_element.set_error(ex)
|
|
raise
|
|
finally:
|
|
if should_pop:
|
|
trace_stack_pop(trace_stack_cv)
|
|
|
|
|
|
def trace_condition_function(condition: ConditionCheckerType) -> ConditionCheckerType:
|
|
"""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, config: ConfigType
|
|
) -> ConditionProtocol | None:
|
|
platform = config[CONF_CONDITION]
|
|
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 "{platform}" specified {config}'
|
|
) from None
|
|
try:
|
|
return await integration.async_get_platform("condition")
|
|
except ImportError:
|
|
raise HomeAssistantError(
|
|
f"Integration '{platform}' does not provide condition support"
|
|
) from None
|
|
|
|
|
|
async def async_from_config(
|
|
hass: HomeAssistant,
|
|
config: ConfigType,
|
|
) -> ConditionCheckerType:
|
|
"""Turn a condition configuration into a method.
|
|
|
|
Should be run on the event loop.
|
|
"""
|
|
factory: Any = None
|
|
platform = await _async_get_condition_platform(hass, config)
|
|
|
|
if platform is None:
|
|
condition = config.get(CONF_CONDITION)
|
|
for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT):
|
|
factory = getattr(sys.modules[__name__], fmt.format(condition), None)
|
|
|
|
if factory:
|
|
break
|
|
else:
|
|
factory = platform.async_condition_from_config
|
|
|
|
# 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:
|
|
|
|
@trace_condition_function
|
|
def disabled_condition(
|
|
hass: HomeAssistant, variables: TemplateVarsType = None
|
|
) -> bool | None:
|
|
"""Condition not enabled, will act as if it didn't exist."""
|
|
return None
|
|
|
|
return disabled_condition
|
|
|
|
# Check for partials to properly determine if coroutine function
|
|
check_factory = factory
|
|
while isinstance(check_factory, ft.partial):
|
|
check_factory = check_factory.func
|
|
|
|
if asyncio.iscoroutinefunction(check_factory):
|
|
return cast(ConditionCheckerType, await factory(hass, config))
|
|
return cast(ConditionCheckerType, factory(config))
|
|
|
|
|
|
async def async_and_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionCheckerType:
|
|
"""Create multi condition matcher using 'AND'."""
|
|
checks = [await async_from_config(hass, entry) for entry in config["conditions"]]
|
|
|
|
@trace_condition_function
|
|
def if_and_condition(
|
|
hass: HomeAssistant, variables: TemplateVarsType = None
|
|
) -> bool:
|
|
"""Test and condition."""
|
|
errors = []
|
|
for index, check in enumerate(checks):
|
|
try:
|
|
with trace_path(["conditions", str(index)]):
|
|
if check(hass, variables) is False:
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex("and", index=index, total=len(checks), error=ex)
|
|
)
|
|
|
|
# Raise the errors if no check was false
|
|
if errors:
|
|
raise ConditionErrorContainer("and", errors=errors)
|
|
|
|
return True
|
|
|
|
return if_and_condition
|
|
|
|
|
|
async def async_or_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionCheckerType:
|
|
"""Create multi condition matcher using 'OR'."""
|
|
checks = [await async_from_config(hass, entry) for entry in config["conditions"]]
|
|
|
|
@trace_condition_function
|
|
def if_or_condition(
|
|
hass: HomeAssistant, variables: TemplateVarsType = None
|
|
) -> bool:
|
|
"""Test or condition."""
|
|
errors = []
|
|
for index, check in enumerate(checks):
|
|
try:
|
|
with trace_path(["conditions", str(index)]):
|
|
if check(hass, variables) is True:
|
|
return True
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex("or", index=index, total=len(checks), error=ex)
|
|
)
|
|
|
|
# Raise the errors if no check was true
|
|
if errors:
|
|
raise ConditionErrorContainer("or", errors=errors)
|
|
|
|
return False
|
|
|
|
return if_or_condition
|
|
|
|
|
|
async def async_not_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionCheckerType:
|
|
"""Create multi condition matcher using 'NOT'."""
|
|
checks = [await async_from_config(hass, entry) for entry in config["conditions"]]
|
|
|
|
@trace_condition_function
|
|
def if_not_condition(
|
|
hass: HomeAssistant, variables: TemplateVarsType = None
|
|
) -> bool:
|
|
"""Test not condition."""
|
|
errors = []
|
|
for index, check in enumerate(checks):
|
|
try:
|
|
with trace_path(["conditions", str(index)]):
|
|
if check(hass, variables):
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex("not", index=index, total=len(checks), error=ex)
|
|
)
|
|
|
|
# Raise the errors if no check was true
|
|
if errors:
|
|
raise ConditionErrorContainer("not", errors=errors)
|
|
|
|
return True
|
|
|
|
return if_not_condition
|
|
|
|
|
|
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)
|
|
|
|
@trace_condition_function
|
|
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
|
|
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]
|
|
|
|
@trace_condition_function
|
|
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 sun(
|
|
hass: HomeAssistant,
|
|
before: str | None = None,
|
|
after: str | None = None,
|
|
before_offset: timedelta | None = None,
|
|
after_offset: timedelta | None = None,
|
|
) -> bool:
|
|
"""Test if current time matches sun requirements."""
|
|
utcnow = dt_util.utcnow()
|
|
today = dt_util.as_local(utcnow).date()
|
|
before_offset = before_offset or timedelta(0)
|
|
after_offset = after_offset or timedelta(0)
|
|
|
|
sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today)
|
|
sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today)
|
|
|
|
has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after)
|
|
has_sunset_condition = SUN_EVENT_SUNSET in (before, after)
|
|
|
|
after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date()
|
|
if after_sunrise and has_sunrise_condition:
|
|
tomorrow = today + timedelta(days=1)
|
|
sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow)
|
|
|
|
after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date()
|
|
if after_sunset and has_sunset_condition:
|
|
tomorrow = today + timedelta(days=1)
|
|
sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow)
|
|
|
|
# Special case: before sunrise OR after sunset
|
|
# This will handle the very rare case in the polar region when the sun rises/sets
|
|
# but does not set/rise.
|
|
# However this entire condition does not handle those full days of darkness
|
|
# or light, the following should be used instead:
|
|
#
|
|
# condition:
|
|
# condition: state
|
|
# entity_id: sun.sun
|
|
# state: 'above_horizon' (or 'below_horizon')
|
|
#
|
|
if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET:
|
|
wanted_time_before = cast(datetime, sunrise) + before_offset
|
|
condition_trace_update_result(wanted_time_before=wanted_time_before)
|
|
wanted_time_after = cast(datetime, sunset) + after_offset
|
|
condition_trace_update_result(wanted_time_after=wanted_time_after)
|
|
return utcnow < wanted_time_before or utcnow > wanted_time_after
|
|
|
|
if sunrise is None and has_sunrise_condition:
|
|
# There is no sunrise today
|
|
condition_trace_set_result(False, message="no sunrise today")
|
|
return False
|
|
|
|
if sunset is None and has_sunset_condition:
|
|
# There is no sunset today
|
|
condition_trace_set_result(False, message="no sunset today")
|
|
return False
|
|
|
|
if before == SUN_EVENT_SUNRISE:
|
|
wanted_time_before = cast(datetime, sunrise) + before_offset
|
|
condition_trace_update_result(wanted_time_before=wanted_time_before)
|
|
if utcnow > wanted_time_before:
|
|
return False
|
|
|
|
if before == SUN_EVENT_SUNSET:
|
|
wanted_time_before = cast(datetime, sunset) + before_offset
|
|
condition_trace_update_result(wanted_time_before=wanted_time_before)
|
|
if utcnow > wanted_time_before:
|
|
return False
|
|
|
|
if after == SUN_EVENT_SUNRISE:
|
|
wanted_time_after = cast(datetime, sunrise) + after_offset
|
|
condition_trace_update_result(wanted_time_after=wanted_time_after)
|
|
if utcnow < wanted_time_after:
|
|
return False
|
|
|
|
if after == SUN_EVENT_SUNSET:
|
|
wanted_time_after = cast(datetime, sunset) + after_offset
|
|
condition_trace_update_result(wanted_time_after=wanted_time_after)
|
|
if utcnow < wanted_time_after:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def sun_from_config(config: ConfigType) -> ConditionCheckerType:
|
|
"""Wrap action method with sun based condition."""
|
|
before = config.get("before")
|
|
after = config.get("after")
|
|
before_offset = config.get("before_offset")
|
|
after_offset = config.get("after_offset")
|
|
|
|
@trace_condition_function
|
|
def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
|
"""Validate time based if-condition."""
|
|
return sun(hass, before, after, before_offset, after_offset)
|
|
|
|
return sun_if
|
|
|
|
|
|
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))
|
|
|
|
@trace_condition_function
|
|
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)".
|
|
"""
|
|
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.attributes.get(
|
|
ATTR_DEVICE_CLASS
|
|
) == SensorDeviceClass.TIMESTAMP 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.attributes.get(
|
|
ATTR_DEVICE_CLASS
|
|
) == SensorDeviceClass.TIMESTAMP 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)
|
|
|
|
@trace_condition_function
|
|
def time_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
|
"""Validate time based if-condition."""
|
|
return time(hass, before, after, weekday)
|
|
|
|
return time_if
|
|
|
|
|
|
def zone(
|
|
hass: HomeAssistant,
|
|
zone_ent: str | State | None,
|
|
entity: str | State | None,
|
|
) -> bool:
|
|
"""Test if zone-condition matches.
|
|
|
|
Async friendly.
|
|
"""
|
|
if zone_ent is None:
|
|
raise ConditionErrorMessage("zone", "no zone specified")
|
|
|
|
if isinstance(zone_ent, str):
|
|
zone_ent_id = zone_ent
|
|
|
|
if (zone_ent := hass.states.get(zone_ent)) is None:
|
|
raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}")
|
|
|
|
if entity is None:
|
|
raise ConditionErrorMessage("zone", "no entity specified")
|
|
|
|
if isinstance(entity, str):
|
|
entity_id = entity
|
|
|
|
if (entity := hass.states.get(entity)) is None:
|
|
raise ConditionErrorMessage("zone", f"unknown entity {entity_id}")
|
|
else:
|
|
entity_id = entity.entity_id
|
|
|
|
if entity.state in (
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
):
|
|
return False
|
|
|
|
latitude = entity.attributes.get(ATTR_LATITUDE)
|
|
longitude = entity.attributes.get(ATTR_LONGITUDE)
|
|
|
|
if latitude is None:
|
|
raise ConditionErrorMessage(
|
|
"zone", f"entity {entity_id} has no 'latitude' attribute"
|
|
)
|
|
|
|
if longitude is None:
|
|
raise ConditionErrorMessage(
|
|
"zone", f"entity {entity_id} has no 'longitude' attribute"
|
|
)
|
|
|
|
return zone_cmp.in_zone(
|
|
zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0)
|
|
)
|
|
|
|
|
|
def zone_from_config(config: ConfigType) -> ConditionCheckerType:
|
|
"""Wrap action method with zone based condition."""
|
|
entity_ids = config.get(CONF_ENTITY_ID, [])
|
|
zone_entity_ids = config.get(CONF_ZONE, [])
|
|
|
|
@trace_condition_function
|
|
def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
|
"""Test if condition."""
|
|
errors = []
|
|
|
|
all_ok = True
|
|
for entity_id in entity_ids:
|
|
entity_ok = False
|
|
for zone_entity_id in zone_entity_ids:
|
|
try:
|
|
if zone(hass, zone_entity_id, entity_id):
|
|
entity_ok = True
|
|
except ConditionErrorMessage as ex:
|
|
errors.append(
|
|
ConditionErrorMessage(
|
|
"zone",
|
|
(
|
|
f"error matching {entity_id} with {zone_entity_id}:"
|
|
f" {ex.message}"
|
|
),
|
|
)
|
|
)
|
|
|
|
if not entity_ok:
|
|
all_ok = False
|
|
|
|
# Raise the errors only if no definitive result was found
|
|
if errors and not all_ok:
|
|
raise ConditionErrorContainer("zone", errors=errors)
|
|
|
|
return all_ok
|
|
|
|
return if_in_zone
|
|
|
|
|
|
async def async_trigger_from_config(
|
|
hass: HomeAssistant, config: ConfigType
|
|
) -> ConditionCheckerType:
|
|
"""Test a trigger condition."""
|
|
trigger_id = config[CONF_ID]
|
|
|
|
@trace_condition_function
|
|
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
|
|
) -> ConfigType:
|
|
"""Validate config."""
|
|
condition = config[CONF_CONDITION]
|
|
if condition 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 = await _async_get_condition_platform(hass, config)
|
|
if platform is not None and hasattr(platform, "async_validate_condition_config"):
|
|
return await platform.async_validate_condition_config(hass, config)
|
|
if platform is None and condition in ("numeric_state", "state"):
|
|
validator = cast(
|
|
Callable[[HomeAssistant, ConfigType], ConfigType],
|
|
getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)),
|
|
)
|
|
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,
|
|
) -> Callable[[TemplateVarsType], bool]:
|
|
"""AND all conditions."""
|
|
checks: list[ConditionCheckerType] = [
|
|
await async_from_config(hass, condition_config)
|
|
for condition_config in condition_configs
|
|
]
|
|
|
|
def check_conditions(variables: TemplateVarsType = None) -> bool:
|
|
"""AND all conditions."""
|
|
errors: list[ConditionErrorIndex] = []
|
|
for index, check in enumerate(checks):
|
|
try:
|
|
with trace_path(["condition", str(index)]):
|
|
if check(hass, variables) is False:
|
|
return False
|
|
except ConditionError as ex:
|
|
errors.append(
|
|
ConditionErrorIndex(
|
|
"condition", index=index, total=len(checks), error=ex
|
|
)
|
|
)
|
|
|
|
if errors:
|
|
logger.warning(
|
|
"Error evaluating condition in '%s':\n%s",
|
|
name,
|
|
ConditionErrorContainer("condition", errors=errors),
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
return check_conditions
|
|
|
|
|
|
@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
|
|
|
|
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)
|
|
|
|
return referenced
|
|
|
|
|
|
@callback
|
|
def async_extract_devices(config: ConfigType | Template) -> set[str]:
|
|
"""Extract devices from a condition."""
|
|
referenced = 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":
|
|
continue
|
|
|
|
if (device_id := config.get(CONF_DEVICE_ID)) is not None:
|
|
referenced.add(device_id)
|
|
|
|
return referenced
|