Home Assistant Unofficial Reference 2024.12.1
state.py
Go to the documentation of this file.
1 """Offer state listening automation rules."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from datetime import timedelta
7 import logging
8 
9 import voluptuous as vol
10 
11 from homeassistant import exceptions
12 from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL
13 from homeassistant.core import (
14  CALLBACK_TYPE,
15  Event,
16  EventStateChangedData,
17  HassJob,
18  HomeAssistant,
19  State,
20  callback,
21 )
22 from homeassistant.helpers import (
23  config_validation as cv,
24  entity_registry as er,
25  template,
26 )
27 from homeassistant.helpers.event import (
28  async_track_same_state,
29  async_track_state_change_event,
30  process_state_match,
31 )
32 from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
33 from homeassistant.helpers.typing import ConfigType
34 
35 _LOGGER = logging.getLogger(__name__)
36 
37 CONF_ENTITY_ID = "entity_id"
38 CONF_FROM = "from"
39 CONF_TO = "to"
40 CONF_NOT_FROM = "not_from"
41 CONF_NOT_TO = "not_to"
42 
43 BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
44  {
45  vol.Required(CONF_PLATFORM): "state",
46  vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
47  vol.Optional(CONF_FOR): cv.positive_time_period_template,
48  vol.Optional(CONF_ATTRIBUTE): cv.match_all,
49  }
50 )
51 
52 TRIGGER_STATE_SCHEMA = BASE_SCHEMA.extend(
53  {
54  # These are str on purpose. Want to catch YAML conversions
55  vol.Exclusive(CONF_FROM, CONF_FROM): vol.Any(str, [str], None),
56  vol.Exclusive(CONF_NOT_FROM, CONF_FROM): vol.Any(str, [str], None),
57  vol.Exclusive(CONF_TO, CONF_TO): vol.Any(str, [str], None),
58  vol.Exclusive(CONF_NOT_TO, CONF_TO): vol.Any(str, [str], None),
59  }
60 )
61 
62 TRIGGER_ATTRIBUTE_SCHEMA = BASE_SCHEMA.extend(
63  {
64  vol.Exclusive(CONF_FROM, CONF_FROM): cv.match_all,
65  vol.Exclusive(CONF_NOT_FROM, CONF_FROM): cv.match_all,
66  vol.Exclusive(CONF_TO, CONF_TO): cv.match_all,
67  vol.Exclusive(CONF_NOT_TO, CONF_TO): cv.match_all,
68  }
69 )
70 
71 
73  hass: HomeAssistant, config: ConfigType
74 ) -> ConfigType:
75  """Validate trigger config."""
76  if not isinstance(config, dict):
77  raise vol.Invalid("Expected a dictionary")
78 
79  # We use this approach instead of vol.Any because
80  # this gives better error messages.
81  if CONF_ATTRIBUTE in config:
82  config = TRIGGER_ATTRIBUTE_SCHEMA(config)
83  else:
84  config = TRIGGER_STATE_SCHEMA(config)
85 
86  registry = er.async_get(hass)
87  config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
88  registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID])
89  )
90 
91  return config
92 
93 
95  hass: HomeAssistant,
96  config: ConfigType,
97  action: TriggerActionType,
98  trigger_info: TriggerInfo,
99  *,
100  platform_type: str = "state",
101 ) -> CALLBACK_TYPE:
102  """Listen for state changes based on configuration."""
103  entity_ids = config[CONF_ENTITY_ID]
104 
105  if (from_state := config.get(CONF_FROM)) is not None:
106  match_from_state = process_state_match(from_state)
107  elif (not_from_state := config.get(CONF_NOT_FROM)) is not None:
108  match_from_state = process_state_match(not_from_state, invert=True)
109  else:
110  match_from_state = process_state_match(MATCH_ALL)
111 
112  if (to_state := config.get(CONF_TO)) is not None:
113  match_to_state = process_state_match(to_state)
114  elif (not_to_state := config.get(CONF_NOT_TO)) is not None:
115  match_to_state = process_state_match(not_to_state, invert=True)
116  else:
117  match_to_state = process_state_match(MATCH_ALL)
118 
119  time_delta = config.get(CONF_FOR)
120  # If neither CONF_FROM or CONF_TO are specified,
121  # fire on all changes to the state or an attribute
122  match_all = all(
123  item not in config for item in (CONF_FROM, CONF_NOT_FROM, CONF_NOT_TO, CONF_TO)
124  )
125  unsub_track_same: dict[str, Callable[[], None]] = {}
126  period: dict[str, timedelta] = {}
127  attribute = config.get(CONF_ATTRIBUTE)
128  job = HassJob(action, f"state trigger {trigger_info}")
129 
130  trigger_data = trigger_info["trigger_data"]
131  _variables = trigger_info["variables"] or {}
132 
133  @callback
134  def state_automation_listener(event: Event[EventStateChangedData]) -> None:
135  """Listen for state changes and calls action."""
136  entity = event.data["entity_id"]
137  from_s = event.data["old_state"]
138  to_s = event.data["new_state"]
139 
140  if from_s is None:
141  old_value = None
142  elif attribute is None:
143  old_value = from_s.state
144  else:
145  old_value = from_s.attributes.get(attribute)
146 
147  if to_s is None:
148  new_value = None
149  elif attribute is None:
150  new_value = to_s.state
151  else:
152  new_value = to_s.attributes.get(attribute)
153 
154  # When we listen for state changes with `match_all`, we
155  # will trigger even if just an attribute changes. When
156  # we listen to just an attribute, we should ignore all
157  # other attribute changes.
158  if attribute is not None and old_value == new_value:
159  return
160 
161  if (
162  not match_from_state(old_value)
163  or not match_to_state(new_value)
164  or (not match_all and old_value == new_value)
165  ):
166  return
167 
168  @callback
169  def call_action() -> None:
170  """Call action with right context."""
171  hass.async_run_hass_job(
172  job,
173  {
174  "trigger": {
175  **trigger_data,
176  "platform": platform_type,
177  "entity_id": entity,
178  "from_state": from_s,
179  "to_state": to_s,
180  "for": time_delta if not time_delta else period[entity],
181  "attribute": attribute,
182  "description": f"state of {entity}",
183  }
184  },
185  event.context,
186  )
187 
188  if not time_delta:
189  call_action()
190  return
191 
192  data = {
193  "trigger": {
194  "platform": "state",
195  "entity_id": entity,
196  "from_state": from_s,
197  "to_state": to_s,
198  }
199  }
200  variables = {**_variables, **data}
201 
202  try:
203  period[entity] = cv.positive_time_period(
204  template.render_complex(time_delta, variables)
205  )
206  except (exceptions.TemplateError, vol.Invalid) as ex:
207  _LOGGER.error(
208  "Error rendering '%s' for template: %s", trigger_info["name"], ex
209  )
210  return
211 
212  def _check_same_state(_: str, _2: State | None, new_st: State | None) -> bool:
213  if new_st is None:
214  return False
215 
216  cur_value: str | None
217  if attribute is None:
218  cur_value = new_st.state
219  else:
220  cur_value = new_st.attributes.get(attribute)
221 
222  if CONF_FROM in config and CONF_TO not in config:
223  return cur_value != old_value
224 
225  return cur_value == new_value
226 
227  unsub_track_same[entity] = async_track_same_state(
228  hass,
229  period[entity],
230  call_action,
231  _check_same_state,
232  entity_ids=entity,
233  )
234 
235  unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener)
236 
237  @callback
238  def async_remove() -> None:
239  """Remove state listeners async."""
240  unsub()
241  for async_remove in unsub_track_same.values():
242  async_remove()
243  unsub_track_same.clear()
244 
245  return async_remove
ConfigType async_validate_trigger_config(HomeAssistant hass, ConfigType config)
Definition: state.py:74
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, ConfigType config, TriggerActionType action, TriggerInfo trigger_info, *str platform_type="state")
Definition: state.py:101
CALLBACK_TYPE async_track_same_state(HomeAssistant hass, timedelta period, Callable[[], Coroutine[Any, Any, None]|None] action, Callable[[str, State|None, State|None], bool] async_check_same_func, str|Iterable[str] entity_ids=MATCH_ALL)
Definition: event.py:1395
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314
Callable[[str|None], bool] process_state_match(str|Iterable[str]|None parameter, bool invert=False)
Definition: event.py:1917
None async_remove(HomeAssistant hass, str intent_type)
Definition: intent.py:90