Home Assistant Unofficial Reference 2024.12.1
trigger.py
Go to the documentation of this file.
1 """Triggers."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import defaultdict
7 from collections.abc import Callable, Coroutine
8 from dataclasses import dataclass, field
9 import functools
10 import logging
11 from typing import Any, Protocol, TypedDict, cast
12 
13 import voluptuous as vol
14 
15 from homeassistant.const import (
16  CONF_ALIAS,
17  CONF_ENABLED,
18  CONF_ID,
19  CONF_PLATFORM,
20  CONF_VARIABLES,
21 )
22 from homeassistant.core import (
23  CALLBACK_TYPE,
24  Context,
25  HassJob,
26  HomeAssistant,
27  callback,
28  is_callback,
29 )
30 from homeassistant.exceptions import HomeAssistantError, TemplateError
31 from homeassistant.loader import IntegrationNotFound, async_get_integration
32 from homeassistant.util.async_ import create_eager_task
33 from homeassistant.util.hass_dict import HassKey
34 
35 from .template import Template
36 from .typing import ConfigType, TemplateVarsType
37 
38 _PLATFORM_ALIASES = {
39  "device": "device_automation",
40  "event": "homeassistant",
41  "numeric_state": "homeassistant",
42  "state": "homeassistant",
43  "time_pattern": "homeassistant",
44  "time": "homeassistant",
45 }
46 
47 DATA_PLUGGABLE_ACTIONS: HassKey[defaultdict[tuple, PluggableActionsEntry]] = HassKey(
48  "pluggable_actions"
49 )
50 
51 
52 class TriggerProtocol(Protocol):
53  """Define the format of trigger modules.
54 
55  Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config.
56  """
57 
58  TRIGGER_SCHEMA: vol.Schema
59 
61  self, hass: HomeAssistant, config: ConfigType
62  ) -> ConfigType:
63  """Validate config."""
64 
65  async def async_attach_trigger(
66  self,
67  hass: HomeAssistant,
68  config: ConfigType,
69  action: TriggerActionType,
70  trigger_info: TriggerInfo,
71  ) -> CALLBACK_TYPE:
72  """Attach a trigger."""
73 
74 
75 class TriggerActionType(Protocol):
76  """Protocol type for trigger action callback."""
77 
78  async def __call__(
79  self,
80  run_variables: dict[str, Any],
81  context: Context | None = None,
82  ) -> Any:
83  """Define action callback type."""
84 
85 
86 class TriggerData(TypedDict):
87  """Trigger data."""
88 
89  id: str
90  idx: str
91  alias: str | None
92 
93 
94 class TriggerInfo(TypedDict):
95  """Information about trigger."""
96 
97  domain: str
98  name: str
99  home_assistant_start: bool
100  variables: TemplateVarsType
101  trigger_data: TriggerData
102 
103 
104 @dataclass(slots=True)
106  """Holder to keep track of all plugs and actions for a given trigger."""
107 
108  plugs: set[PluggableAction] = field(default_factory=set)
109  actions: dict[
110  object,
111  tuple[
112  HassJob[[dict[str, Any], Context | None], Coroutine[Any, Any, None]],
113  dict[str, Any],
114  ],
115  ] = field(default_factory=dict)
116 
117 
119  """A pluggable action handler."""
120 
121  _entry: PluggableActionsEntry | None = None
122 
123  def __init__(self, update: CALLBACK_TYPE | None = None) -> None:
124  """Initialize a pluggable action.
125 
126  :param update: callback triggered whenever triggers are attached or removed.
127  """
128  self._update_update = update
129 
130  def __bool__(self) -> bool:
131  """Return if we have something attached."""
132  return bool(self._entry_entry and self._entry_entry.actions)
133 
134  @callback
135  def async_run_update(self) -> None:
136  """Run update function if one exists."""
137  if self._update_update:
138  self._update_update()
139 
140  @staticmethod
141  @callback
142  def async_get_registry(hass: HomeAssistant) -> dict[tuple, PluggableActionsEntry]:
143  """Return the pluggable actions registry."""
144  if data := hass.data.get(DATA_PLUGGABLE_ACTIONS):
145  return data
146  data = hass.data[DATA_PLUGGABLE_ACTIONS] = defaultdict(PluggableActionsEntry)
147  return data
148 
149  @staticmethod
150  @callback
152  hass: HomeAssistant,
153  trigger: dict[str, str],
154  action: TriggerActionType,
155  variables: dict[str, Any],
156  ) -> CALLBACK_TYPE:
157  """Attach an action to a trigger entry.
158 
159  Existing or future plugs registered will be attached.
160  """
161  reg = PluggableAction.async_get_registry(hass)
162  key = tuple(sorted(trigger.items()))
163  entry = reg[key]
164 
165  def _update() -> None:
166  for plug in entry.plugs:
167  plug.async_run_update()
168 
169  @callback
170  def _remove() -> None:
171  """Remove this action attachment, and disconnect all plugs."""
172  del entry.actions[_remove]
173  _update()
174  if not entry.actions and not entry.plugs:
175  del reg[key]
176 
177  job = HassJob(action, f"trigger {trigger} {variables}")
178  entry.actions[_remove] = (job, variables)
179  _update()
180 
181  return _remove
182 
183  @callback
185  self, hass: HomeAssistant, trigger: dict[str, str]
186  ) -> CALLBACK_TYPE:
187  """Register plug in the global plugs dictionary."""
188 
189  reg = PluggableAction.async_get_registry(hass)
190  key = tuple(sorted(trigger.items()))
191  self._entry_entry = reg[key]
192  self._entry_entry.plugs.add(self)
193 
194  @callback
195  def _remove() -> None:
196  """Remove plug from registration.
197 
198  Clean up entry if there are no actions or plugs registered.
199  """
200  assert self._entry_entry
201  self._entry_entry.plugs.remove(self)
202  if not self._entry_entry.actions and not self._entry_entry.plugs:
203  del reg[key]
204  self._entry_entry = None
205 
206  return _remove
207 
208  async def async_run(
209  self, hass: HomeAssistant, context: Context | None = None
210  ) -> None:
211  """Run all actions."""
212  assert self._entry_entry
213  for job, variables in self._entry_entry.actions.values():
214  task = hass.async_run_hass_job(job, variables, context)
215  if task:
216  await task
217 
218 
220  hass: HomeAssistant, config: ConfigType
221 ) -> TriggerProtocol:
222  platform_and_sub_type = config[CONF_PLATFORM].split(".")
223  platform = platform_and_sub_type[0]
224  platform = _PLATFORM_ALIASES.get(platform, platform)
225  try:
226  integration = await async_get_integration(hass, platform)
227  except IntegrationNotFound:
228  raise vol.Invalid(f"Invalid trigger '{platform}' specified") from None
229  try:
230  return await integration.async_get_platform("trigger")
231  except ImportError:
232  raise vol.Invalid(
233  f"Integration '{platform}' does not provide trigger support"
234  ) from None
235 
236 
238  hass: HomeAssistant, trigger_config: list[ConfigType]
239 ) -> list[ConfigType]:
240  """Validate triggers."""
241  config = []
242  for conf in trigger_config:
243  platform = await _async_get_trigger_platform(hass, conf)
244  if hasattr(platform, "async_validate_trigger_config"):
245  conf = await platform.async_validate_trigger_config(hass, conf)
246  else:
247  conf = platform.TRIGGER_SCHEMA(conf)
248  config.append(conf)
249  return config
250 
251 
253  hass: HomeAssistant, action: Callable, conf: ConfigType
254 ) -> Callable:
255  """Wrap trigger action with extra vars if configured.
256 
257  If action is a coroutine function, a coroutine function will be returned.
258  If action is a callback, a callback will be returned.
259  """
260  if CONF_VARIABLES not in conf:
261  return action
262 
263  # Check for partials to properly determine if coroutine function
264  check_func = action
265  while isinstance(check_func, functools.partial):
266  check_func = check_func.func
267 
268  wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]
269  if asyncio.iscoroutinefunction(check_func):
270  async_action = cast(Callable[..., Coroutine[Any, Any, None]], action)
271 
272  @functools.wraps(async_action)
273  async def async_with_vars(
274  run_variables: dict[str, Any], context: Context | None = None
275  ) -> None:
276  """Wrap action with extra vars."""
277  trigger_variables = conf[CONF_VARIABLES]
278  run_variables.update(trigger_variables.async_render(hass, run_variables))
279  await action(run_variables, context)
280 
281  wrapper_func = async_with_vars
282 
283  else:
284 
285  @functools.wraps(action)
286  async def with_vars(
287  run_variables: dict[str, Any], context: Context | None = None
288  ) -> None:
289  """Wrap action with extra vars."""
290  trigger_variables = conf[CONF_VARIABLES]
291  run_variables.update(trigger_variables.async_render(hass, run_variables))
292  action(run_variables, context)
293 
294  if is_callback(check_func):
295  with_vars = callback(with_vars)
296 
297  wrapper_func = with_vars
298 
299  return wrapper_func
300 
301 
303  hass: HomeAssistant,
304  trigger_config: list[ConfigType],
305  action: Callable,
306  domain: str,
307  name: str,
308  log_cb: Callable,
309  home_assistant_start: bool = False,
310  variables: TemplateVarsType = None,
311 ) -> CALLBACK_TYPE | None:
312  """Initialize triggers."""
313  triggers: list[asyncio.Task[CALLBACK_TYPE]] = []
314  for idx, conf in enumerate(trigger_config):
315  # Skip triggers that are not enabled
316  if CONF_ENABLED in conf:
317  enabled = conf[CONF_ENABLED]
318  if isinstance(enabled, Template):
319  try:
320  enabled = enabled.async_render(variables, limited=True)
321  except TemplateError as err:
322  log_cb(logging.ERROR, f"Error rendering enabled template: {err}")
323  continue
324  if not enabled:
325  continue
326 
327  platform = await _async_get_trigger_platform(hass, conf)
328  trigger_id = conf.get(CONF_ID, f"{idx}")
329  trigger_idx = f"{idx}"
330  trigger_alias = conf.get(CONF_ALIAS)
331  trigger_data = TriggerData(id=trigger_id, idx=trigger_idx, alias=trigger_alias)
332  info = TriggerInfo(
333  domain=domain,
334  name=name,
335  home_assistant_start=home_assistant_start,
336  variables=variables,
337  trigger_data=trigger_data,
338  )
339 
340  triggers.append(
341  create_eager_task(
342  platform.async_attach_trigger(
343  hass, conf, _trigger_action_wrapper(hass, action, conf), info
344  )
345  )
346  )
347 
348  attach_results = await asyncio.gather(*triggers, return_exceptions=True)
349  removes: list[Callable[[], None]] = []
350 
351  for result in attach_results:
352  if isinstance(result, HomeAssistantError):
353  log_cb(logging.ERROR, f"Got error '{result}' when setting up triggers for")
354  elif isinstance(result, Exception):
355  log_cb(logging.ERROR, "Error setting up trigger", exc_info=result)
356  elif isinstance(result, BaseException):
357  raise result from None
358  elif result is None:
359  log_cb( # type: ignore[unreachable]
360  logging.ERROR, "Unknown error while setting up trigger (empty result)"
361  )
362  else:
363  removes.append(result)
364 
365  if not removes:
366  return None
367 
368  log_cb(logging.INFO, "Initialized trigger")
369 
370  @callback
371  def remove_triggers() -> None:
372  """Remove triggers."""
373  for remove in removes:
374  remove()
375 
376  return remove_triggers
CALLBACK_TYPE async_register(self, HomeAssistant hass, dict[str, str] trigger)
Definition: trigger.py:186
dict[tuple, PluggableActionsEntry] async_get_registry(HomeAssistant hass)
Definition: trigger.py:142
None async_run(self, HomeAssistant hass, Context|None context=None)
Definition: trigger.py:210
None __init__(self, CALLBACK_TYPE|None update=None)
Definition: trigger.py:123
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, dict[str, str] trigger, TriggerActionType action, dict[str, Any] variables)
Definition: trigger.py:156
Any __call__(self, dict[str, Any] run_variables, Context|None context=None)
Definition: trigger.py:82
ConfigType async_validate_trigger_config(self, HomeAssistant hass, ConfigType config)
Definition: trigger.py:62
bool remove(self, _T matcher)
Definition: match.py:214
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, ConfigType config, TriggerActionType action, TriggerInfo trigger_info)
Definition: trigger.py:43
bool is_callback(Callable[..., Any] func)
Definition: core.py:259
TriggerProtocol _async_get_trigger_platform(HomeAssistant hass, ConfigType config)
Definition: trigger.py:221
list[ConfigType] async_validate_trigger_config(HomeAssistant hass, list[ConfigType] trigger_config)
Definition: trigger.py:239
CALLBACK_TYPE|None async_initialize_triggers(HomeAssistant hass, list[ConfigType] trigger_config, Callable action, str domain, str name, Callable log_cb, bool home_assistant_start=False, TemplateVarsType variables=None)
Definition: trigger.py:311
Callable _trigger_action_wrapper(HomeAssistant hass, Callable action, ConfigType conf)
Definition: trigger.py:254
Integration async_get_integration(HomeAssistant hass, str domain)
Definition: loader.py:1354