Home Assistant Unofficial Reference 2024.12.1
config_entry.py
Go to the documentation of this file.
1 """Code to set up a device tracker platform using a config entry."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from typing import final
7 
8 from propcache import cached_property
9 
10 from homeassistant.components import zone
11 from homeassistant.config_entries import ConfigEntry
12 from homeassistant.const import (
13  ATTR_BATTERY_LEVEL,
14  ATTR_GPS_ACCURACY,
15  ATTR_LATITUDE,
16  ATTR_LONGITUDE,
17  STATE_HOME,
18  STATE_NOT_HOME,
19  EntityCategory,
20 )
21 from homeassistant.core import Event, HomeAssistant, callback
22 from homeassistant.helpers import device_registry as dr, entity_registry as er
24  DeviceInfo,
25  EventDeviceRegistryUpdatedData,
26 )
27 from homeassistant.helpers.dispatcher import async_dispatcher_send
28 from homeassistant.helpers.entity import Entity, EntityDescription
29 from homeassistant.helpers.entity_component import EntityComponent
30 from homeassistant.helpers.entity_platform import EntityPlatform
31 from homeassistant.helpers.typing import StateType
32 from homeassistant.util.hass_dict import HassKey
33 
34 from .const import (
35  ATTR_HOST_NAME,
36  ATTR_IP,
37  ATTR_MAC,
38  ATTR_SOURCE_TYPE,
39  CONNECTED_DEVICE_REGISTERED,
40  DOMAIN,
41  LOGGER,
42  SourceType,
43 )
44 
45 DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
46 DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
47 
48 # mypy: disallow-any-generics
49 
50 
51 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
52  """Set up an entry."""
53  component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
54 
55  if component is not None:
56  return await component.async_setup_entry(entry)
57 
58  component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
59  LOGGER, DOMAIN, hass
60  )
61  component.register_shutdown()
62 
63  return await component.async_setup_entry(entry)
64 
65 
66 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
67  """Unload an entry."""
68  return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
69 
70 
71 @callback
73  hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
74 ) -> None:
75  """Register a newly seen connected device.
76 
77  This is currently used by the dhcp integration
78  to listen for newly registered connected devices
79  for discovery.
80  """
82  hass,
83  CONNECTED_DEVICE_REGISTERED,
84  {
85  ATTR_IP: ip_address,
86  ATTR_MAC: mac,
87  ATTR_HOST_NAME: hostname,
88  },
89  )
90 
91 
92 @callback
94  hass: HomeAssistant,
95  domain: str,
96  mac: str,
97  unique_id: str,
98 ) -> None:
99  """Register a mac address with a unique ID."""
100  mac = dr.format_mac(mac)
101  if DATA_KEY in hass.data:
102  hass.data[DATA_KEY][mac] = (domain, unique_id)
103  return
104 
105  # Setup listening.
106 
107  # dict mapping mac -> partial unique ID
108  data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
109 
110  @callback
111  def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
112  """Enable the online status entity for the mac of a newly created device."""
113  # Only for new devices
114  if ev.data["action"] != "create":
115  return
116 
117  dev_reg = dr.async_get(hass)
118  device_entry = dev_reg.async_get(ev.data["device_id"])
119 
120  if device_entry is None:
121  # This should not happen, since the device was just created.
122  return
123 
124  # Check if device has a mac
125  mac = None
126  for conn in device_entry.connections:
127  if conn[0] == dr.CONNECTION_NETWORK_MAC:
128  mac = conn[1]
129  break
130 
131  if mac is None:
132  return
133 
134  # Check if we have an entity for this mac
135  if (unique_id := data.get(mac)) is None:
136  return
137 
138  ent_reg = er.async_get(hass)
139 
140  if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
141  return
142 
143  entity_entry = ent_reg.entities[entity_id]
144 
145  # Make sure entity has a config entry and was disabled by the
146  # default disable logic in the integration and new entities
147  # are allowed to be added.
148  if (
149  entity_entry.config_entry_id is None
150  or (
151  (
152  config_entry := hass.config_entries.async_get_entry(
153  entity_entry.config_entry_id
154  )
155  )
156  is not None
157  and config_entry.pref_disable_new_entities
158  )
159  or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
160  ):
161  return
162 
163  # Enable entity
164  ent_reg.async_update_entity(entity_id, disabled_by=None)
165 
166  hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
167 
168 
170  """Represent a tracked device."""
171 
172  _attr_device_info: None = None
173  _attr_entity_category = EntityCategory.DIAGNOSTIC
174  _attr_source_type: SourceType
175 
176  @cached_property
177  def battery_level(self) -> int | None:
178  """Return the battery level of the device.
179 
180  Percentage from 0-100.
181  """
182  return None
183 
184  @property
185  def source_type(self) -> SourceType:
186  """Return the source type, eg gps or router, of the device."""
187  if hasattr(self, "_attr_source_type"):
188  return self._attr_source_type
189  raise NotImplementedError
190 
191  @property
192  def state_attributes(self) -> dict[str, StateType]:
193  """Return the device state attributes."""
194  attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_typesource_type}
195 
196  if self.battery_levelbattery_level is not None:
197  attr[ATTR_BATTERY_LEVEL] = self.battery_levelbattery_level
198 
199  return attr
200 
201 
202 class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
203  """A class that describes tracker entities."""
204 
205 
206 CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
207  "latitude",
208  "location_accuracy",
209  "location_name",
210  "longitude",
211 }
212 
213 
215  BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
216 ):
217  """Base class for a tracked device."""
218 
219  entity_description: TrackerEntityDescription
220  _attr_latitude: float | None = None
221  _attr_location_accuracy: int = 0
222  _attr_location_name: str | None = None
223  _attr_longitude: float | None = None
224  _attr_source_type: SourceType = SourceType.GPS
225 
226  @cached_property
227  def should_poll(self) -> bool:
228  """No polling for entities that have location pushed."""
229  return False
230 
231  @property
232  def force_update(self) -> bool:
233  """All updates need to be written to the state machine if we're not polling."""
234  return not self.should_pollshould_pollshould_poll
235 
236  @cached_property
237  def location_accuracy(self) -> int:
238  """Return the location accuracy of the device.
239 
240  Value in meters.
241  """
242  return self._attr_location_accuracy
243 
244  @cached_property
245  def location_name(self) -> str | None:
246  """Return a location name for the current location of the device."""
247  return self._attr_location_name
248 
249  @cached_property
250  def latitude(self) -> float | None:
251  """Return latitude value of the device."""
252  return self._attr_latitude
253 
254  @cached_property
255  def longitude(self) -> float | None:
256  """Return longitude value of the device."""
257  return self._attr_longitude
258 
259  @property
260  def state(self) -> str | None:
261  """Return the state of the device."""
262  if self.location_namelocation_name is not None:
263  return self.location_namelocation_name
264 
265  if self.latitudelatitude is not None and self.longitudelongitude is not None:
266  zone_state = zone.async_active_zone(
267  self.hasshass, self.latitudelatitude, self.longitudelongitude, self.location_accuracylocation_accuracy
268  )
269  if zone_state is None:
270  state = STATE_NOT_HOME
271  elif zone_state.entity_id == zone.ENTITY_ID_HOME:
272  state = STATE_HOME
273  else:
274  state = zone_state.name
275  return state
276 
277  return None
278 
279  @final
280  @property
281  def state_attributes(self) -> dict[str, StateType]:
282  """Return the device state attributes."""
283  attr: dict[str, StateType] = {}
284  attr.update(super().state_attributes)
285 
286  if self.latitudelatitude is not None and self.longitudelongitude is not None:
287  attr[ATTR_LATITUDE] = self.latitudelatitude
288  attr[ATTR_LONGITUDE] = self.longitudelongitude
289  attr[ATTR_GPS_ACCURACY] = self.location_accuracylocation_accuracy
290 
291  return attr
292 
293 
294 class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
295  """A class that describes tracker entities."""
296 
297 
298 CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
299  "ip_address",
300  "mac_address",
301  "hostname",
302 }
303 
304 
306  BaseTrackerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
307 ):
308  """Base class for a tracked device that is on a scanned network."""
309 
310  entity_description: ScannerEntityDescription
311  _attr_hostname: str | None = None
312  _attr_ip_address: str | None = None
313  _attr_mac_address: str | None = None
314  _attr_source_type: SourceType = SourceType.ROUTER
315 
316  @cached_property
317  def ip_address(self) -> str | None:
318  """Return the primary ip address of the device."""
319  return self._attr_ip_address
320 
321  @cached_property
322  def mac_address(self) -> str | None:
323  """Return the mac address of the device."""
324  return self._attr_mac_address
325 
326  @cached_property
327  def hostname(self) -> str | None:
328  """Return hostname of the device."""
329  return self._attr_hostname
330 
331  @property
332  def state(self) -> str:
333  """Return the state of the device."""
334  if self.is_connectedis_connected:
335  return STATE_HOME
336  return STATE_NOT_HOME
337 
338  @property
339  def is_connected(self) -> bool:
340  """Return true if the device is connected to the network."""
341  raise NotImplementedError
342 
343  @property
344  def unique_id(self) -> str | None:
345  """Return unique ID of the entity."""
346  return self.mac_addressmac_address
347 
348  @final
349  @property
350  def device_info(self) -> DeviceInfo | None:
351  """Device tracker entities should not create device registry entries."""
352  return None
353 
354  @property
356  """Return if entity is enabled by default."""
357  # If mac_address is None, we can never find a device entry.
358  return (
359  # Do not disable if we won't activate our attach to device logic
360  self.mac_addressmac_address is None
361  or self.device_infodevice_infodevice_info is not None
362  # Disable if we automatically attach but there is no device
363  or self.find_device_entryfind_device_entry() is not None
364  )
365 
366  @callback
368  self,
369  hass: HomeAssistant,
370  platform: EntityPlatform,
371  parallel_updates: asyncio.Semaphore | None,
372  ) -> None:
373  """Start adding an entity to a platform."""
374  super().add_to_platform_start(hass, platform, parallel_updates)
375  if self.mac_addressmac_address and self.unique_idunique_idunique_id:
377  hass,
378  platform.platform_name,
379  self.mac_addressmac_address,
380  self.unique_idunique_idunique_id,
381  )
382  if self.is_connectedis_connected and self.ip_addressip_address:
384  hass,
385  self.mac_addressmac_address,
386  self.ip_addressip_address,
387  self.hostnamehostname,
388  )
389 
390  @callback
391  def find_device_entry(self) -> dr.DeviceEntry | None:
392  """Return device entry."""
393  assert self.mac_addressmac_address is not None
394 
395  return dr.async_get(self.hasshass).async_get_device(
396  connections={(dr.CONNECTION_NETWORK_MAC, self.mac_addressmac_address)}
397  )
398 
399  async def async_internal_added_to_hass(self) -> None:
400  """Handle added to Home Assistant."""
401  # Entities without a unique ID don't have a device
402  if (
403  not self.registry_entryregistry_entryregistry_entry
404  or not self.platformplatform.config_entry
405  or not self.mac_addressmac_address
406  or (device_entry := self.find_device_entryfind_device_entry()) is None
407  # Entities should not have a device info. We opt them out
408  # of this logic if they do.
409  or self.device_infodevice_infodevice_info
410  ):
411  if self.device_infodevice_infodevice_info:
412  LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_identity_id)
413  await super().async_internal_added_to_hass()
414  return
415 
416  # Attach entry to device
417  if self.registry_entryregistry_entryregistry_entry.device_id != device_entry.id:
418  self.registry_entryregistry_entryregistry_entry = er.async_get(self.hasshass).async_update_entity(
419  self.entity_identity_id, device_id=device_entry.id
420  )
421 
422  # Attach device to config entry
423  if self.platformplatform.config_entry.entry_id not in device_entry.config_entries:
424  dr.async_get(self.hasshass).async_update_device(
425  device_entry.id,
426  add_config_entry_id=self.platformplatform.config_entry.entry_id,
427  )
428 
429  # Do this last or else the entity registry update listener has been installed
430  await super().async_internal_added_to_hass()
431 
432  @final
433  @property
434  def state_attributes(self) -> dict[str, StateType]:
435  """Return the device state attributes."""
436  attr = super().state_attributes
437 
438  if ip_address := self.ip_addressip_address:
439  attr[ATTR_IP] = ip_address
440  if (mac_address := self.mac_addressmac_address) is not None:
441  attr[ATTR_MAC] = mac_address
442  if (hostname := self.hostnamehostname) is not None:
443  attr[ATTR_HOST_NAME] = hostname
444 
445  return attr
dict[str, StateType] state_attributes(self)
SourceType source_type(self)
int|None battery_level(self)
registry_entry
dr.DeviceEntry|None find_device_entry(self)
None add_to_platform_start(self, HomeAssistant hass, EntityPlatform platform, asyncio.Semaphore|None parallel_updates)
str|None unique_id(self)
dict[str, StateType] state_attributes(self)
bool is_connected(self)
str|None hostname(self)
DeviceInfo|None device_info(self)
str|None ip_address(self)
bool entity_registry_enabled_default(self)
str state(self)
str|None mac_address(self)
None async_internal_added_to_hass(self)
int location_accuracy(self)
float|None longitude(self)
bool should_poll(self)
str|None state(self)
str|None location_name(self)
float|None latitude(self)
dict[str, StateType] state_attributes(self)
bool force_update(self)
DeviceInfo|None device_info(self)
Definition: entity.py:798
None async_update_device(HomeAssistant hass, ConfigEntry entry, str adapter, AdapterDetails details)
Definition: __init__.py:294
None _async_register_mac(HomeAssistant hass, str domain, str mac, str unique_id)
Definition: config_entry.py:98
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: config_entry.py:51
None _async_connected_device_registered(HomeAssistant hass, str mac, str|None ip_address, str|None hostname)
Definition: config_entry.py:74
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: config_entry.py:66
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
None async_update_entity(HomeAssistant hass, str entity_id)