Home Assistant Unofficial Reference 2024.12.1
numeric_state.py
Go to the documentation of this file.
1 """Offer numeric 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 from typing import Any
9 
10 import voluptuous as vol
11 
12 from homeassistant import exceptions
13 from homeassistant.const import (
14  CONF_ABOVE,
15  CONF_ATTRIBUTE,
16  CONF_BELOW,
17  CONF_ENTITY_ID,
18  CONF_FOR,
19  CONF_PLATFORM,
20  CONF_VALUE_TEMPLATE,
21 )
22 from homeassistant.core import (
23  CALLBACK_TYPE,
24  Event,
25  EventStateChangedData,
26  HassJob,
27  HomeAssistant,
28  State,
29  callback,
30 )
31 from homeassistant.helpers import (
32  condition,
33  config_validation as cv,
34  entity_registry as er,
35  template,
36 )
37 from homeassistant.helpers.event import (
38  async_track_same_state,
39  async_track_state_change_event,
40 )
41 from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
42 from homeassistant.helpers.typing import ConfigType
43 
44 
45 def validate_above_below[_T: dict[str, Any]](value: _T) -> _T:
46  """Validate that above and below can co-exist."""
47  above = value.get(CONF_ABOVE)
48  below = value.get(CONF_BELOW)
49 
50  if above is None or below is None:
51  return value
52 
53  if isinstance(above, str) or isinstance(below, str):
54  return value
55 
56  if above > below:
57  raise vol.Invalid(
58  (
59  f"A value can never be above {above} and below {below} at the same"
60  " time. You probably want two different triggers."
61  ),
62  )
63 
64  return value
65 
66 
67 _TRIGGER_SCHEMA = vol.All(
68  cv.TRIGGER_BASE_SCHEMA.extend(
69  {
70  vol.Required(CONF_PLATFORM): "numeric_state",
71  vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
72  vol.Optional(CONF_BELOW): cv.NUMERIC_STATE_THRESHOLD_SCHEMA,
73  vol.Optional(CONF_ABOVE): cv.NUMERIC_STATE_THRESHOLD_SCHEMA,
74  vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
75  vol.Optional(CONF_FOR): cv.positive_time_period_template,
76  vol.Optional(CONF_ATTRIBUTE): cv.match_all,
77  }
78  ),
79  cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE),
80  validate_above_below,
81 )
82 
83 _LOGGER = logging.getLogger(__name__)
84 
85 
87  hass: HomeAssistant, config: ConfigType
88 ) -> ConfigType:
89  """Validate trigger config."""
90  config = _TRIGGER_SCHEMA(config)
91  registry = er.async_get(hass)
92  config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
93  registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID])
94  )
95  return config
96 
97 
99  hass: HomeAssistant,
100  config: ConfigType,
101  action: TriggerActionType,
102  trigger_info: TriggerInfo,
103  *,
104  platform_type: str = "numeric_state",
105 ) -> CALLBACK_TYPE:
106  """Listen for state changes based on configuration."""
107  entity_ids: list[str] = config[CONF_ENTITY_ID]
108  below = config.get(CONF_BELOW)
109  above = config.get(CONF_ABOVE)
110  time_delta = config.get(CONF_FOR)
111  value_template = config.get(CONF_VALUE_TEMPLATE)
112  unsub_track_same: dict[str, Callable[[], None]] = {}
113  armed_entities: set[str] = set()
114  period: dict[str, timedelta] = {}
115  attribute = config.get(CONF_ATTRIBUTE)
116  job = HassJob(action, f"numeric state trigger {trigger_info}")
117 
118  trigger_data = trigger_info["trigger_data"]
119  _variables = trigger_info["variables"] or {}
120 
121  def variables(entity_id: str) -> dict[str, Any]:
122  """Return a dict with trigger variables."""
123  trigger_info = {
124  "trigger": {
125  "platform": "numeric_state",
126  "entity_id": entity_id,
127  "below": below,
128  "above": above,
129  "attribute": attribute,
130  }
131  }
132  return {**_variables, **trigger_info}
133 
134  @callback
135  def check_numeric_state(
136  entity_id: str, from_s: State | None, to_s: str | State | None
137  ) -> bool:
138  """Return whether the criteria are met, raise ConditionError if unknown."""
139  return condition.async_numeric_state(
140  hass, to_s, below, above, value_template, variables(entity_id), attribute
141  )
142 
143  # Each entity that starts outside the range is already armed (ready to fire).
144  for entity_id in entity_ids:
145  try:
146  if not check_numeric_state(entity_id, None, entity_id):
147  armed_entities.add(entity_id)
148  except exceptions.ConditionError as ex:
149  _LOGGER.warning(
150  "Error initializing '%s' trigger: %s",
151  trigger_info["name"],
152  ex,
153  )
154 
155  @callback
156  def state_automation_listener(event: Event[EventStateChangedData]) -> None:
157  """Listen for state changes and calls action."""
158  entity_id = event.data["entity_id"]
159  from_s = event.data["old_state"]
160  to_s = event.data["new_state"]
161 
162  if to_s is None:
163  return
164 
165  @callback
166  def call_action() -> None:
167  """Call action with right context."""
168  hass.async_run_hass_job(
169  job,
170  {
171  "trigger": {
172  **trigger_data,
173  "platform": platform_type,
174  "entity_id": entity_id,
175  "below": below,
176  "above": above,
177  "from_state": from_s,
178  "to_state": to_s,
179  "for": time_delta if not time_delta else period[entity_id],
180  "description": f"numeric state of {entity_id}",
181  }
182  },
183  to_s.context,
184  )
185 
186  @callback
187  def check_numeric_state_no_raise(
188  entity_id: str, from_s: State | None, to_s: State | None
189  ) -> bool:
190  """Return True if the criteria are now met, False otherwise."""
191  try:
192  return check_numeric_state(entity_id, from_s, to_s)
194  # This is an internal same-state listener so we just drop the
195  # error. The same error will be reached and logged by the
196  # primary async_track_state_change_event() listener.
197  return False
198 
199  try:
200  matching = check_numeric_state(entity_id, from_s, to_s)
201  except exceptions.ConditionError as ex:
202  _LOGGER.warning("Error in '%s' trigger: %s", trigger_info["name"], ex)
203  return
204 
205  if not matching:
206  armed_entities.add(entity_id)
207  elif entity_id in armed_entities:
208  armed_entities.discard(entity_id)
209 
210  if time_delta:
211  try:
212  period[entity_id] = cv.positive_time_period(
213  template.render_complex(time_delta, variables(entity_id))
214  )
215  except (exceptions.TemplateError, vol.Invalid) as ex:
216  _LOGGER.error(
217  "Error rendering '%s' for template: %s",
218  trigger_info["name"],
219  ex,
220  )
221  return
222 
223  unsub_track_same[entity_id] = async_track_same_state(
224  hass,
225  period[entity_id],
226  call_action,
227  entity_ids=entity_id,
228  async_check_same_func=check_numeric_state_no_raise,
229  )
230  else:
231  call_action()
232 
233  unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener)
234 
235  @callback
236  def async_remove() -> None:
237  """Remove state listeners async."""
238  unsub()
239  for async_remove in unsub_track_same.values():
240  async_remove()
241  unsub_track_same.clear()
242 
243  return async_remove
ConfigType async_validate_trigger_config(HomeAssistant hass, ConfigType config)
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, ConfigType config, TriggerActionType action, TriggerInfo trigger_info, *str platform_type="numeric_state")
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
None async_remove(HomeAssistant hass, str intent_type)
Definition: intent.py:90