1 """Support for tracking people."""
3 from __future__
import annotations
5 from collections.abc
import Callable
7 from typing
import Any, Self
9 import voluptuous
as vol
15 DOMAIN
as DEVICE_TRACKER_DOMAIN,
27 EVENT_HOMEASSISTANT_START,
36 EventStateChangedData,
45 config_validation
as cv,
46 entity_registry
as er,
56 from .const
import DOMAIN
58 _LOGGER = logging.getLogger(__name__)
60 ATTR_SOURCE =
"source"
61 ATTR_USER_ID =
"user_id"
62 ATTR_DEVICE_TRACKERS =
"device_trackers"
64 CONF_DEVICE_TRACKERS =
"device_trackers"
65 CONF_USER_ID =
"user_id"
66 CONF_PICTURE =
"picture"
71 IGNORE_STATES = (STATE_UNKNOWN, STATE_UNAVAILABLE)
73 PERSON_SCHEMA = vol.Schema(
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)
81 vol.Optional(CONF_PICTURE): cv.string,
85 CONFIG_SCHEMA = vol.Schema(
87 vol.Optional(DOMAIN, default=[]): vol.All(
88 cv.ensure_list, cv.remove_falsy, [PERSON_SCHEMA]
91 extra=vol.ALLOW_EXTRA,
100 user_id: str |
None =
None,
101 device_trackers: list[str] |
None =
None,
103 """Create a new person."""
107 ATTR_USER_ID: user_id,
108 CONF_DEVICE_TRACKERS: device_trackers
or [],
115 hass: HomeAssistant, user_id: str, device_tracker_entity_id: str
117 """Add a device tracker to a person linked to a user."""
118 coll: PersonStorageCollection = hass.data[DOMAIN][1]
120 for person
in coll.async_items():
121 if person.get(ATTR_USER_ID) != user_id:
124 device_trackers = person[CONF_DEVICE_TRACKERS]
126 if device_tracker_entity_id
in device_trackers:
129 await coll.async_update_item(
131 {CONF_DEVICE_TRACKERS: [*device_trackers, device_tracker_entity_id]},
138 """Return all persons that reference the entity."""
140 DOMAIN
not in hass.data
145 component: EntityComponent[Person] = hass.data[DOMAIN][2]
148 person_entity.entity_id
149 for person_entity
in component.entities
150 if entity_id
in person_entity.device_trackers
156 """Return all entities belonging to a person."""
157 if DOMAIN
not in hass.data:
160 component: EntityComponent[Person] = hass.data[DOMAIN][2]
162 if (person_entity := component.get_entity(entity_id))
is None:
165 return person_entity.device_trackers
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)
174 vol.Optional(CONF_PICTURE): vol.Any(str,
None),
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)
184 vol.Optional(CONF_PICTURE): vol.Any(str,
None),
189 """Person storage."""
192 self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any]
194 """Migrate to the new version.
196 Migrate storage to use format of collection helper.
198 return {
"items": old_data[
"persons"]}
202 """Person collection stored in storage."""
204 CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
205 UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
210 id_manager: collection.IDManager,
211 yaml_collection: collection.YamlCollection,
213 """Initialize a person storage collection."""
220 A past bug caused onboarding to create invalid person objects.
228 for person
in data[
"items"]:
229 if person[CONF_DEVICE_TRACKERS]
is None:
230 person[CONF_DEVICE_TRACKERS] = []
235 """Load the Storage collection."""
237 self.hass.bus.async_listen(
238 er.EVENT_ENTITY_REGISTRY_UPDATED,
245 self, event_data: er.EventEntityRegistryUpdatedData
247 """Filter entity registry events."""
249 event_data[
"action"] ==
"remove"
254 self, event: Event[er.EventEntityRegistryUpdatedData]
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]:
262 await self.async_update_item(
265 CONF_DEVICE_TRACKERS: [
267 for devt
in person[CONF_DEVICE_TRACKERS]
274 """Validate the config is valid."""
277 if (user_id := data.get(CONF_USER_ID))
is not None:
284 """Suggest an ID based on the config."""
285 return info[CONF_NAME]
288 """Return a new updated data object."""
291 user_id: str |
None = update_data.get(CONF_USER_ID)
293 if user_id
is not None and user_id != item.get(CONF_USER_ID):
296 return {**item, **update_data}
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")
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")
309 """Class to expose storage collection management over websocket."""
318 yaml, storage, _ = hass.data[DOMAIN]
319 connection.send_result(
321 {
"storage": storage.async_items(),
"config": yaml.async_items()},
326 """Validate YAML data that we can't validate via schema."""
328 person_invalid_user = []
330 for person_conf
in persons:
331 user_id = person_conf.get(CONF_USER_ID)
333 if user_id
is not None and await hass.auth.async_get_user(user_id)
is None:
335 "Invalid user_id detected for person %s",
336 person_conf[CONF_ID],
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}"
344 filtered.append(person_conf)
346 if person_invalid_user:
347 persistent_notification.async_create(
350 The following persons point at invalid users:
352 {"- ".join(person_invalid_user)}
354 "Invalid Person Configuration",
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
374 collection.sync_entity_lifecycle(
375 hass, DOMAIN, DOMAIN, entity_component, yaml_collection, Person
377 collection.sync_entity_lifecycle(
378 hass, DOMAIN, DOMAIN, entity_component, storage_collection, Person
381 await yaml_collection.async_load(
384 await storage_collection.async_load()
386 hass.data[DOMAIN] = (yaml_collection, storage_collection, entity_component)
389 storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
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}
401 hass.bus.async_listen(EVENT_USER_REMOVED, _handle_user_removed)
403 async
def async_reload_yaml(call: ServiceCall) ->
None:
405 conf = await entity_component.async_prepare_reload(skip_reset=
True)
408 await yaml_collection.async_load(
412 service.async_register_admin_service(
413 hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml
420 collection.CollectionEntity,
423 """Represent a tracked person."""
425 _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS})
427 _attr_should_poll =
False
430 def __init__(self, config: dict[str, Any]) ->
None:
433 self.
_latitude_latitude: float |
None =
None
434 self.
_longitude_longitude: float |
None =
None
436 self.
_source_source: str |
None =
None
445 """Set attributes from config."""
452 """Return entity instance initialized from storage."""
454 person.editable =
True
459 """Return entity instance initialized from yaml."""
461 person.editable =
False
465 """Register device trackers."""
470 if self.
hasshass.is_running:
477 def _async_person_start_hass(_: Event) ->
None:
480 self.
hasshass.bus.async_listen_once(
481 EVENT_HOMEASSISTANT_START, _async_person_start_hass
488 """Handle when the config is updated."""
493 """Handle when the config is updated."""
501 if trackers := self.
_config_config[CONF_DEVICE_TRACKERS]:
502 _LOGGER.debug(
"Subscribe to device trackers for %s", self.
entity_identity_id)
512 """Handle the device tracker state changes."""
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)
522 if not state
or state.state
in IGNORE_STATES:
525 if state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
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)
532 if latest_non_gps_home:
533 latest = latest_non_gps_home
537 latest = latest_not_home
553 """Parse source state and set person attributes.
555 This is a device tracker state or the restored person 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)
565 """Update extra state attributes."""
566 data: dict[str, Any] = {
567 ATTR_EDITABLE: self.editable,
573 data[ATTR_LATITUDE] = self.
_latitude_latitude
575 data[ATTR_LONGITUDE] = self.
_longitude_longitude
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
587 """Get latest state."""
588 if prev
is None or curr.last_updated > prev.last_updated:
None ws_list_item(self, HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
dict _process_create_data(self, dict data)
bool _entity_registry_filter(self, er.EventEntityRegistryUpdatedData event_data)
None __init__(self, Store store, collection.IDManager id_manager, collection.YamlCollection yaml_collection)
None _validate_user_id(self, str user_id)
str _get_suggested_id(self, dict info)
dict _update_data(self, dict item, dict update_data)
collection.SerializedStorageCollection|None _async_load_data(self)
None _entity_registry_updated(self, Event[er.EventEntityRegistryUpdatedData] event)
dict[str, Any] _async_migrate_func(self, int old_major_version, int old_minor_version, dict[str, Any] old_data)
Self from_yaml(cls, ConfigType config)
None async_added_to_hass(self)
None __init__(self, dict[str, Any] config)
None _async_handle_tracker_update(self, Event[EventStateChangedData] event)
None _set_attrs_from_config(self)
Self from_storage(cls, ConfigType config)
None _parse_source_state(self, State state)
None async_update_config(self, ConfigType config)
_attr_extra_state_attributes
None _update_extra_state_attributes(self)
None _async_update_config(self, ConfigType config)
None async_write_ha_state(self)
State|None async_get_last_state(self)
web.Response get(self, web.Request request, str config_key)
State _get_latest(State|None prev, State curr)
list[str] entities_in_person(HomeAssistant hass, str entity_id)
list[str] persons_with_entity(HomeAssistant hass, str entity_id)
bool async_setup(HomeAssistant hass, ConfigType config)
list[dict] filter_yaml_data(HomeAssistant hass, list[dict] persons)
None async_add_user_device_tracker(HomeAssistant hass, str user_id, str device_tracker_entity_id)
None async_create_person(HomeAssistant hass, str name, *str|None user_id=None, list[str]|None device_trackers=None)
tuple[str, str] split_entity_id(str entity_id)
_ItemT async_create_item(self, dict data)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)