core/homeassistant/components/automation/__init__.py

1230 lines
41 KiB
Python

"""Allow to set up simple automation rules via the config file."""
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from functools import partial
import logging
from typing import Any, Protocol, cast
from propcache import cached_property
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_MODE,
ATTR_NAME,
CONF_ALIAS,
CONF_CONDITIONS,
CONF_DEVICE_ID,
CONF_ENTITY_ID,
CONF_EVENT_DATA,
CONF_ID,
CONF_MODE,
CONF_PATH,
CONF_PLATFORM,
CONF_VARIABLES,
CONF_ZONE,
EVENT_HOMEASSISTANT_STARTED,
SERVICE_RELOAD,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_ON,
)
from homeassistant.core import (
CALLBACK_TYPE,
Context,
CoreState,
Event,
HomeAssistant,
ServiceCall,
callback,
split_entity_id,
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.deprecation import (
DeprecatedConstant,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import (
ATTR_CUR,
ATTR_MAX,
CONF_MAX,
CONF_MAX_EXCEEDED,
Script,
ScriptRunResult,
script_stack_cv,
)
from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.service import (
ReloadServiceHelper,
async_register_admin_service,
)
from homeassistant.helpers.trace import (
TraceElement,
script_execution_set,
trace_append_element,
trace_get,
trace_path,
)
from homeassistant.helpers.trigger import (
TriggerActionType,
TriggerData,
TriggerInfo,
async_initialize_triggers,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.dt import parse_datetime
from homeassistant.util.hass_dict import HassKey
from .config import AutomationConfig, ValidationStatus
from .const import (
CONF_ACTIONS,
CONF_INITIAL_STATE,
CONF_TRACE,
CONF_TRIGGER_VARIABLES,
CONF_TRIGGERS,
DEFAULT_INITIAL_STATE,
DOMAIN,
LOGGER,
)
from .helpers import async_get_blueprints
from .trace import trace_automation
DATA_COMPONENT: HassKey[EntityComponent[BaseAutomationEntity]] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_SKIP_CONDITION = "skip_condition"
CONF_STOP_ACTIONS = "stop_actions"
DEFAULT_STOP_ACTIONS = True
EVENT_AUTOMATION_RELOADED = "automation_reloaded"
EVENT_AUTOMATION_TRIGGERED = "automation_triggered"
ATTR_LAST_TRIGGERED = "last_triggered"
ATTR_SOURCE = "source"
ATTR_VARIABLES = "variables"
SERVICE_TRIGGER = "trigger"
class IfAction(Protocol):
"""Define the format of if_action."""
config: list[ConfigType]
def __call__(self, variables: Mapping[str, Any] | None = None) -> bool:
"""AND all conditions."""
# AutomationActionType, AutomationTriggerData,
# and AutomationTriggerInfo are deprecated as of 2022.9.
# Can be removed in 2025.1
_DEPRECATED_AutomationActionType = DeprecatedConstant(
TriggerActionType, "TriggerActionType", "2025.1"
)
_DEPRECATED_AutomationTriggerData = DeprecatedConstant(
TriggerData, "TriggerData", "2025.1"
)
_DEPRECATED_AutomationTriggerInfo = DeprecatedConstant(
TriggerInfo, "TriggerInfo", "2025.1"
)
@bind_hass
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return true if specified automation entity_id is on.
Async friendly.
"""
return hass.states.is_state(entity_id, STATE_ON)
def _automations_with_x(
hass: HomeAssistant, referenced_id: str, property_name: str
) -> list[str]:
"""Return all automations that reference the x."""
if DATA_COMPONENT not in hass.data:
return []
return [
automation_entity.entity_id
for automation_entity in hass.data[DATA_COMPONENT].entities
if referenced_id in getattr(automation_entity, property_name)
]
def _x_in_automation(
hass: HomeAssistant, entity_id: str, property_name: str
) -> list[str]:
"""Return all x in an automation."""
if DATA_COMPONENT not in hass.data:
return []
if (automation_entity := hass.data[DATA_COMPONENT].get_entity(entity_id)) is None:
return []
return list(getattr(automation_entity, property_name))
@callback
def automations_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all automations that reference the entity."""
return _automations_with_x(hass, entity_id, "referenced_entities")
@callback
def entities_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all entities in an automation."""
return _x_in_automation(hass, entity_id, "referenced_entities")
@callback
def automations_with_device(hass: HomeAssistant, device_id: str) -> list[str]:
"""Return all automations that reference the device."""
return _automations_with_x(hass, device_id, "referenced_devices")
@callback
def devices_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all devices in an automation."""
return _x_in_automation(hass, entity_id, "referenced_devices")
@callback
def automations_with_area(hass: HomeAssistant, area_id: str) -> list[str]:
"""Return all automations that reference the area."""
return _automations_with_x(hass, area_id, "referenced_areas")
@callback
def areas_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all areas in an automation."""
return _x_in_automation(hass, entity_id, "referenced_areas")
@callback
def automations_with_floor(hass: HomeAssistant, floor_id: str) -> list[str]:
"""Return all automations that reference the floor."""
return _automations_with_x(hass, floor_id, "referenced_floors")
@callback
def floors_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all floors in an automation."""
return _x_in_automation(hass, entity_id, "referenced_floors")
@callback
def automations_with_label(hass: HomeAssistant, label_id: str) -> list[str]:
"""Return all automations that reference the label."""
return _automations_with_x(hass, label_id, "referenced_labels")
@callback
def labels_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all labels in an automation."""
return _x_in_automation(hass, entity_id, "referenced_labels")
@callback
def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[str]:
"""Return all automations that reference the blueprint."""
if DOMAIN not in hass.data:
return []
return [
automation_entity.entity_id
for automation_entity in hass.data[DATA_COMPONENT].entities
if automation_entity.referenced_blueprint == blueprint_path
]
@callback
def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None:
"""Return the blueprint the automation is based on or None."""
if DATA_COMPONENT not in hass.data:
return None
if (automation_entity := hass.data[DATA_COMPONENT].get_entity(entity_id)) is None:
return None
return automation_entity.referenced_blueprint
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up all automations."""
hass.data[DATA_COMPONENT] = component = EntityComponent[BaseAutomationEntity](
LOGGER, DOMAIN, hass
)
# Register automation as valid domain for Blueprint
async_get_blueprints(hass)
await _async_process_config(hass, config, component)
# Add some default blueprints to blueprints/automation, does nothing
# if blueprints/automation already exists but still has to create
# an executor job to check if the folder exists so we run it in a
# separate task to avoid waiting for it to finish setting up
# since a tracked task will be waited at the end of startup
hass.async_create_task(
async_get_blueprints(hass).async_populate(), eager_start=True
)
async def trigger_service_handler(
entity: BaseAutomationEntity, service_call: ServiceCall
) -> None:
"""Handle forced automation trigger, e.g. from frontend."""
await entity.async_trigger(
{**service_call.data[ATTR_VARIABLES], "trigger": {"platform": None}},
skip_condition=service_call.data[CONF_SKIP_CONDITION],
context=service_call.context,
)
component.async_register_entity_service(
SERVICE_TRIGGER,
{
vol.Optional(ATTR_VARIABLES, default={}): dict,
vol.Optional(CONF_SKIP_CONDITION, default=True): bool,
},
trigger_service_handler,
)
component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle")
component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
component.async_register_entity_service(
SERVICE_TURN_OFF,
{vol.Optional(CONF_STOP_ACTIONS, default=DEFAULT_STOP_ACTIONS): cv.boolean},
"async_turn_off",
)
async def reload_service_handler(service_call: ServiceCall) -> None:
"""Remove all automations and load new ones from config."""
await async_get_blueprints(hass).async_reset_cache()
if (conf := await component.async_prepare_reload(skip_reset=True)) is None:
return
if automation_id := service_call.data.get(CONF_ID):
await _async_process_single_config(hass, conf, component, automation_id)
else:
await _async_process_config(hass, conf, component)
hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context)
def reload_targets(service_call: ServiceCall) -> set[str | None]:
if automation_id := service_call.data.get(CONF_ID):
return {automation_id}
return {automation.unique_id for automation in component.entities}
reload_helper = ReloadServiceHelper(reload_service_handler, reload_targets)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_RELOAD,
reload_helper.execute_service,
schema=vol.Schema({vol.Optional(CONF_ID): str}),
)
websocket_api.async_register_command(hass, websocket_config)
return True
class BaseAutomationEntity(ToggleEntity, ABC):
"""Base class for automation entities."""
_entity_component_unrecorded_attributes = frozenset(
(ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID)
)
raw_config: ConfigType | None
@property
def capability_attributes(self) -> dict[str, Any] | None:
"""Return capability attributes."""
if self.unique_id is not None:
return {CONF_ID: self.unique_id}
return None
@cached_property
@abstractmethod
def referenced_labels(self) -> set[str]:
"""Return a set of referenced labels."""
@cached_property
@abstractmethod
def referenced_floors(self) -> set[str]:
"""Return a set of referenced floors."""
@cached_property
@abstractmethod
def referenced_areas(self) -> set[str]:
"""Return a set of referenced areas."""
@property
@abstractmethod
def referenced_blueprint(self) -> str | None:
"""Return referenced blueprint or None."""
@cached_property
@abstractmethod
def referenced_devices(self) -> set[str]:
"""Return a set of referenced devices."""
@cached_property
@abstractmethod
def referenced_entities(self) -> set[str]:
"""Return a set of referenced entities."""
@abstractmethod
async def async_trigger(
self,
run_variables: dict[str, Any],
context: Context | None = None,
skip_condition: bool = False,
) -> ScriptRunResult | None:
"""Trigger automation."""
class UnavailableAutomationEntity(BaseAutomationEntity):
"""A non-functional automation entity with its state set to unavailable.
This class is instantiated when an automation fails to validate.
"""
_attr_should_poll = False
_attr_available = False
def __init__(
self,
automation_id: str | None,
name: str,
raw_config: ConfigType | None,
validation_error: str,
validation_status: ValidationStatus,
) -> None:
"""Initialize an automation entity."""
self._attr_name = name
self._attr_unique_id = automation_id
self.raw_config = raw_config
self._validation_error = validation_error
self._validation_status = validation_status
@cached_property
def referenced_labels(self) -> set[str]:
"""Return a set of referenced labels."""
return set()
@cached_property
def referenced_floors(self) -> set[str]:
"""Return a set of referenced floors."""
return set()
@cached_property
def referenced_areas(self) -> set[str]:
"""Return a set of referenced areas."""
return set()
@property
def referenced_blueprint(self) -> str | None:
"""Return referenced blueprint or None."""
return None
@cached_property
def referenced_devices(self) -> set[str]:
"""Return a set of referenced devices."""
return set()
@cached_property
def referenced_entities(self) -> set[str]:
"""Return a set of referenced entities."""
return set()
async def async_added_to_hass(self) -> None:
"""Create a repair issue to notify the user the automation has errors."""
await super().async_added_to_hass()
async_create_issue(
self.hass,
DOMAIN,
f"{self.entity_id}_validation_{self._validation_status}",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key=f"validation_{self._validation_status}",
translation_placeholders={
"edit": f"/config/automation/edit/{self.unique_id}",
"entity_id": self.entity_id,
"error": self._validation_error,
"name": self._attr_name or self.entity_id,
},
)
async def async_will_remove_from_hass(self) -> None:
await super().async_will_remove_from_hass()
async_delete_issue(
self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}"
)
async def async_trigger(
self,
run_variables: dict[str, Any],
context: Context | None = None,
skip_condition: bool = False,
) -> None:
"""Trigger automation."""
class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Entity to show status of entity."""
_attr_should_poll = False
def __init__(
self,
automation_id: str | None,
name: str,
trigger_config: list[ConfigType],
cond_func: IfAction | None,
action_script: Script,
initial_state: bool | None,
variables: ScriptVariables | None,
trigger_variables: ScriptVariables | None,
raw_config: ConfigType | None,
blueprint_inputs: ConfigType | None,
trace_config: ConfigType,
) -> None:
"""Initialize an automation entity."""
self._attr_name = name
self._trigger_config = trigger_config
self._async_detach_triggers: CALLBACK_TYPE | None = None
self._cond_func = cond_func
self.action_script = action_script
self.action_script.change_listener = self.async_write_ha_state
self._initial_state = initial_state
self._is_enabled = False
self._logger = LOGGER
self._variables = variables
self._trigger_variables = trigger_variables
self.raw_config = raw_config
self._blueprint_inputs = blueprint_inputs
self._trace_config = trace_config
self._attr_unique_id = automation_id
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the entity state attributes."""
attrs = {
ATTR_LAST_TRIGGERED: self.action_script.last_triggered,
ATTR_MODE: self.action_script.script_mode,
ATTR_CUR: self.action_script.runs,
}
if self.action_script.supports_max:
attrs[ATTR_MAX] = self.action_script.max_runs
return attrs
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self._async_detach_triggers is not None or self._is_enabled
@property
def referenced_labels(self) -> set[str]:
"""Return a set of referenced labels."""
return self.action_script.referenced_labels
@property
def referenced_floors(self) -> set[str]:
"""Return a set of referenced floors."""
return self.action_script.referenced_floors
@cached_property
def referenced_areas(self) -> set[str]:
"""Return a set of referenced areas."""
return self.action_script.referenced_areas
@property
def referenced_blueprint(self) -> str | None:
"""Return referenced blueprint or None."""
if self._blueprint_inputs is None:
return None
return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH])
@cached_property
def referenced_devices(self) -> set[str]:
"""Return a set of referenced devices."""
referenced = self.action_script.referenced_devices
if self._cond_func is not None:
for conf in self._cond_func.config:
referenced |= condition.async_extract_devices(conf)
for conf in self._trigger_config:
referenced |= set(_trigger_extract_devices(conf))
return referenced
@cached_property
def referenced_entities(self) -> set[str]:
"""Return a set of referenced entities."""
referenced = self.action_script.referenced_entities
if self._cond_func is not None:
for conf in self._cond_func.config:
referenced |= condition.async_extract_entities(conf)
for conf in self._trigger_config:
for entity_id in _trigger_extract_entities(conf):
referenced.add(entity_id)
return referenced
async def async_added_to_hass(self) -> None:
"""Startup with initial state or previous state."""
await super().async_added_to_hass()
self._logger = logging.getLogger(
f"{__name__}.{split_entity_id(self.entity_id)[1]}"
)
self.action_script.update_logger(self._logger)
if state := await self.async_get_last_state():
enable_automation = state.state == STATE_ON
last_triggered = state.attributes.get("last_triggered")
if last_triggered is not None:
self.action_script.last_triggered = parse_datetime(last_triggered)
self._logger.debug(
"Loaded automation %s with state %s from state storage last state %s",
self.entity_id,
enable_automation,
state,
)
else:
enable_automation = DEFAULT_INITIAL_STATE
self._logger.debug(
"Automation %s not in state storage, state %s from default is used",
self.entity_id,
enable_automation,
)
if self._initial_state is not None:
enable_automation = self._initial_state
self._logger.debug(
"Automation %s initial state %s overridden from config initial_state",
self.entity_id,
enable_automation,
)
if enable_automation:
await self._async_enable()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on and update the state."""
await self._async_enable()
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
if CONF_STOP_ACTIONS in kwargs:
await self._async_disable(kwargs[CONF_STOP_ACTIONS])
else:
await self._async_disable()
self.async_write_ha_state()
async def async_trigger(
self,
run_variables: dict[str, Any],
context: Context | None = None,
skip_condition: bool = False,
) -> ScriptRunResult | None:
"""Trigger automation.
This method is a coroutine.
"""
reason = ""
alias = ""
if "trigger" in run_variables:
if "description" in run_variables["trigger"]:
reason = f' by {run_variables["trigger"]["description"]}'
if "alias" in run_variables["trigger"]:
alias = f' trigger \'{run_variables["trigger"]["alias"]}\''
self._logger.debug("Automation%s triggered%s", alias, reason)
# Create a new context referring to the old context.
parent_id = None if context is None else context.id
trigger_context = Context(parent_id=parent_id)
with trace_automation(
self.hass,
self.unique_id,
self.raw_config,
self._blueprint_inputs,
trigger_context,
self._trace_config,
) as automation_trace:
this = None
if state := self.hass.states.get(self.entity_id):
this = state.as_dict()
variables: dict[str, Any] = {"this": this, **(run_variables or {})}
if self._variables:
try:
variables = self._variables.async_render(self.hass, variables)
except TemplateError as err:
self._logger.error("Error rendering variables: %s", err)
automation_trace.set_error(err)
return None
# Prepare tracing the automation
automation_trace.set_trace(trace_get())
# Set trigger reason
trigger_description = variables.get("trigger", {}).get("description")
automation_trace.set_trigger_description(trigger_description)
# Add initial variables as the trigger step
if "trigger" in variables and "idx" in variables["trigger"]:
trigger_path = f"trigger/{variables['trigger']['idx']}"
else:
trigger_path = "trigger"
trace_element = TraceElement(variables, trigger_path)
trace_append_element(trace_element)
if (
not skip_condition
and self._cond_func is not None
and not self._cond_func(variables)
):
self._logger.debug(
"Conditions not met, aborting automation. Condition summary: %s",
trace_get(clear=False),
)
script_execution_set("failed_conditions")
return None
self.async_set_context(trigger_context)
event_data = {
ATTR_NAME: self.name,
ATTR_ENTITY_ID: self.entity_id,
}
if "trigger" in variables and "description" in variables["trigger"]:
event_data[ATTR_SOURCE] = variables["trigger"]["description"]
@callback
def started_action() -> None:
# This is always a callback from a coro so there is no
# risk of this running in a thread which allows us to use
# async_fire_internal
self.hass.bus.async_fire_internal(
EVENT_AUTOMATION_TRIGGERED, event_data, context=trigger_context
)
# Make a new empty script stack; automations are allowed
# to recursively trigger themselves
script_stack_cv.set([])
try:
with trace_path("action"):
return await self.action_script.async_run(
variables, trigger_context, started_action
)
except ServiceNotFound as err:
async_create_issue(
self.hass,
DOMAIN,
f"{self.entity_id}_service_not_found_{err.domain}.{err.service}",
is_fixable=True,
is_persistent=True,
severity=IssueSeverity.ERROR,
translation_key="service_not_found",
translation_placeholders={
"service": f"{err.domain}.{err.service}",
"entity_id": self.entity_id,
"name": self._attr_name or self.entity_id,
"edit": f"/config/automation/edit/{self.unique_id}",
},
)
automation_trace.set_error(err)
except (vol.Invalid, HomeAssistantError) as err:
self._logger.error(
"Error while executing automation %s: %s",
self.entity_id,
err,
)
automation_trace.set_error(err)
except Exception as err:
self._logger.exception("While executing automation %s", self.entity_id)
automation_trace.set_error(err)
return None
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
async def _async_enable_automation(self, event: Event) -> None:
"""Start automation on startup."""
# Don't do anything if no longer enabled or already attached
if not self._is_enabled or self._async_detach_triggers is not None:
return
self._async_detach_triggers = await self._async_attach_triggers(True)
self.async_write_ha_state()
async def _async_enable(self) -> None:
"""Enable this automation entity.
This method is not expected to write state to the
state machine.
"""
if self._is_enabled:
return
self._is_enabled = True
# HomeAssistant is starting up
if self.hass.state is not CoreState.not_running:
self._async_detach_triggers = await self._async_attach_triggers(False)
return
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED,
self._async_enable_automation,
)
async def _async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None:
"""Disable the automation entity.
This method is not expected to write state to the
state machine.
"""
if not self._is_enabled and not self.action_script.runs:
return
self._is_enabled = False
if self._async_detach_triggers is not None:
self._async_detach_triggers()
self._async_detach_triggers = None
if stop_actions:
await self.action_script.async_stop()
def _log_callback(self, level: int, msg: str, **kwargs: Any) -> None:
"""Log helper callback."""
self._logger.log(level, "%s %s", msg, self.name, **kwargs)
async def _async_trigger_if_enabled(
self,
run_variables: dict[str, Any],
context: Context | None = None,
skip_condition: bool = False,
) -> ScriptRunResult | None:
"""Trigger automation if enabled.
If the trigger starts but has a delay, the automation will be triggered
when the delay has passed so we need to make sure its still enabled before
executing the action.
"""
if not self._is_enabled:
return None
return await self.async_trigger(run_variables, context, skip_condition)
async def _async_attach_triggers(
self, home_assistant_start: bool
) -> Callable[[], None] | None:
"""Set up the triggers."""
this = None
if state := self.hass.states.get(self.entity_id):
this = state.as_dict()
variables = {"this": this}
if self._trigger_variables:
try:
variables = self._trigger_variables.async_render(
self.hass,
variables,
limited=True,
)
except TemplateError as err:
self._logger.error("Error rendering trigger variables: %s", err)
return None
return await async_initialize_triggers(
self.hass,
self._trigger_config,
self._async_trigger_if_enabled,
DOMAIN,
str(self.name),
self._log_callback,
home_assistant_start,
variables,
)
@dataclass(slots=True)
class AutomationEntityConfig:
"""Container for prepared automation entity configuration."""
config_block: ConfigType
list_no: int
raw_blueprint_inputs: ConfigType | None
raw_config: ConfigType | None
validation_error: str | None
validation_status: ValidationStatus
async def _prepare_automation_config(
hass: HomeAssistant,
config: ConfigType,
wanted_automation_id: str | None,
) -> list[AutomationEntityConfig]:
"""Parse configuration and prepare automation entity configuration."""
automation_configs: list[AutomationEntityConfig] = []
conf: list[ConfigType] = config[DOMAIN]
for list_no, config_block in enumerate(conf):
automation_id: str | None = config_block.get(CONF_ID)
if wanted_automation_id is not None and automation_id != wanted_automation_id:
continue
raw_config = cast(AutomationConfig, config_block).raw_config
raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs
validation_error = cast(AutomationConfig, config_block).validation_error
validation_status = cast(AutomationConfig, config_block).validation_status
automation_configs.append(
AutomationEntityConfig(
config_block,
list_no,
raw_blueprint_inputs,
raw_config,
validation_error,
validation_status,
)
)
return automation_configs
def _automation_name(automation_config: AutomationEntityConfig) -> str:
"""Return the configured name of an automation."""
config_block = automation_config.config_block
list_no = automation_config.list_no
return config_block.get(CONF_ALIAS) or f"{DOMAIN} {list_no}"
async def _create_automation_entities(
hass: HomeAssistant, automation_configs: list[AutomationEntityConfig]
) -> list[BaseAutomationEntity]:
"""Create automation entities from prepared configuration."""
entities: list[BaseAutomationEntity] = []
for automation_config in automation_configs:
config_block = automation_config.config_block
automation_id: str | None = config_block.get(CONF_ID)
name = _automation_name(automation_config)
if automation_config.validation_status != ValidationStatus.OK:
entities.append(
UnavailableAutomationEntity(
automation_id,
name,
automation_config.raw_config,
cast(str, automation_config.validation_error),
automation_config.validation_status,
)
)
continue
initial_state: bool | None = config_block.get(CONF_INITIAL_STATE)
action_script = Script(
hass,
config_block[CONF_ACTIONS],
name,
DOMAIN,
running_description="automation actions",
script_mode=config_block[CONF_MODE],
max_runs=config_block[CONF_MAX],
max_exceeded=config_block[CONF_MAX_EXCEEDED],
logger=LOGGER,
# We don't pass variables here
# Automation will already render them to use them in the condition
# and so will pass them on to the script.
)
if CONF_CONDITIONS in config_block:
cond_func = await _async_process_if(hass, name, config_block)
if cond_func is None:
continue
else:
cond_func = None
# Add trigger variables to variables
variables = None
if CONF_TRIGGER_VARIABLES in config_block and CONF_VARIABLES in config_block:
variables = ScriptVariables(
dict(config_block[CONF_TRIGGER_VARIABLES].as_dict())
)
variables.variables.update(config_block[CONF_VARIABLES].as_dict())
elif CONF_TRIGGER_VARIABLES in config_block:
variables = config_block[CONF_TRIGGER_VARIABLES]
elif CONF_VARIABLES in config_block:
variables = config_block[CONF_VARIABLES]
entity = AutomationEntity(
automation_id,
name,
config_block[CONF_TRIGGERS],
cond_func,
action_script,
initial_state,
variables,
config_block.get(CONF_TRIGGER_VARIABLES),
automation_config.raw_config,
automation_config.raw_blueprint_inputs,
config_block[CONF_TRACE],
)
entities.append(entity)
return entities
async def _async_process_config(
hass: HomeAssistant,
config: dict[str, Any],
component: EntityComponent[BaseAutomationEntity],
) -> None:
"""Process config and add automations."""
def automation_matches_config(
automation: BaseAutomationEntity, config: AutomationEntityConfig
) -> bool:
name = _automation_name(config)
return automation.name == name and automation.raw_config == config.raw_config
def find_matches(
automations: list[BaseAutomationEntity],
automation_configs: list[AutomationEntityConfig],
) -> tuple[set[int], set[int]]:
"""Find matches between a list of automation entities and a list of configurations.
An automation or configuration is only allowed to match at most once to handle
the case of multiple automations with identical configuration.
Returns a tuple of sets of indices: ({automation_matches}, {config_matches})
"""
automation_matches: set[int] = set()
config_matches: set[int] = set()
automation_configs_with_id: dict[str, tuple[int, AutomationEntityConfig]] = {}
automation_configs_without_id: list[tuple[int, AutomationEntityConfig]] = []
for config_idx, automation_config in enumerate(automation_configs):
if automation_id := automation_config.config_block.get(CONF_ID):
automation_configs_with_id[automation_id] = (
config_idx,
automation_config,
)
continue
automation_configs_without_id.append((config_idx, automation_config))
for automation_idx, automation in enumerate(automations):
if automation.unique_id:
if automation.unique_id not in automation_configs_with_id:
continue
config_idx, automation_config = automation_configs_with_id.pop(
automation.unique_id
)
if automation_matches_config(automation, automation_config):
automation_matches.add(automation_idx)
config_matches.add(config_idx)
continue
for config_idx, automation_config in automation_configs_without_id:
if config_idx in config_matches:
# Only allow an automation config to match at most once
continue
if automation_matches_config(automation, automation_config):
automation_matches.add(automation_idx)
config_matches.add(config_idx)
# Only allow an automation to match at most once
break
return automation_matches, config_matches
automation_configs = await _prepare_automation_config(hass, config, None)
automations: list[BaseAutomationEntity] = list(component.entities)
# Find automations and configurations which have matches
automation_matches, config_matches = find_matches(automations, automation_configs)
# Remove automations which have changed config or no longer exist
tasks = [
automation.async_remove()
for idx, automation in enumerate(automations)
if idx not in automation_matches
]
await asyncio.gather(*tasks)
# Create automations which have changed config or have been added
updated_automation_configs = [
config
for idx, config in enumerate(automation_configs)
if idx not in config_matches
]
entities = await _create_automation_entities(hass, updated_automation_configs)
await component.async_add_entities(entities)
def _automation_matches_config(
automation: BaseAutomationEntity | None, config: AutomationEntityConfig | None
) -> bool:
"""Return False if an automation's config has been changed."""
if not automation:
return False
if not config:
return False
name = _automation_name(config)
return automation.name == name and automation.raw_config == config.raw_config
async def _async_process_single_config(
hass: HomeAssistant,
config: dict[str, Any],
component: EntityComponent[BaseAutomationEntity],
automation_id: str,
) -> None:
"""Process config and add a single automation."""
automation_configs = await _prepare_automation_config(hass, config, automation_id)
automation = next(
(x for x in component.entities if x.unique_id == automation_id), None
)
automation_config = automation_configs[0] if automation_configs else None
if _automation_matches_config(automation, automation_config):
return
if automation:
await automation.async_remove()
entities = await _create_automation_entities(hass, automation_configs)
await component.async_add_entities(entities)
async def _async_process_if(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> IfAction | None:
"""Process if checks."""
if_configs = config[CONF_CONDITIONS]
try:
if_action = await condition.async_conditions_from_config(
hass, if_configs, LOGGER, name
)
except HomeAssistantError as ex:
LOGGER.warning("Invalid condition: %s", ex)
return None
result: IfAction = if_action # type: ignore[assignment]
result.config = if_configs
return result
@callback
def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
"""Extract devices from a trigger config."""
if trigger_conf[CONF_PLATFORM] == "device":
return [trigger_conf[CONF_DEVICE_ID]]
if (
trigger_conf[CONF_PLATFORM] == "event"
and CONF_EVENT_DATA in trigger_conf
and CONF_DEVICE_ID in trigger_conf[CONF_EVENT_DATA]
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID], str)
):
return [trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID]]
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
return []
@callback
def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
"""Extract entities from a trigger config."""
if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"):
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
if trigger_conf[CONF_PLATFORM] == "calendar":
return [trigger_conf[CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
if trigger_conf[CONF_PLATFORM] == "geo_location":
return [trigger_conf[CONF_ZONE]]
if trigger_conf[CONF_PLATFORM] == "sun":
return ["sun.sun"]
if (
trigger_conf[CONF_PLATFORM] == "event"
and CONF_EVENT_DATA in trigger_conf
and CONF_ENTITY_ID in trigger_conf[CONF_EVENT_DATA]
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID], str)
and valid_entity_id(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID])
):
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
return []
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
def websocket_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get automation config."""
automation = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])
if automation is None:
connection.send_error(
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
)
return
connection.send_result(
msg["id"],
{
"config": automation.raw_config,
},
)
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())