Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for the definition of zones."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Iterable
6 import logging
7 from operator import attrgetter
8 import sys
9 from typing import Any, Self, cast
10 
11 import voluptuous as vol
12 
13 from homeassistant import config_entries
14 from homeassistant.const import (
15  ATTR_EDITABLE,
16  ATTR_LATITUDE,
17  ATTR_LONGITUDE,
18  ATTR_PERSONS,
19  CONF_ICON,
20  CONF_ID,
21  CONF_LATITUDE,
22  CONF_LONGITUDE,
23  CONF_NAME,
24  CONF_RADIUS,
25  EVENT_CORE_CONFIG_UPDATE,
26  SERVICE_RELOAD,
27  STATE_HOME,
28  STATE_NOT_HOME,
29  STATE_UNAVAILABLE,
30  STATE_UNKNOWN,
31 )
32 from homeassistant.core import (
33  Event,
34  EventStateChangedData,
35  HomeAssistant,
36  ServiceCall,
37  State,
38  callback,
39 )
40 from homeassistant.helpers import (
41  collection,
42  config_validation as cv,
43  entity_component,
44  event,
45  service,
46  storage,
47 )
48 from homeassistant.helpers.typing import ConfigType, VolDictType
49 from homeassistant.loader import bind_hass
50 from homeassistant.util.location import distance
51 
52 from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE
53 
54 _LOGGER = logging.getLogger(__name__)
55 
56 DEFAULT_PASSIVE = False
57 DEFAULT_RADIUS = 100
58 
59 ENTITY_ID_FORMAT = "zone.{}"
60 ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE)
61 
62 ICON_HOME = "mdi:home"
63 ICON_IMPORT = "mdi:import"
64 
65 CREATE_FIELDS: VolDictType = {
66  vol.Required(CONF_NAME): cv.string,
67  vol.Required(CONF_LATITUDE): cv.latitude,
68  vol.Required(CONF_LONGITUDE): cv.longitude,
69  vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float),
70  vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean,
71  vol.Optional(CONF_ICON): cv.icon,
72 }
73 
74 
75 UPDATE_FIELDS: VolDictType = {
76  vol.Optional(CONF_NAME): cv.string,
77  vol.Optional(CONF_LATITUDE): cv.latitude,
78  vol.Optional(CONF_LONGITUDE): cv.longitude,
79  vol.Optional(CONF_RADIUS): vol.Coerce(float),
80  vol.Optional(CONF_PASSIVE): cv.boolean,
81  vol.Optional(CONF_ICON): cv.icon,
82 }
83 
84 
85 def empty_value(value: Any) -> Any:
86  """Test if the user has the default config value from adding "zone:"."""
87  if isinstance(value, dict) and len(value) == 0:
88  return []
89 
90  raise vol.Invalid("Not a default value")
91 
92 
93 CONFIG_SCHEMA = vol.Schema(
94  {
95  vol.Optional(DOMAIN, default=[]): vol.Any(
96  vol.All(cv.ensure_list, [vol.Schema(CREATE_FIELDS)]),
97  empty_value,
98  )
99  },
100  extra=vol.ALLOW_EXTRA,
101 )
102 
103 RELOAD_SERVICE_SCHEMA = vol.Schema({})
104 STORAGE_KEY = DOMAIN
105 STORAGE_VERSION = 1
106 
107 ENTITY_ID_SORTER = attrgetter("entity_id")
108 
109 ZONE_ENTITY_IDS = "zone_entity_ids"
110 
111 
112 @bind_hass
114  hass: HomeAssistant, latitude: float, longitude: float, radius: int = 0
115 ) -> State | None:
116  """Find the active zone for given latitude, longitude.
117 
118  This method must be run in the event loop.
119  """
120  # Sort entity IDs so that we are deterministic if equal distance to 2 zones
121  min_dist: float = sys.maxsize
122  closest: State | None = None
123 
124  # This can be called before async_setup by device tracker
125  zone_entity_ids: Iterable[str] = hass.data.get(ZONE_ENTITY_IDS, ())
126 
127  for entity_id in zone_entity_ids:
128  if (
129  not (zone := hass.states.get(entity_id))
130  # Skip unavailable zones
131  or zone.state == STATE_UNAVAILABLE
132  # Skip passive zones
133  or (zone_attrs := zone.attributes).get(ATTR_PASSIVE)
134  # Skip zones where we cannot calculate distance
135  or (
136  zone_dist := distance(
137  latitude,
138  longitude,
139  zone_attrs[ATTR_LATITUDE],
140  zone_attrs[ATTR_LONGITUDE],
141  )
142  )
143  is None
144  # Skip zone that are outside the radius aka the
145  # lat/long is outside the zone
146  or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius)
147  ):
148  continue
149 
150  # If have a closest and its not closer than the closest skip it
151  if closest and not (
152  zone_dist < min_dist
153  or (
154  # If same distance, prefer smaller zone
155  zone_dist == min_dist and zone_radius < closest.attributes[ATTR_RADIUS]
156  )
157  ):
158  continue
159 
160  # We got here which means it closer than the previous known closest
161  # or equal distance but this one is smaller.
162  min_dist = zone_dist
163  closest = zone
164 
165  return closest
166 
167 
168 @callback
169 def async_setup_track_zone_entity_ids(hass: HomeAssistant) -> None:
170  """Set up track of entity IDs for zones."""
171  zone_entity_ids: list[str] = hass.states.async_entity_ids(DOMAIN)
172  hass.data[ZONE_ENTITY_IDS] = zone_entity_ids
173 
174  @callback
175  def _async_add_zone_entity_id(
176  event_: Event[EventStateChangedData],
177  ) -> None:
178  """Add zone entity ID."""
179  zone_entity_ids.append(event_.data["entity_id"])
180  zone_entity_ids.sort()
181 
182  @callback
183  def _async_remove_zone_entity_id(
184  event_: Event[EventStateChangedData],
185  ) -> None:
186  """Remove zone entity ID."""
187  zone_entity_ids.remove(event_.data["entity_id"])
188 
189  event.async_track_state_added_domain(hass, DOMAIN, _async_add_zone_entity_id)
190  event.async_track_state_removed_domain(hass, DOMAIN, _async_remove_zone_entity_id)
191 
192 
193 def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) -> bool:
194  """Test if given latitude, longitude is in given zone.
195 
196  Async friendly.
197  """
198  if zone.state == STATE_UNAVAILABLE:
199  return False
200 
201  zone_dist = distance(
202  latitude,
203  longitude,
204  zone.attributes[ATTR_LATITUDE],
205  zone.attributes[ATTR_LONGITUDE],
206  )
207 
208  if zone_dist is None or zone.attributes[ATTR_RADIUS] is None:
209  return False
210  return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS])
211 
212 
213 class ZoneStorageCollection(collection.DictStorageCollection):
214  """Zone collection stored in storage."""
215 
216  CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
217  UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
218 
219  async def _process_create_data(self, data: dict) -> dict:
220  """Validate the config is valid."""
221  return cast(dict, self.CREATE_SCHEMACREATE_SCHEMA(data))
222 
223  @callback
224  def _get_suggested_id(self, info: dict) -> str:
225  """Suggest an ID based on the config."""
226  return cast(str, info[CONF_NAME])
227 
228  async def _update_data(self, item: dict, update_data: dict) -> dict:
229  """Return a new updated data object."""
230  update_data = self.UPDATE_SCHEMAUPDATE_SCHEMA(update_data)
231  return {**item, **update_data}
232 
233 
234 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
235  """Set up configured zones as well as Home Assistant zone if necessary."""
237 
238  component = entity_component.EntityComponent[Zone](_LOGGER, DOMAIN, hass)
239  id_manager = collection.IDManager()
240 
241  yaml_collection = collection.IDLessCollection(
242  logging.getLogger(f"{__name__}.yaml_collection"), id_manager
243  )
244  collection.sync_entity_lifecycle(
245  hass, DOMAIN, DOMAIN, component, yaml_collection, Zone
246  )
247 
248  storage_collection = ZoneStorageCollection(
249  storage.Store(hass, STORAGE_VERSION, STORAGE_KEY),
250  id_manager,
251  )
252  collection.sync_entity_lifecycle(
253  hass, DOMAIN, DOMAIN, component, storage_collection, Zone
254  )
255 
256  if config[DOMAIN]:
257  await yaml_collection.async_load(config[DOMAIN])
258 
259  await storage_collection.async_load()
260 
261  collection.DictStorageCollectionWebsocket(
262  storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
263  ).async_setup(hass)
264 
265  async def reload_service_handler(service_call: ServiceCall) -> None:
266  """Remove all zones and load new ones from config."""
267  conf = await component.async_prepare_reload(skip_reset=True)
268  if conf is None:
269  return
270  await yaml_collection.async_load(conf[DOMAIN])
271 
272  service.async_register_admin_service(
273  hass,
274  DOMAIN,
275  SERVICE_RELOAD,
276  reload_service_handler,
277  schema=RELOAD_SERVICE_SCHEMA,
278  )
279 
280  if component.get_entity("zone.home"):
281  return True
282 
283  home_zone = Zone(_home_conf(hass))
284  home_zone.entity_id = ENTITY_ID_HOME
285  await component.async_add_entities([home_zone])
286 
287  async def core_config_updated(_: Event) -> None:
288  """Handle core config updated."""
289  await home_zone.async_update_config(_home_conf(hass))
290 
291  hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated)
292 
293  hass.data[DOMAIN] = storage_collection
294 
295  return True
296 
297 
298 @callback
299 def _home_conf(hass: HomeAssistant) -> dict:
300  """Return the home zone config."""
301  return {
302  CONF_NAME: hass.config.location_name,
303  CONF_LATITUDE: hass.config.latitude,
304  CONF_LONGITUDE: hass.config.longitude,
305  CONF_RADIUS: hass.config.radius,
306  CONF_ICON: ICON_HOME,
307  CONF_PASSIVE: False,
308  }
309 
310 
312  hass: HomeAssistant, config_entry: config_entries.ConfigEntry
313 ) -> bool:
314  """Set up zone as config entry."""
315  storage_collection = cast(ZoneStorageCollection, hass.data[DOMAIN])
316 
317  data = dict(config_entry.data)
318  data.setdefault(CONF_PASSIVE, DEFAULT_PASSIVE)
319  data.setdefault(CONF_RADIUS, DEFAULT_RADIUS)
320 
321  await storage_collection.async_create_item(data)
322 
323  hass.async_create_task(
324  hass.config_entries.async_remove(config_entry.entry_id), eager_start=True
325  )
326 
327  return True
328 
329 
331  hass: HomeAssistant, config_entry: config_entries.ConfigEntry
332 ) -> bool:
333  """Will be called once we remove it."""
334  return True
335 
336 
337 class Zone(collection.CollectionEntity):
338  """Representation of a Zone."""
339 
340  editable: bool
341  _attr_should_poll = False
342 
343  def __init__(self, config: ConfigType) -> None:
344  """Initialize the zone."""
345  self._config_config = config
346  self.editableeditable = True
347  self._attrs: dict | None = None
348  self._remove_listener: Callable[[], None] | None = None
349  self._persons_in_zone_persons_in_zone: set[str] = set()
350  self._set_attrs_from_config_set_attrs_from_config()
351 
352  def _set_attrs_from_config(self) -> None:
353  """Set the attributes from the config."""
354  config = self._config_config
355  name: str = config[CONF_NAME]
356  self._attr_name_attr_name = name
357  self._case_folded_name_case_folded_name = name.casefold()
358  self._attr_unique_id_attr_unique_id = config.get(CONF_ID)
359  self._attr_icon_attr_icon = config.get(CONF_ICON)
360 
361  @classmethod
362  def from_storage(cls, config: ConfigType) -> Self:
363  """Return entity instance initialized from storage."""
364  zone = cls(config)
365  zone.editable = True
366  zone._generate_attrs() # noqa: SLF001
367  return zone
368 
369  @classmethod
370  def from_yaml(cls, config: ConfigType) -> Self:
371  """Return entity instance initialized from yaml."""
372  zone = cls(config)
373  zone.editable = False
374  zone._generate_attrs() # noqa: SLF001
375  return zone
376 
377  @property
378  def state(self) -> int:
379  """Return the state property really does nothing for a zone."""
380  return len(self._persons_in_zone_persons_in_zone)
381 
382  async def async_update_config(self, config: ConfigType) -> None:
383  """Handle when the config is updated."""
384  if self._config_config == config:
385  return
386  self._config_config = config
387  self._set_attrs_from_config_set_attrs_from_config()
388  self._generate_attrs_generate_attrs()
389  self.async_write_ha_state()
390 
391  @callback
392  def _person_state_change_listener(self, evt: Event[EventStateChangedData]) -> None:
393  person_entity_id = evt.data["entity_id"]
394  persons_in_zone = self._persons_in_zone_persons_in_zone
395  cur_count = len(persons_in_zone)
396  if self._state_is_in_zone_state_is_in_zone(evt.data["new_state"]):
397  persons_in_zone.add(person_entity_id)
398  elif person_entity_id in persons_in_zone:
399  persons_in_zone.remove(person_entity_id)
400 
401  if len(persons_in_zone) != cur_count:
402  self._generate_attrs_generate_attrs()
403  self.async_write_ha_state()
404 
405  async def async_added_to_hass(self) -> None:
406  """Run when entity about to be added to hass."""
407  await super().async_added_to_hass()
408  person_domain = "person" # avoid circular import
409  self._persons_in_zone_persons_in_zone = {
410  state.entity_id
411  for state in self.hass.states.async_all(person_domain)
412  if self._state_is_in_zone_state_is_in_zone(state)
413  }
414  self._generate_attrs_generate_attrs()
415 
416  self.async_on_remove(
417  event.async_track_state_change_filtered(
418  self.hass,
419  event.TrackStates(False, set(), {person_domain}),
420  self._person_state_change_listener_person_state_change_listener,
421  ).async_remove
422  )
423 
424  @callback
425  def _generate_attrs(self) -> None:
426  """Generate new attrs based on config."""
427  self._attr_extra_state_attributes_attr_extra_state_attributes = {
428  ATTR_LATITUDE: self._config_config[CONF_LATITUDE],
429  ATTR_LONGITUDE: self._config_config[CONF_LONGITUDE],
430  ATTR_RADIUS: self._config_config[CONF_RADIUS],
431  ATTR_PASSIVE: self._config_config[CONF_PASSIVE],
432  ATTR_PERSONS: sorted(self._persons_in_zone_persons_in_zone),
433  ATTR_EDITABLE: self.editableeditable,
434  }
435 
436  @callback
437  def _state_is_in_zone(self, state: State | None) -> bool:
438  """Return if given state is in zone."""
439  return (
440  state is not None
441  and state.state
442  not in (
443  STATE_NOT_HOME,
444  STATE_UNKNOWN,
445  STATE_UNAVAILABLE,
446  )
447  and (
448  state.state.casefold() == self._case_folded_name_case_folded_name
449  or (state.state == STATE_HOME and self.entity_identity_id == ENTITY_ID_HOME)
450  )
451  )
dict _update_data(self, dict item, dict update_data)
Definition: __init__.py:228
bool _state_is_in_zone(self, State|None state)
Definition: __init__.py:437
None __init__(self, ConfigType config)
Definition: __init__.py:343
Self from_yaml(cls, ConfigType config)
Definition: __init__.py:370
None _person_state_change_listener(self, Event[EventStateChangedData] evt)
Definition: __init__.py:392
Self from_storage(cls, ConfigType config)
Definition: __init__.py:362
None async_update_config(self, ConfigType config)
Definition: __init__.py:382
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
Any empty_value(Any value)
Definition: __init__.py:85
bool async_setup_entry(HomeAssistant hass, config_entries.ConfigEntry config_entry)
Definition: __init__.py:313
State|None async_active_zone(HomeAssistant hass, float latitude, float longitude, int radius=0)
Definition: __init__.py:115
bool in_zone(State zone, float latitude, float longitude, float radius=0)
Definition: __init__.py:193
bool async_unload_entry(HomeAssistant hass, config_entries.ConfigEntry config_entry)
Definition: __init__.py:332
dict _home_conf(HomeAssistant hass)
Definition: __init__.py:299
None async_setup_track_zone_entity_ids(HomeAssistant hass)
Definition: __init__.py:169
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:234
def distance(hass, *args)
Definition: template.py:1796