Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for tracking people."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import logging
7 from typing import Any, Self
8 
9 import voluptuous as vol
10 
11 from homeassistant.auth import EVENT_USER_REMOVED
12 from homeassistant.components import persistent_notification, websocket_api
14  ATTR_SOURCE_TYPE,
15  DOMAIN as DEVICE_TRACKER_DOMAIN,
16  SourceType,
17 )
18 from homeassistant.const import (
19  ATTR_EDITABLE,
20  ATTR_GPS_ACCURACY,
21  ATTR_ID,
22  ATTR_LATITUDE,
23  ATTR_LONGITUDE,
24  ATTR_NAME,
25  CONF_ID,
26  CONF_NAME,
27  EVENT_HOMEASSISTANT_START,
28  SERVICE_RELOAD,
29  STATE_HOME,
30  STATE_NOT_HOME,
31  STATE_UNAVAILABLE,
32  STATE_UNKNOWN,
33 )
34 from homeassistant.core import (
35  Event,
36  EventStateChangedData,
37  HomeAssistant,
38  ServiceCall,
39  State,
40  callback,
41  split_entity_id,
42 )
43 from homeassistant.helpers import (
44  collection,
45  config_validation as cv,
46  entity_registry as er,
47  service,
48 )
49 from homeassistant.helpers.entity_component import EntityComponent
50 from homeassistant.helpers.event import async_track_state_change_event
51 from homeassistant.helpers.restore_state import RestoreEntity
52 from homeassistant.helpers.storage import Store
53 from homeassistant.helpers.typing import ConfigType, VolDictType
54 from homeassistant.loader import bind_hass
55 
56 from .const import DOMAIN
57 
58 _LOGGER = logging.getLogger(__name__)
59 
60 ATTR_SOURCE = "source"
61 ATTR_USER_ID = "user_id"
62 ATTR_DEVICE_TRACKERS = "device_trackers"
63 
64 CONF_DEVICE_TRACKERS = "device_trackers"
65 CONF_USER_ID = "user_id"
66 CONF_PICTURE = "picture"
67 
68 STORAGE_KEY = DOMAIN
69 STORAGE_VERSION = 2
70 # Device tracker states to ignore
71 IGNORE_STATES = (STATE_UNKNOWN, STATE_UNAVAILABLE)
72 
73 PERSON_SCHEMA = vol.Schema(
74  {
75  vol.Required(CONF_ID): cv.string,
76  vol.Required(CONF_NAME): cv.string,
77  vol.Optional(CONF_USER_ID): cv.string,
78  vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All(
79  cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
80  ),
81  vol.Optional(CONF_PICTURE): cv.string,
82  }
83 )
84 
85 CONFIG_SCHEMA = vol.Schema(
86  {
87  vol.Optional(DOMAIN, default=[]): vol.All(
88  cv.ensure_list, cv.remove_falsy, [PERSON_SCHEMA]
89  )
90  },
91  extra=vol.ALLOW_EXTRA,
92 )
93 
94 
95 @bind_hass
97  hass: HomeAssistant,
98  name: str,
99  *,
100  user_id: str | None = None,
101  device_trackers: list[str] | None = None,
102 ) -> None:
103  """Create a new person."""
104  await hass.data[DOMAIN][1].async_create_item(
105  {
106  ATTR_NAME: name,
107  ATTR_USER_ID: user_id,
108  CONF_DEVICE_TRACKERS: device_trackers or [],
109  }
110  )
111 
112 
113 @bind_hass
115  hass: HomeAssistant, user_id: str, device_tracker_entity_id: str
116 ) -> None:
117  """Add a device tracker to a person linked to a user."""
118  coll: PersonStorageCollection = hass.data[DOMAIN][1]
119 
120  for person in coll.async_items():
121  if person.get(ATTR_USER_ID) != user_id:
122  continue
123 
124  device_trackers = person[CONF_DEVICE_TRACKERS]
125 
126  if device_tracker_entity_id in device_trackers:
127  return
128 
129  await coll.async_update_item(
130  person[CONF_ID],
131  {CONF_DEVICE_TRACKERS: [*device_trackers, device_tracker_entity_id]},
132  )
133  break
134 
135 
136 @callback
137 def persons_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]:
138  """Return all persons that reference the entity."""
139  if (
140  DOMAIN not in hass.data
141  or split_entity_id(entity_id)[0] != DEVICE_TRACKER_DOMAIN
142  ):
143  return []
144 
145  component: EntityComponent[Person] = hass.data[DOMAIN][2]
146 
147  return [
148  person_entity.entity_id
149  for person_entity in component.entities
150  if entity_id in person_entity.device_trackers
151  ]
152 
153 
154 @callback
155 def entities_in_person(hass: HomeAssistant, entity_id: str) -> list[str]:
156  """Return all entities belonging to a person."""
157  if DOMAIN not in hass.data:
158  return []
159 
160  component: EntityComponent[Person] = hass.data[DOMAIN][2]
161 
162  if (person_entity := component.get_entity(entity_id)) is None:
163  return []
164 
165  return person_entity.device_trackers
166 
167 
168 CREATE_FIELDS: VolDictType = {
169  vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)),
170  vol.Optional(CONF_USER_ID): vol.Any(str, None),
171  vol.Optional(CONF_DEVICE_TRACKERS, default=list): vol.All(
172  cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
173  ),
174  vol.Optional(CONF_PICTURE): vol.Any(str, None),
175 }
176 
177 
178 UPDATE_FIELDS: VolDictType = {
179  vol.Optional(CONF_NAME): vol.All(str, vol.Length(min=1)),
180  vol.Optional(CONF_USER_ID): vol.Any(str, None),
181  vol.Optional(CONF_DEVICE_TRACKERS, default=list): vol.All(
182  cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
183  ),
184  vol.Optional(CONF_PICTURE): vol.Any(str, None),
185 }
186 
187 
189  """Person storage."""
190 
192  self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any]
193  ) -> dict[str, Any]:
194  """Migrate to the new version.
195 
196  Migrate storage to use format of collection helper.
197  """
198  return {"items": old_data["persons"]}
199 
200 
201 class PersonStorageCollection(collection.DictStorageCollection):
202  """Person collection stored in storage."""
203 
204  CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
205  UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
206 
207  def __init__(
208  self,
209  store: Store,
210  id_manager: collection.IDManager,
211  yaml_collection: collection.YamlCollection,
212  ) -> None:
213  """Initialize a person storage collection."""
214  super().__init__(store, id_manager)
215  self.yaml_collectionyaml_collection = yaml_collection
216 
217  async def _async_load_data(self) -> collection.SerializedStorageCollection | None:
218  """Load the data.
219 
220  A past bug caused onboarding to create invalid person objects.
221  This patches it up.
222  """
223  data = await super()._async_load_data()
224 
225  if data is None:
226  return data
227 
228  for person in data["items"]:
229  if person[CONF_DEVICE_TRACKERS] is None:
230  person[CONF_DEVICE_TRACKERS] = []
231 
232  return data
233 
234  async def async_load(self) -> None:
235  """Load the Storage collection."""
236  await super().async_load()
237  self.hass.bus.async_listen(
238  er.EVENT_ENTITY_REGISTRY_UPDATED,
239  self._entity_registry_updated_entity_registry_updated,
240  event_filter=self._entity_registry_filter_entity_registry_filter,
241  )
242 
243  @callback
245  self, event_data: er.EventEntityRegistryUpdatedData
246  ) -> bool:
247  """Filter entity registry events."""
248  return (
249  event_data["action"] == "remove"
250  and split_entity_id(event_data["entity_id"])[0] == "device_tracker"
251  )
252 
254  self, event: Event[er.EventEntityRegistryUpdatedData]
255  ) -> None:
256  """Handle entity registry updated."""
257  entity_id = event.data["entity_id"]
258  for person in list(self.data.values()):
259  if entity_id not in person[CONF_DEVICE_TRACKERS]:
260  continue
261 
262  await self.async_update_item(
263  person[CONF_ID],
264  {
265  CONF_DEVICE_TRACKERS: [
266  devt
267  for devt in person[CONF_DEVICE_TRACKERS]
268  if devt != entity_id
269  ]
270  },
271  )
272 
273  async def _process_create_data(self, data: dict) -> dict:
274  """Validate the config is valid."""
275  data = self.CREATE_SCHEMACREATE_SCHEMA(data)
276 
277  if (user_id := data.get(CONF_USER_ID)) is not None:
278  await self._validate_user_id_validate_user_id(user_id)
279 
280  return data
281 
282  @callback
283  def _get_suggested_id(self, info: dict) -> str:
284  """Suggest an ID based on the config."""
285  return info[CONF_NAME]
286 
287  async def _update_data(self, item: dict, update_data: dict) -> dict:
288  """Return a new updated data object."""
289  update_data = self.UPDATE_SCHEMAUPDATE_SCHEMA(update_data)
290 
291  user_id: str | None = update_data.get(CONF_USER_ID)
292 
293  if user_id is not None and user_id != item.get(CONF_USER_ID):
294  await self._validate_user_id_validate_user_id(user_id)
295 
296  return {**item, **update_data}
297 
298  async def _validate_user_id(self, user_id: str) -> None:
299  """Validate the used user_id."""
300  if await self.hass.auth.async_get_user(user_id) is None:
301  raise ValueError("User does not exist")
302 
303  for persons in (self.data.values(), self.yaml_collectionyaml_collection.async_items()):
304  if any(person for person in persons if person.get(CONF_USER_ID) == user_id):
305  raise ValueError("User already taken")
306 
307 
308 class PersonStorageCollectionWebsocket(collection.DictStorageCollectionWebsocket):
309  """Class to expose storage collection management over websocket."""
310 
312  self,
313  hass: HomeAssistant,
314  connection: websocket_api.ActiveConnection,
315  msg: dict[str, Any],
316  ) -> None:
317  """List persons."""
318  yaml, storage, _ = hass.data[DOMAIN]
319  connection.send_result(
320  msg[ATTR_ID],
321  {"storage": storage.async_items(), "config": yaml.async_items()},
322  )
323 
324 
325 async def filter_yaml_data(hass: HomeAssistant, persons: list[dict]) -> list[dict]:
326  """Validate YAML data that we can't validate via schema."""
327  filtered = []
328  person_invalid_user = []
329 
330  for person_conf in persons:
331  user_id = person_conf.get(CONF_USER_ID)
332 
333  if user_id is not None and await hass.auth.async_get_user(user_id) is None:
334  _LOGGER.error(
335  "Invalid user_id detected for person %s",
336  person_conf[CONF_ID],
337  )
338  person_invalid_user.append(
339  f"- Person {person_conf[CONF_NAME]} (id: {person_conf[CONF_ID]}) points"
340  f" at invalid user {user_id}"
341  )
342  continue
343 
344  filtered.append(person_conf)
345 
346  if person_invalid_user:
347  persistent_notification.async_create(
348  hass,
349  f"""
350 The following persons point at invalid users:
351 
352 {"- ".join(person_invalid_user)}
353  """,
354  "Invalid Person Configuration",
355  DOMAIN,
356  )
357 
358  return filtered
359 
360 
361 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
362  """Set up the person component."""
363  entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass)
364  id_manager = collection.IDManager()
365  yaml_collection = collection.YamlCollection(
366  logging.getLogger(f"{__name__}.yaml_collection"), id_manager
367  )
368  storage_collection = PersonStorageCollection(
369  PersonStore(hass, STORAGE_VERSION, STORAGE_KEY),
370  id_manager,
371  yaml_collection,
372  )
373 
374  collection.sync_entity_lifecycle(
375  hass, DOMAIN, DOMAIN, entity_component, yaml_collection, Person
376  )
377  collection.sync_entity_lifecycle(
378  hass, DOMAIN, DOMAIN, entity_component, storage_collection, Person
379  )
380 
381  await yaml_collection.async_load(
382  await filter_yaml_data(hass, config.get(DOMAIN, []))
383  )
384  await storage_collection.async_load()
385 
386  hass.data[DOMAIN] = (yaml_collection, storage_collection, entity_component)
387 
389  storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
390  ).async_setup(hass)
391 
392  async def _handle_user_removed(event: Event) -> None:
393  """Handle a user being removed."""
394  user_id = event.data[ATTR_USER_ID]
395  for person in storage_collection.async_items():
396  if person[CONF_USER_ID] == user_id:
397  await storage_collection.async_update_item(
398  person[CONF_ID], {CONF_USER_ID: None}
399  )
400 
401  hass.bus.async_listen(EVENT_USER_REMOVED, _handle_user_removed)
402 
403  async def async_reload_yaml(call: ServiceCall) -> None:
404  """Reload YAML."""
405  conf = await entity_component.async_prepare_reload(skip_reset=True)
406  if conf is None:
407  return
408  await yaml_collection.async_load(
409  await filter_yaml_data(hass, conf.get(DOMAIN, []))
410  )
411 
412  service.async_register_admin_service(
413  hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml
414  )
415 
416  return True
417 
418 
419 class Person(
420  collection.CollectionEntity,
421  RestoreEntity,
422 ):
423  """Represent a tracked person."""
424 
425  _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS})
426 
427  _attr_should_poll = False
428  editable: bool
429 
430  def __init__(self, config: dict[str, Any]) -> None:
431  """Set up person."""
432  self._config_config = config
433  self._latitude_latitude: float | None = None
434  self._longitude_longitude: float | None = None
435  self._gps_accuracy_gps_accuracy: float | None = None
436  self._source_source: str | None = None
437  self._unsub_track_device_unsub_track_device: Callable[[], None] | None = None
438  self._attr_state_attr_state: str | None = None
439  self.device_trackersdevice_trackers: list[str] = []
440 
441  self._attr_unique_id_attr_unique_id = config[CONF_ID]
442  self._set_attrs_from_config_set_attrs_from_config()
443 
444  def _set_attrs_from_config(self) -> None:
445  """Set attributes from config."""
446  self._attr_name_attr_name = self._config_config[CONF_NAME]
447  self._attr_entity_picture_attr_entity_picture = self._config_config.get(CONF_PICTURE)
448  self.device_trackersdevice_trackers = self._config_config[CONF_DEVICE_TRACKERS]
449 
450  @classmethod
451  def from_storage(cls, config: ConfigType) -> Self:
452  """Return entity instance initialized from storage."""
453  person = cls(config)
454  person.editable = True
455  return person
456 
457  @classmethod
458  def from_yaml(cls, config: ConfigType) -> Self:
459  """Return entity instance initialized from yaml."""
460  person = cls(config)
461  person.editable = False
462  return person
463 
464  async def async_added_to_hass(self) -> None:
465  """Register device trackers."""
466  await super().async_added_to_hass()
467  if state := await self.async_get_last_stateasync_get_last_state():
468  self._parse_source_state_parse_source_state(state)
469 
470  if self.hasshass.is_running:
471  # Update person now if hass is already running.
472  self._async_update_config_async_update_config(self._config_config)
473  else:
474  # Wait for hass start to not have race between person
475  # and device trackers finishing setup.
476  @callback
477  def _async_person_start_hass(_: Event) -> None:
478  self._async_update_config_async_update_config(self._config_config)
479 
480  self.hasshass.bus.async_listen_once(
481  EVENT_HOMEASSISTANT_START, _async_person_start_hass
482  )
483  # Update extra state attributes now
484  # as there are attributes that can already be set
485  self._update_extra_state_attributes_update_extra_state_attributes()
486 
487  async def async_update_config(self, config: ConfigType) -> None:
488  """Handle when the config is updated."""
489  self._async_update_config_async_update_config(config)
490 
491  @callback
492  def _async_update_config(self, config: ConfigType) -> None:
493  """Handle when the config is updated."""
494  self._config_config = config
495  self._set_attrs_from_config_set_attrs_from_config()
496 
497  if self._unsub_track_device_unsub_track_device is not None:
498  self._unsub_track_device_unsub_track_device()
499  self._unsub_track_device_unsub_track_device = None
500 
501  if trackers := self._config_config[CONF_DEVICE_TRACKERS]:
502  _LOGGER.debug("Subscribe to device trackers for %s", self.entity_identity_id)
503 
504  self._unsub_track_device_unsub_track_device = async_track_state_change_event(
505  self.hasshass, trackers, self._async_handle_tracker_update_async_handle_tracker_update
506  )
507 
508  self._update_state_update_state()
509 
510  @callback
511  def _async_handle_tracker_update(self, event: Event[EventStateChangedData]) -> None:
512  """Handle the device tracker state changes."""
513  self._update_state_update_state()
514 
515  @callback
516  def _update_state(self) -> None:
517  """Update the state."""
518  latest_non_gps_home = latest_not_home = latest_gps = latest = None
519  for entity_id in self._config_config[CONF_DEVICE_TRACKERS]:
520  state = self.hasshass.states.get(entity_id)
521 
522  if not state or state.state in IGNORE_STATES:
523  continue
524 
525  if state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
526  latest_gps = _get_latest(latest_gps, state)
527  elif state.state == STATE_HOME:
528  latest_non_gps_home = _get_latest(latest_non_gps_home, state)
529  elif state.state == STATE_NOT_HOME:
530  latest_not_home = _get_latest(latest_not_home, state)
531 
532  if latest_non_gps_home:
533  latest = latest_non_gps_home
534  elif latest_gps:
535  latest = latest_gps
536  else:
537  latest = latest_not_home
538 
539  if latest:
540  self._parse_source_state_parse_source_state(latest)
541  else:
542  self._attr_state_attr_state = None
543  self._source_source = None
544  self._latitude_latitude = None
545  self._longitude_longitude = None
546  self._gps_accuracy_gps_accuracy = None
547 
548  self._update_extra_state_attributes_update_extra_state_attributes()
549  self.async_write_ha_stateasync_write_ha_state()
550 
551  @callback
552  def _parse_source_state(self, state: State) -> None:
553  """Parse source state and set person attributes.
554 
555  This is a device tracker state or the restored person state.
556  """
557  self._attr_state_attr_state = state.state
558  self._source_source = state.entity_id
559  self._latitude_latitude = state.attributes.get(ATTR_LATITUDE)
560  self._longitude_longitude = state.attributes.get(ATTR_LONGITUDE)
561  self._gps_accuracy_gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)
562 
563  @callback
565  """Update extra state attributes."""
566  data: dict[str, Any] = {
567  ATTR_EDITABLE: self.editable,
568  ATTR_ID: self.unique_idunique_id,
569  ATTR_DEVICE_TRACKERS: self.device_trackersdevice_trackers,
570  }
571 
572  if self._latitude_latitude is not None:
573  data[ATTR_LATITUDE] = self._latitude_latitude
574  if self._longitude_longitude is not None:
575  data[ATTR_LONGITUDE] = self._longitude_longitude
576  if self._gps_accuracy_gps_accuracy is not None:
577  data[ATTR_GPS_ACCURACY] = self._gps_accuracy_gps_accuracy
578  if self._source_source is not None:
579  data[ATTR_SOURCE] = self._source_source
580  if (user_id := self._config_config.get(CONF_USER_ID)) is not None:
581  data[ATTR_USER_ID] = user_id
582 
583  self._attr_extra_state_attributes_attr_extra_state_attributes = data
584 
585 
586 def _get_latest(prev: State | None, curr: State) -> State:
587  """Get latest state."""
588  if prev is None or curr.last_updated > prev.last_updated:
589  return curr
590  return prev
None ws_list_item(self, HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: __init__.py:316
bool _entity_registry_filter(self, er.EventEntityRegistryUpdatedData event_data)
Definition: __init__.py:246
None __init__(self, Store store, collection.IDManager id_manager, collection.YamlCollection yaml_collection)
Definition: __init__.py:212
dict _update_data(self, dict item, dict update_data)
Definition: __init__.py:287
collection.SerializedStorageCollection|None _async_load_data(self)
Definition: __init__.py:217
None _entity_registry_updated(self, Event[er.EventEntityRegistryUpdatedData] event)
Definition: __init__.py:255
dict[str, Any] _async_migrate_func(self, int old_major_version, int old_minor_version, dict[str, Any] old_data)
Definition: __init__.py:193
Self from_yaml(cls, ConfigType config)
Definition: __init__.py:458
None __init__(self, dict[str, Any] config)
Definition: __init__.py:430
None _async_handle_tracker_update(self, Event[EventStateChangedData] event)
Definition: __init__.py:511
Self from_storage(cls, ConfigType config)
Definition: __init__.py:451
None _parse_source_state(self, State state)
Definition: __init__.py:552
None async_update_config(self, ConfigType config)
Definition: __init__.py:487
None _async_update_config(self, ConfigType config)
Definition: __init__.py:492
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
State _get_latest(State|None prev, State curr)
Definition: __init__.py:586
list[str] entities_in_person(HomeAssistant hass, str entity_id)
Definition: __init__.py:155
list[str] persons_with_entity(HomeAssistant hass, str entity_id)
Definition: __init__.py:137
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:361
list[dict] filter_yaml_data(HomeAssistant hass, list[dict] persons)
Definition: __init__.py:325
None async_add_user_device_tracker(HomeAssistant hass, str user_id, str device_tracker_entity_id)
Definition: __init__.py:116
None async_create_person(HomeAssistant hass, str name, *str|None user_id=None, list[str]|None device_trackers=None)
Definition: __init__.py:102
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214
_ItemT async_create_item(self, dict data)
Definition: collection.py:314
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