Home Assistant Unofficial Reference 2024.12.1
helpers.py
Go to the documentation of this file.
1 """Event parser and human readable log generator."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Mapping
6 from typing import Any
7 
8 from homeassistant.components.sensor import ATTR_STATE_CLASS
9 from homeassistant.const import (
10  ATTR_DEVICE_ID,
11  ATTR_DOMAIN,
12  ATTR_ENTITY_ID,
13  ATTR_UNIT_OF_MEASUREMENT,
14  EVENT_LOGBOOK_ENTRY,
15  EVENT_STATE_CHANGED,
16 )
17 from homeassistant.core import (
18  CALLBACK_TYPE,
19  Event,
20  EventStateChangedData,
21  HomeAssistant,
22  State,
23  callback,
24  is_callback,
25  split_entity_id,
26 )
27 from homeassistant.helpers import device_registry as dr, entity_registry as er
28 from homeassistant.helpers.event import async_track_state_change_event
29 from homeassistant.util.event_type import EventType
30 
31 from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN
32 from .models import LogbookConfig
33 
34 
35 def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]:
36  """Filter out any entities that logbook will not produce results for."""
37  ent_reg = er.async_get(hass)
38  return [
39  entity_id
40  for entity_id in entity_ids
41  if split_entity_id(entity_id)[0] not in ALWAYS_CONTINUOUS_DOMAINS
42  and not is_sensor_continuous(hass, ent_reg, entity_id)
43  ]
44 
45 
46 @callback
48  hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
49 ) -> set[str]:
50  """Find the config entry ids for a set of entities or devices."""
51  config_entry_ids: set[str] = set()
52  if entity_ids:
53  eng_reg = er.async_get(hass)
54  for entity_id in entity_ids:
55  if (entry := eng_reg.async_get(entity_id)) and entry.config_entry_id:
56  config_entry_ids.add(entry.config_entry_id)
57  if device_ids:
58  dev_reg = dr.async_get(hass)
59  for device_id in device_ids:
60  if (device := dev_reg.async_get(device_id)) and device.config_entries:
61  config_entry_ids |= device.config_entries
62  return config_entry_ids
63 
64 
66  hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
67 ) -> tuple[EventType[Any] | str, ...]:
68  """Reduce the event types based on the entity ids and device ids."""
69  logbook_config: LogbookConfig = hass.data[DOMAIN]
70  external_events = logbook_config.external_events
71  if not entity_ids and not device_ids:
72  return (*BUILT_IN_EVENTS, *external_events)
73 
74  interested_domains: set[str] = set()
75  for entry_id in _async_config_entries_for_ids(hass, entity_ids, device_ids):
76  if entry := hass.config_entries.async_get_entry(entry_id):
77  interested_domains.add(entry.domain)
78 
79  #
80  # automations and scripts can refer to entities or devices
81  # but they do not have a config entry so we need
82  # to add them since we have historically included
83  # them when matching only on entities
84  #
85  intrested_event_types: set[EventType[Any] | str] = {
86  external_event
87  for external_event, domain_call in external_events.items()
88  if domain_call[0] in interested_domains
89  } | AUTOMATION_EVENTS
90  if entity_ids:
91  # We also allow entity_ids to be recorded via manual logbook entries.
92  intrested_event_types.add(EVENT_LOGBOOK_ENTRY)
93 
94  return tuple(intrested_event_types)
95 
96 
97 @callback
98 def extract_attr(source: Mapping[str, Any], attr: str) -> list[str]:
99  """Extract an attribute as a list or string."""
100  if (value := source.get(attr)) is None:
101  return []
102  if isinstance(value, list):
103  return value
104  return str(value).split(",")
105 
106 
107 @callback
109  target: Callable[[Event], None],
110  entities_filter: Callable[[str], bool] | None,
111  entity_ids: list[str] | None,
112  device_ids: list[str] | None,
113 ) -> Callable[[Event], None]:
114  """Make a callable to filter events."""
115  if not entities_filter and not entity_ids and not device_ids:
116  # No filter
117  # - Script Trace (context ids)
118  # - Automation Trace (context ids)
119  return target
120 
121  if entities_filter:
122  # We have an entity filter:
123  # - Logbook panel
124 
125  @callback
126  def _forward_events_filtered_by_entities_filter(event: Event) -> None:
127  assert entities_filter is not None
128  event_data = event.data
129  entity_ids = extract_attr(event_data, ATTR_ENTITY_ID)
130  if entity_ids and not any(
131  entities_filter(entity_id) for entity_id in entity_ids
132  ):
133  return
134  domain = event_data.get(ATTR_DOMAIN)
135  if domain and not entities_filter(f"{domain}._"):
136  return
137  target(event)
138 
139  return _forward_events_filtered_by_entities_filter
140 
141  # We are filtering on entity_ids and/or device_ids:
142  # - Areas
143  # - Devices
144  # - Logbook Card
145  entity_ids_set = set(entity_ids) if entity_ids else set()
146  device_ids_set = set(device_ids) if device_ids else set()
147 
148  @callback
149  def _forward_events_filtered_by_device_entity_ids(event: Event) -> None:
150  event_data = event.data
151  if entity_ids_set.intersection(
152  extract_attr(event_data, ATTR_ENTITY_ID)
153  ) or device_ids_set.intersection(extract_attr(event_data, ATTR_DEVICE_ID)):
154  target(event)
155 
156  return _forward_events_filtered_by_device_entity_ids
157 
158 
159 @callback
161  hass: HomeAssistant,
162  subscriptions: list[CALLBACK_TYPE],
163  target: Callable[[Event[Any]], None],
164  event_types: tuple[EventType[Any] | str, ...],
165  entities_filter: Callable[[str], bool] | None,
166  entity_ids: list[str] | None,
167  device_ids: list[str] | None,
168 ) -> None:
169  """Subscribe to events for the entities and devices or all.
170 
171  These are the events we need to listen for to do
172  the live logbook stream.
173  """
174  assert is_callback(target), "target must be a callback"
175  event_forwarder = event_forwarder_filtered(
176  target, entities_filter, entity_ids, device_ids
177  )
178  subscriptions.extend(
179  hass.bus.async_listen(event_type, event_forwarder) for event_type in event_types
180  )
181 
182  if device_ids and not entity_ids:
183  # No entities to subscribe to but we are filtering
184  # on device ids so we do not want to get any state
185  # changed events
186  return
187 
188  @callback
189  def _forward_state_events_filtered(event: Event[EventStateChangedData]) -> None:
190  if (old_state := event.data["old_state"]) is None or (
191  new_state := event.data["new_state"]
192  ) is None:
193  return
194  if _is_state_filtered(new_state, old_state) or (
195  entities_filter and not entities_filter(new_state.entity_id)
196  ):
197  return
198  target(event)
199 
200  if entity_ids:
201  subscriptions.append(
203  hass, entity_ids, _forward_state_events_filtered
204  )
205  )
206  return
207 
208  # We want the firehose
209  subscriptions.append(
210  hass.bus.async_listen(
211  EVENT_STATE_CHANGED,
212  _forward_state_events_filtered,
213  )
214  )
215 
216 
218  hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str
219 ) -> bool:
220  """Determine if a sensor is continuous.
221 
222  Sensors with a unit_of_measurement or state_class are considered continuous.
223 
224  The unit_of_measurement check will already happen if this is
225  called for historical data because the SQL query generated by _get_events
226  will filter out any sensors with a unit_of_measurement.
227 
228  If the state still exists in the state machine, this function still
229  checks for ATTR_UNIT_OF_MEASUREMENT since the live mode is not filtered
230  by the SQL query.
231  """
232  # If it is in the state machine we can quick check if it
233  # has a unit_of_measurement or state_class, and filter if
234  # it does
235  if (state := hass.states.get(entity_id)) and (attributes := state.attributes):
236  return ATTR_UNIT_OF_MEASUREMENT in attributes or ATTR_STATE_CLASS in attributes
237  # If its not in the state machine, we need to check
238  # the entity registry to see if its a sensor
239  # filter with a state class. We do not check
240  # for unit_of_measurement since the SQL query
241  # will filter out any sensors with a unit_of_measurement
242  # and we should never get here in live mode because
243  # the state machine will always have the state.
244  return bool(
245  (entry := ent_reg.async_get(entity_id))
246  and entry.capabilities
247  and entry.capabilities.get(ATTR_STATE_CLASS)
248  )
249 
250 
251 def _is_state_filtered(new_state: State, old_state: State) -> bool:
252  """Check if the logbook should filter a state.
253 
254  Used when we are in live mode to ensure
255  we only get significant changes (state.last_changed != state.last_updated)
256  """
257  return bool(
258  new_state.state == old_state.state
259  or new_state.last_changed != new_state.last_updated
260  or new_state.domain in ALWAYS_CONTINUOUS_DOMAINS
261  or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes
262  or ATTR_STATE_CLASS in new_state.attributes
263  )
tuple[EventType[Any]|str,...] async_determine_event_types(HomeAssistant hass, list[str]|None entity_ids, list[str]|None device_ids)
Definition: helpers.py:67
list[str] async_filter_entities(HomeAssistant hass, list[str] entity_ids)
Definition: helpers.py:35
bool is_sensor_continuous(HomeAssistant hass, er.EntityRegistry ent_reg, str entity_id)
Definition: helpers.py:219
set[str] _async_config_entries_for_ids(HomeAssistant hass, list[str]|None entity_ids, list[str]|None device_ids)
Definition: helpers.py:49
list[str] extract_attr(Mapping[str, Any] source, str attr)
Definition: helpers.py:98
bool _is_state_filtered(State new_state, State old_state)
Definition: helpers.py:251
None async_subscribe_events(HomeAssistant hass, list[CALLBACK_TYPE] subscriptions, Callable[[Event[Any]], None] target, tuple[EventType[Any]|str,...] event_types, Callable[[str], bool]|None entities_filter, list[str]|None entity_ids, list[str]|None device_ids)
Definition: helpers.py:168
Callable[[Event], None] event_forwarder_filtered(Callable[[Event], None] target, Callable[[str], bool]|None entities_filter, list[str]|None entity_ids, list[str]|None device_ids)
Definition: helpers.py:113
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214
bool is_callback(Callable[..., Any] func)
Definition: core.py:259
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