Home Assistant Unofficial Reference 2024.12.1
device_registry.py
Go to the documentation of this file.
1 """Provide a way to connect entities belonging to one device."""
2 
3 from __future__ import annotations
4 
5 from collections import defaultdict
6 from collections.abc import Mapping
7 from datetime import datetime
8 from enum import StrEnum
9 from functools import lru_cache, partial
10 import logging
11 import time
12 from typing import TYPE_CHECKING, Any, Literal, TypedDict
13 
14 import attr
15 from yarl import URL
16 
17 from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
18 from homeassistant.core import (
19  Event,
20  HomeAssistant,
21  ReleaseChannel,
22  callback,
23  get_release_channel,
24 )
25 from homeassistant.exceptions import HomeAssistantError
26 from homeassistant.loader import async_suggest_report_issue
27 from homeassistant.util.dt import utc_from_timestamp, utcnow
28 from homeassistant.util.event_type import EventType
29 from homeassistant.util.hass_dict import HassKey
30 from homeassistant.util.json import format_unserializable_data
31 import homeassistant.util.uuid as uuid_util
32 
33 from . import storage, translation
34 from .debounce import Debouncer
35 from .deprecation import (
36  DeprecatedConstantEnum,
37  all_with_deprecated_constants,
38  check_if_deprecated_constant,
39  dir_with_deprecated_constants,
40 )
41 from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment
42 from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType
43 from .singleton import singleton
44 from .typing import UNDEFINED, UndefinedType
45 
46 if TYPE_CHECKING:
47  # mypy cannot workout _cache Protocol with attrs
48  from propcache import cached_property as under_cached_property
49 
50  from homeassistant.config_entries import ConfigEntry
51 
52  from . import entity_registry
53 else:
54  from propcache import under_cached_property
55 
56 _LOGGER = logging.getLogger(__name__)
57 
58 DATA_REGISTRY: HassKey[DeviceRegistry] = HassKey("device_registry")
59 EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = EventType(
60  "device_registry_updated"
61 )
62 STORAGE_KEY = "core.device_registry"
63 STORAGE_VERSION_MAJOR = 1
64 STORAGE_VERSION_MINOR = 8
65 
66 CLEANUP_DELAY = 10
67 
68 CONNECTION_BLUETOOTH = "bluetooth"
69 CONNECTION_NETWORK_MAC = "mac"
70 CONNECTION_UPNP = "upnp"
71 CONNECTION_ZIGBEE = "zigbee"
72 
73 ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30
74 
75 RUNTIME_ONLY_ATTRS = {"suggested_area"}
76 
77 CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"}
78 
79 
80 class DeviceEntryDisabler(StrEnum):
81  """What disabled a device entry."""
82 
83  CONFIG_ENTRY = "config_entry"
84  INTEGRATION = "integration"
85  USER = "user"
86 
87 
88 # DISABLED_* are deprecated, to be removed in 2022.3
89 _DEPRECATED_DISABLED_CONFIG_ENTRY = DeprecatedConstantEnum(
90  DeviceEntryDisabler.CONFIG_ENTRY, "2025.1"
91 )
92 _DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum(
93  DeviceEntryDisabler.INTEGRATION, "2025.1"
94 )
95 _DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1")
96 
97 
98 class DeviceInfo(TypedDict, total=False):
99  """Entity device information for device registry."""
100 
101  configuration_url: str | URL | None
102  connections: set[tuple[str, str]]
103  created_at: str
104  default_manufacturer: str
105  default_model: str
106  default_name: str
107  entry_type: DeviceEntryType | None
108  identifiers: set[tuple[str, str]]
109  manufacturer: str | None
110  model: str | None
111  model_id: str | None
112  modified_at: str
113  name: str | None
114  serial_number: str | None
115  suggested_area: str | None
116  sw_version: str | None
117  hw_version: str | None
118  translation_key: str | None
119  translation_placeholders: Mapping[str, str] | None
120  via_device: tuple[str, str]
121 
122 
123 DEVICE_INFO_TYPES = {
124  # Device info is categorized by finding the first device info type which has all
125  # the keys of the device info. The link device info type must be kept first
126  # to make it preferred over primary.
127  "link": {
128  "connections",
129  "identifiers",
130  },
131  "primary": {
132  "configuration_url",
133  "connections",
134  "entry_type",
135  "hw_version",
136  "identifiers",
137  "manufacturer",
138  "model",
139  "model_id",
140  "name",
141  "serial_number",
142  "suggested_area",
143  "sw_version",
144  "via_device",
145  },
146  "secondary": {
147  "connections",
148  "default_manufacturer",
149  "default_model",
150  "default_name",
151  # Used by Fritz
152  "via_device",
153  },
154 }
155 
156 DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values()))
157 
158 # Integrations which may share a device with a native integration
159 LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"}
160 
161 
163  """EventDeviceRegistryUpdated data for action type 'create' and 'remove'."""
164 
165  action: Literal["create", "remove"]
166  device_id: str
167 
168 
170  """EventDeviceRegistryUpdated data for action type 'update'."""
171 
172  action: Literal["update"]
173  device_id: str
174  changes: dict[str, Any]
175 
176 
177 type EventDeviceRegistryUpdatedData = (
178  _EventDeviceRegistryUpdatedData_CreateRemove
179  | _EventDeviceRegistryUpdatedData_Update
180 )
181 
182 
183 class DeviceEntryType(StrEnum):
184  """Device entry type."""
185 
186  SERVICE = "service"
187 
188 
190  """Raised when device info is invalid."""
191 
192  def __init__(self, domain: str, device_info: DeviceInfo, message: str) -> None:
193  """Initialize error."""
194  super().__init__(
195  f"Invalid device info {device_info} for '{domain}' config entry: {message}",
196  )
197  self.device_infodevice_info = device_info
198  self.domaindomain = domain
199 
200 
202  """Raised when a device collision is detected."""
203 
204 
205 class DeviceIdentifierCollisionError(DeviceCollisionError):
206  """Raised when a device identifier collision is detected."""
207 
208  def __init__(
209  self, identifiers: set[tuple[str, str]], existing_device: DeviceEntry
210  ) -> None:
211  """Initialize error."""
212  super().__init__(
213  f"Identifiers {identifiers} already registered with {existing_device}"
214  )
215 
216 
218  """Raised when a device connection collision is detected."""
219 
220  def __init__(
221  self, normalized_connections: set[tuple[str, str]], existing_device: DeviceEntry
222  ) -> None:
223  """Initialize error."""
224  super().__init__(
225  f"Connections {normalized_connections} "
226  f"already registered with {existing_device}"
227  )
228 
229 
231  config_entry: ConfigEntry,
232  device_info: DeviceInfo,
233 ) -> str:
234  """Process a device info."""
235  keys = set(device_info)
236 
237  # If no keys or not enough info to match up, abort
238  if not device_info.get("connections") and not device_info.get("identifiers"):
239  raise DeviceInfoError(
240  config_entry.domain,
241  device_info,
242  "device info must include at least one of identifiers or connections",
243  )
244 
245  device_info_type: str | None = None
246 
247  # Find the first device info type which has all keys in the device info
248  for possible_type, allowed_keys in DEVICE_INFO_TYPES.items():
249  if keys <= allowed_keys:
250  device_info_type = possible_type
251  break
252 
253  if device_info_type is None:
254  raise DeviceInfoError(
255  config_entry.domain,
256  device_info,
257  (
258  "device info needs to either describe a device, "
259  "link to existing device or provide extra information."
260  ),
261  )
262 
263  return device_info_type
264 
265 
266 _cached_parse_url = lru_cache(maxsize=512)(URL)
267 """Parse a URL and cache the result."""
268 
269 
270 def _validate_configuration_url(value: Any) -> str | None:
271  """Validate and convert configuration_url."""
272  if value is None:
273  return None
274 
275  url_as_str = str(value)
276  url = value if type(value) is URL else _cached_parse_url(url_as_str)
277 
278  if url.scheme not in CONFIGURATION_URL_SCHEMES or not url.host:
279  raise ValueError(f"invalid configuration_url '{value}'")
280 
281  return url_as_str
282 
283 
284 @attr.s(frozen=True, slots=True)
286  """Device Registry Entry."""
287 
288  area_id: str | None = attr.ib(default=None)
289  config_entries: set[str] = attr.ib(converter=set, factory=set)
290  configuration_url: str | None = attr.ib(default=None)
291  connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
292  created_at: datetime = attr.ib(factory=utcnow)
293  disabled_by: DeviceEntryDisabler | None = attr.ib(default=None)
294  entry_type: DeviceEntryType | None = attr.ib(default=None)
295  hw_version: str | None = attr.ib(default=None)
296  id: str = attr.ib(factory=uuid_util.random_uuid_hex)
297  identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
298  labels: set[str] = attr.ib(converter=set, factory=set)
299  manufacturer: str | None = attr.ib(default=None)
300  model: str | None = attr.ib(default=None)
301  model_id: str | None = attr.ib(default=None)
302  modified_at: datetime = attr.ib(factory=utcnow)
303  name_by_user: str | None = attr.ib(default=None)
304  name: str | None = attr.ib(default=None)
305  primary_config_entry: str | None = attr.ib(default=None)
306  serial_number: str | None = attr.ib(default=None)
307  suggested_area: str | None = attr.ib(default=None)
308  sw_version: str | None = attr.ib(default=None)
309  via_device_id: str | None = attr.ib(default=None)
310  # This value is not stored, just used to keep track of events to fire.
311  is_new: bool = attr.ib(default=False)
312  _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
313 
314  @property
315  def disabled(self) -> bool:
316  """Return if entry is disabled."""
317  return self.disabled_by is not None
318 
319  @property
320  def dict_repr(self) -> dict[str, Any]:
321  """Return a dict representation of the entry."""
322  # Convert sets and tuples to lists
323  # so the JSON serializer does not have to do
324  # it every time
325  return {
326  "area_id": self.area_id,
327  "configuration_url": self.configuration_url,
328  "config_entries": list(self.config_entries),
329  "connections": list(self.connections),
330  "created_at": self.created_at.timestamp(),
331  "disabled_by": self.disabled_by,
332  "entry_type": self.entry_type,
333  "hw_version": self.hw_version,
334  "id": self.id,
335  "identifiers": list(self.identifiers),
336  "labels": list(self.labels),
337  "manufacturer": self.manufacturer,
338  "model": self.model,
339  "model_id": self.model_id,
340  "modified_at": self.modified_at.timestamp(),
341  "name_by_user": self.name_by_user,
342  "name": self.name,
343  "primary_config_entry": self.primary_config_entry,
344  "serial_number": self.serial_number,
345  "sw_version": self.sw_version,
346  "via_device_id": self.via_device_id,
347  }
348 
349  @under_cached_property
350  def json_repr(self) -> bytes | None:
351  """Return a cached JSON representation of the entry."""
352  try:
353  dict_repr = self.dict_reprdict_repr
354  return json_bytes(dict_repr)
355  except (ValueError, TypeError):
356  _LOGGER.error(
357  "Unable to serialize entry %s to JSON. Bad data found at %s",
358  self.id,
360  find_paths_unserializable_data(dict_repr, dump=JSON_DUMP)
361  ),
362  )
363  return None
364 
365  @under_cached_property
366  def as_storage_fragment(self) -> json_fragment:
367  """Return a json fragment for storage."""
368  return json_fragment(
369  json_bytes(
370  {
371  "area_id": self.area_id,
372  "config_entries": list(self.config_entries),
373  "configuration_url": self.configuration_url,
374  "connections": list(self.connections),
375  "created_at": self.created_at,
376  "disabled_by": self.disabled_by,
377  "entry_type": self.entry_type,
378  "hw_version": self.hw_version,
379  "id": self.id,
380  "identifiers": list(self.identifiers),
381  "labels": list(self.labels),
382  "manufacturer": self.manufacturer,
383  "model": self.model,
384  "model_id": self.model_id,
385  "modified_at": self.modified_at,
386  "name_by_user": self.name_by_user,
387  "name": self.name,
388  "primary_config_entry": self.primary_config_entry,
389  "serial_number": self.serial_number,
390  "sw_version": self.sw_version,
391  "via_device_id": self.via_device_id,
392  }
393  )
394  )
395 
396 
397 @attr.s(frozen=True, slots=True)
399  """Deleted Device Registry Entry."""
400 
401  config_entries: set[str] = attr.ib()
402  connections: set[tuple[str, str]] = attr.ib()
403  identifiers: set[tuple[str, str]] = attr.ib()
404  id: str = attr.ib()
405  orphaned_timestamp: float | None = attr.ib()
406  created_at: datetime = attr.ib(factory=utcnow)
407  modified_at: datetime = attr.ib(factory=utcnow)
408  _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
409 
411  self,
412  config_entry_id: str,
413  connections: set[tuple[str, str]],
414  identifiers: set[tuple[str, str]],
415  ) -> DeviceEntry:
416  """Create DeviceEntry from DeletedDeviceEntry."""
417  return DeviceEntry(
418  # type ignores: likely https://github.com/python/mypy/issues/8625
419  config_entries={config_entry_id}, # type: ignore[arg-type]
420  connections=self.connections & connections, # type: ignore[arg-type]
421  created_at=self.created_at,
422  identifiers=self.identifiers & identifiers, # type: ignore[arg-type]
423  id=self.id,
424  is_new=True,
425  )
426 
427  @under_cached_property
428  def as_storage_fragment(self) -> json_fragment:
429  """Return a json fragment for storage."""
430  return json_fragment(
431  json_bytes(
432  {
433  "config_entries": list(self.config_entries),
434  "connections": list(self.connections),
435  "created_at": self.created_at,
436  "identifiers": list(self.identifiers),
437  "id": self.id,
438  "orphaned_timestamp": self.orphaned_timestamp,
439  "modified_at": self.modified_at,
440  }
441  )
442  )
443 
444 
445 @lru_cache(maxsize=512)
446 def format_mac(mac: str) -> str:
447  """Format the mac address string for entry into dev reg."""
448  to_test = mac
449 
450  if len(to_test) == 17 and to_test.count(":") == 5:
451  return to_test.lower()
452 
453  if len(to_test) == 17 and to_test.count("-") == 5:
454  to_test = to_test.replace("-", "")
455  elif len(to_test) == 14 and to_test.count(".") == 2:
456  to_test = to_test.replace(".", "")
457 
458  if len(to_test) == 12:
459  # no : included
460  return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2))
461 
462  # Not sure how formatted, return original
463  return mac
464 
465 
466 class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
467  """Store entity registry data."""
468 
470  self,
471  old_major_version: int,
472  old_minor_version: int,
473  old_data: dict[str, list[dict[str, Any]]],
474  ) -> dict[str, Any]:
475  """Migrate to the new version."""
476  if old_major_version < 2:
477  if old_minor_version < 2:
478  # Version 1.2 implements migration and freezes the available keys,
479  # populate keys which were introduced before version 1.2
480  for device in old_data["devices"]:
481  device.setdefault("area_id", None)
482  device.setdefault("configuration_url", None)
483  device.setdefault("disabled_by", None)
484  try:
485  device["entry_type"] = DeviceEntryType(
486  device.get("entry_type"), # type: ignore[arg-type]
487  )
488  except ValueError:
489  device["entry_type"] = None
490  device.setdefault("name_by_user", None)
491  # via_device_id was originally introduced as hub_device_id
492  device.setdefault("via_device_id", device.get("hub_device_id"))
493  old_data.setdefault("deleted_devices", [])
494  for device in old_data["deleted_devices"]:
495  device.setdefault("orphaned_timestamp", None)
496  if old_minor_version < 3:
497  # Version 1.3 adds hw_version
498  for device in old_data["devices"]:
499  device["hw_version"] = None
500  if old_minor_version < 4:
501  # Introduced in 2023.11
502  for device in old_data["devices"]:
503  device["serial_number"] = None
504  if old_minor_version < 5:
505  # Introduced in 2024.3
506  for device in old_data["devices"]:
507  device["labels"] = []
508  if old_minor_version < 6:
509  # Introduced in 2024.7
510  for device in old_data["devices"]:
511  device["primary_config_entry"] = None
512  if old_minor_version < 7:
513  # Introduced in 2024.8
514  for device in old_data["devices"]:
515  device["model_id"] = None
516  if old_minor_version < 8:
517  # Introduced in 2024.8
518  created_at = utc_from_timestamp(0).isoformat()
519  for device in old_data["devices"]:
520  device["created_at"] = device["modified_at"] = created_at
521  for device in old_data["deleted_devices"]:
522  device["created_at"] = device["modified_at"] = created_at
523 
524  if old_major_version > 1:
525  raise NotImplementedError
526  return old_data
527 
528 
529 class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)](
530  BaseRegistryItems[_EntryTypeT]
531 ):
532  """Container for device registry items, maps device id -> entry.
533 
534  Maintains two additional indexes:
535  - (connection_type, connection identifier) -> entry
536  - (DOMAIN, identifier) -> entry
537  """
538 
539  def __init__(self) -> None:
540  """Initialize the container."""
541  super().__init__()
542  self._connections: dict[tuple[str, str], _EntryTypeT] = {}
543  self._identifiers: dict[tuple[str, str], _EntryTypeT] = {}
544 
545  def _index_entry(self, key: str, entry: _EntryTypeT) -> None:
546  """Index an entry."""
547  for connection in entry.connections:
548  self._connections[connection] = entry
549  for identifier in entry.identifiers:
550  self._identifiers[identifier] = entry
551 
553  self, key: str, replacement_entry: _EntryTypeT | None = None
554  ) -> None:
555  """Unindex an entry."""
556  old_entry = self.data[key]
557  for connection in old_entry.connections:
558  del self._connections[connection]
559  for identifier in old_entry.identifiers:
560  del self._identifiers[identifier]
561 
563  self,
564  identifiers: set[tuple[str, str]] | None,
565  connections: set[tuple[str, str]] | None,
566  ) -> _EntryTypeT | None:
567  """Get entry from identifiers or connections."""
568  if identifiers:
569  for identifier in identifiers:
570  if identifier in self._identifiers:
571  return self._identifiers[identifier]
572  if not connections:
573  return None
574  for connection in _normalize_connections(connections):
575  if connection in self._connections:
576  return self._connections[connection]
577  return None
578 
579 
581  """Container for active (non-deleted) device registry entries."""
582 
583  def __init__(self) -> None:
584  """Initialize the container.
585 
586  Maintains three additional indexes:
587 
588  - area_id -> dict[key, True]
589  - config_entry_id -> dict[key, True]
590  - label -> dict[key, True]
591  """
592  super().__init__()
593  self._area_id_index: RegistryIndexType = defaultdict(dict)
594  self._config_entry_id_index: RegistryIndexType = defaultdict(dict)
595  self._labels_index: RegistryIndexType = defaultdict(dict)
596 
597  def _index_entry(self, key: str, entry: DeviceEntry) -> None:
598  """Index an entry."""
599  super()._index_entry(key, entry)
600  if (area_id := entry.area_id) is not None:
601  self._area_id_index[area_id][key] = True
602  for label in entry.labels:
603  self._labels_index[label][key] = True
604  for config_entry_id in entry.config_entries:
605  self._config_entry_id_index[config_entry_id][key] = True
606 
608  self, key: str, replacement_entry: DeviceEntry | None = None
609  ) -> None:
610  """Unindex an entry."""
611  entry = self.data[key]
612  if area_id := entry.area_id:
613  self._unindex_entry_value(key, area_id, self._area_id_index)
614  if labels := entry.labels:
615  for label in labels:
616  self._unindex_entry_value(key, label, self._labels_index)
617  for config_entry_id in entry.config_entries:
618  self._unindex_entry_value(key, config_entry_id, self._config_entry_id_index)
619  super()._unindex_entry(key, replacement_entry)
620 
621  def get_devices_for_area_id(self, area_id: str) -> list[DeviceEntry]:
622  """Get devices for area."""
623  data = self.data
624  return [data[key] for key in self._area_id_index.get(area_id, ())]
625 
626  def get_devices_for_label(self, label: str) -> list[DeviceEntry]:
627  """Get devices for label."""
628  data = self.data
629  return [data[key] for key in self._labels_index.get(label, ())]
630 
632  self, config_entry_id: str
633  ) -> list[DeviceEntry]:
634  """Get devices for config entry."""
635  data = self.data
636  return [
637  data[key] for key in self._config_entry_id_index.get(config_entry_id, ())
638  ]
639 
640 
641 class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
642  """Class to hold a registry of devices."""
643 
644  devices: ActiveDeviceRegistryItems
645  deleted_devices: DeviceRegistryItems[DeletedDeviceEntry]
646  _device_data: dict[str, DeviceEntry]
647 
648  def __init__(self, hass: HomeAssistant) -> None:
649  """Initialize the device registry."""
650  self.hasshass = hass
652  hass,
653  STORAGE_VERSION_MAJOR,
654  STORAGE_KEY,
655  atomic_writes=True,
656  minor_version=STORAGE_VERSION_MINOR,
657  )
658 
659  @callback
660  def async_get(self, device_id: str) -> DeviceEntry | None:
661  """Get device.
662 
663  We retrieve the DeviceEntry from the underlying dict to avoid
664  the overhead of the UserDict __getitem__.
665  """
666  return self._device_data_device_data.get(device_id)
667 
668  @callback
670  self,
671  identifiers: set[tuple[str, str]] | None = None,
672  connections: set[tuple[str, str]] | None = None,
673  ) -> DeviceEntry | None:
674  """Check if device is registered."""
675  return self.devicesdevices.get_entry(identifiers, connections)
676 
678  self,
679  identifiers: set[tuple[str, str]],
680  connections: set[tuple[str, str]],
681  ) -> DeletedDeviceEntry | None:
682  """Check if device is deleted."""
683  return self.deleted_devicesdeleted_devices.get_entry(identifiers, connections)
684 
686  self,
687  domain: str,
688  name: str,
689  translation_placeholders: Mapping[str, str],
690  ) -> str:
691  """Substitute placeholders in entity name."""
692  try:
693  return name.format(**translation_placeholders)
694  except KeyError as err:
695  if get_release_channel() is not ReleaseChannel.STABLE:
696  raise HomeAssistantError(f"Missing placeholder {err}") from err
697  report_issue = async_suggest_report_issue(
698  self.hasshass, integration_domain=domain
699  )
700  _LOGGER.warning(
701  (
702  "Device from integration %s has translation placeholders '%s' "
703  "which do not match the name '%s', please %s"
704  ),
705  domain,
706  translation_placeholders,
707  name,
708  report_issue,
709  )
710  return name
711 
712  @callback
714  self,
715  *,
716  config_entry_id: str,
717  configuration_url: str | URL | None | UndefinedType = UNDEFINED,
718  connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED,
719  created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored
720  default_manufacturer: str | None | UndefinedType = UNDEFINED,
721  default_model: str | None | UndefinedType = UNDEFINED,
722  default_name: str | None | UndefinedType = UNDEFINED,
723  # To disable a device if it gets created
724  disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED,
725  entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED,
726  hw_version: str | None | UndefinedType = UNDEFINED,
727  identifiers: set[tuple[str, str]] | None | UndefinedType = UNDEFINED,
728  manufacturer: str | None | UndefinedType = UNDEFINED,
729  model: str | None | UndefinedType = UNDEFINED,
730  model_id: str | None | UndefinedType = UNDEFINED,
731  modified_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored
732  name: str | None | UndefinedType = UNDEFINED,
733  serial_number: str | None | UndefinedType = UNDEFINED,
734  suggested_area: str | None | UndefinedType = UNDEFINED,
735  sw_version: str | None | UndefinedType = UNDEFINED,
736  translation_key: str | None = None,
737  translation_placeholders: Mapping[str, str] | None = None,
738  via_device: tuple[str, str] | None | UndefinedType = UNDEFINED,
739  ) -> DeviceEntry:
740  """Get device. Create if it doesn't exist."""
741  if configuration_url is not UNDEFINED:
742  configuration_url = _validate_configuration_url(configuration_url)
743 
744  config_entry = self.hasshass.config_entries.async_get_entry(config_entry_id)
745  if config_entry is None:
746  raise HomeAssistantError(
747  f"Can't link device to unknown config entry {config_entry_id}"
748  )
749 
750  if translation_key:
751  full_translation_key = (
752  f"component.{config_entry.domain}.device.{translation_key}.name"
753  )
754  translations = translation.async_get_cached_translations(
755  self.hasshass, self.hasshass.config.language, "device", config_entry.domain
756  )
757  translated_name = translations.get(full_translation_key, translation_key)
758  name = self._substitute_name_placeholders_substitute_name_placeholders(
759  config_entry.domain, translated_name, translation_placeholders or {}
760  )
761 
762  # Reconstruct a DeviceInfo dict from the arguments.
763  # When we upgrade to Python 3.12, we can change this method to instead
764  # accept kwargs typed as a DeviceInfo dict (PEP 692)
765  device_info: DeviceInfo = { # type: ignore[assignment]
766  key: val
767  for key, val in (
768  ("configuration_url", configuration_url),
769  ("connections", connections),
770  ("default_manufacturer", default_manufacturer),
771  ("default_model", default_model),
772  ("default_name", default_name),
773  ("entry_type", entry_type),
774  ("hw_version", hw_version),
775  ("identifiers", identifiers),
776  ("manufacturer", manufacturer),
777  ("model", model),
778  ("model_id", model_id),
779  ("name", name),
780  ("serial_number", serial_number),
781  ("suggested_area", suggested_area),
782  ("sw_version", sw_version),
783  ("via_device", via_device),
784  )
785  if val is not UNDEFINED
786  }
787 
788  device_info_type = _validate_device_info(config_entry, device_info)
789 
790  if identifiers is None or identifiers is UNDEFINED:
791  identifiers = set()
792 
793  if connections is None or connections is UNDEFINED:
794  connections = set()
795  else:
796  connections = _normalize_connections(connections)
797 
798  device = self.async_get_deviceasync_get_device(identifiers=identifiers, connections=connections)
799 
800  if device is None:
801  deleted_device = self._async_get_deleted_device_async_get_deleted_device(identifiers, connections)
802  if deleted_device is None:
803  device = DeviceEntry(is_new=True)
804  else:
805  self.deleted_devicesdeleted_devices.pop(deleted_device.id)
806  device = deleted_device.to_device_entry(
807  config_entry_id, connections, identifiers
808  )
809  self.devicesdevices[device.id] = device
810  # If creating a new device, default to the config entry name
811  if device_info_type == "primary" and (not name or name is UNDEFINED):
812  name = config_entry.title
813 
814  if default_manufacturer is not UNDEFINED and device.manufacturer is None:
815  manufacturer = default_manufacturer
816 
817  if default_model is not UNDEFINED and device.model is None:
818  model = default_model
819 
820  if default_name is not UNDEFINED and device.name is None:
821  name = default_name
822 
823  if via_device is not None and via_device is not UNDEFINED:
824  via = self.async_get_deviceasync_get_device(identifiers={via_device})
825  via_device_id: str | UndefinedType = via.id if via else UNDEFINED
826  else:
827  via_device_id = UNDEFINED
828 
829  device = self.async_update_deviceasync_update_device(
830  device.id,
831  allow_collisions=True,
832  add_config_entry_id=config_entry_id,
833  configuration_url=configuration_url,
834  device_info_type=device_info_type,
835  disabled_by=disabled_by,
836  entry_type=entry_type,
837  hw_version=hw_version,
838  manufacturer=manufacturer,
839  merge_connections=connections or UNDEFINED,
840  merge_identifiers=identifiers or UNDEFINED,
841  model=model,
842  model_id=model_id,
843  name=name,
844  serial_number=serial_number,
845  suggested_area=suggested_area,
846  sw_version=sw_version,
847  via_device_id=via_device_id,
848  )
849 
850  # This is safe because _async_update_device will always return a device
851  # in this use case.
852  assert device
853  return device
854 
855  @callback
856  def async_update_device( # noqa: C901
857  self,
858  device_id: str,
859  *,
860  add_config_entry_id: str | UndefinedType = UNDEFINED,
861  # Temporary flag so we don't blow up when collisions are implicitly introduced
862  # by calls to async_get_or_create. Must not be set by integrations.
863  allow_collisions: bool = False,
864  area_id: str | None | UndefinedType = UNDEFINED,
865  configuration_url: str | URL | None | UndefinedType = UNDEFINED,
866  device_info_type: str | UndefinedType = UNDEFINED,
867  disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED,
868  entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED,
869  hw_version: str | None | UndefinedType = UNDEFINED,
870  labels: set[str] | UndefinedType = UNDEFINED,
871  manufacturer: str | None | UndefinedType = UNDEFINED,
872  merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
873  merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
874  model: str | None | UndefinedType = UNDEFINED,
875  model_id: str | None | UndefinedType = UNDEFINED,
876  name_by_user: str | None | UndefinedType = UNDEFINED,
877  name: str | None | UndefinedType = UNDEFINED,
878  new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
879  new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
880  remove_config_entry_id: str | UndefinedType = UNDEFINED,
881  serial_number: str | None | UndefinedType = UNDEFINED,
882  suggested_area: str | None | UndefinedType = UNDEFINED,
883  sw_version: str | None | UndefinedType = UNDEFINED,
884  via_device_id: str | None | UndefinedType = UNDEFINED,
885  ) -> DeviceEntry | None:
886  """Update device attributes."""
887  old = self.devicesdevices[device_id]
888 
889  new_values: dict[str, Any] = {} # Dict with new key/value pairs
890  old_values: dict[str, Any] = {} # Dict with old key/value pairs
891 
892  config_entries = old.config_entries
893 
894  if add_config_entry_id is not UNDEFINED:
895  if self.hasshass.config_entries.async_get_entry(add_config_entry_id) is None:
896  raise HomeAssistantError(
897  f"Can't link device to unknown config entry {add_config_entry_id}"
898  )
899 
900  if not new_connections and not new_identifiers:
901  raise HomeAssistantError(
902  "A device must have at least one of identifiers or connections"
903  )
904 
905  if merge_connections is not UNDEFINED and new_connections is not UNDEFINED:
906  raise HomeAssistantError(
907  "Cannot define both merge_connections and new_connections"
908  )
909 
910  if merge_identifiers is not UNDEFINED and new_identifiers is not UNDEFINED:
911  raise HomeAssistantError(
912  "Cannot define both merge_identifiers and new_identifiers"
913  )
914 
915  if (
916  suggested_area is not None
917  and suggested_area is not UNDEFINED
918  and suggested_area != ""
919  and area_id is UNDEFINED
920  and old.area_id is None
921  ):
922  # Circular dep
923  # pylint: disable-next=import-outside-toplevel
924  from . import area_registry as ar
925 
926  area = ar.async_get(self.hasshass).async_get_or_create(suggested_area)
927  area_id = area.id
928 
929  if add_config_entry_id is not UNDEFINED:
930  primary_entry_id = old.primary_config_entry
931  if (
932  device_info_type == "primary"
933  and add_config_entry_id != primary_entry_id
934  ):
935  if (
936  primary_entry_id is None
937  or not (
938  primary_entry := self.hasshass.config_entries.async_get_entry(
939  primary_entry_id
940  )
941  )
942  or primary_entry.domain in LOW_PRIO_CONFIG_ENTRY_DOMAINS
943  ):
944  new_values["primary_config_entry"] = add_config_entry_id
945  old_values["primary_config_entry"] = primary_entry_id
946 
947  if add_config_entry_id not in old.config_entries:
948  config_entries = old.config_entries | {add_config_entry_id}
949 
950  if (
951  remove_config_entry_id is not UNDEFINED
952  and remove_config_entry_id in config_entries
953  ):
954  if config_entries == {remove_config_entry_id}:
955  self.async_remove_deviceasync_remove_device(device_id)
956  return None
957 
958  if remove_config_entry_id == old.primary_config_entry:
959  new_values["primary_config_entry"] = None
960  old_values["primary_config_entry"] = old.primary_config_entry
961 
962  config_entries = config_entries - {remove_config_entry_id}
963 
964  if config_entries != old.config_entries:
965  new_values["config_entries"] = config_entries
966  old_values["config_entries"] = old.config_entries
967 
968  for attr_name, setvalue in (
969  ("connections", merge_connections),
970  ("identifiers", merge_identifiers),
971  ):
972  old_value = getattr(old, attr_name)
973  # If not undefined, check if `value` contains new items.
974  if setvalue is not UNDEFINED and not setvalue.issubset(old_value):
975  new_values[attr_name] = old_value | setvalue
976  old_values[attr_name] = old_value
977 
978  if merge_connections is not UNDEFINED:
979  normalized_connections = self._validate_connections_validate_connections(
980  device_id,
981  merge_connections,
982  allow_collisions,
983  )
984  old_connections = old.connections
985  if not normalized_connections.issubset(old_connections):
986  new_values["connections"] = old_connections | normalized_connections
987  old_values["connections"] = old_connections
988 
989  if merge_identifiers is not UNDEFINED:
990  merge_identifiers = self._validate_identifiers_validate_identifiers(
991  device_id, merge_identifiers, allow_collisions
992  )
993  old_identifiers = old.identifiers
994  if not merge_identifiers.issubset(old_identifiers):
995  new_values["identifiers"] = old_identifiers | merge_identifiers
996  old_values["identifiers"] = old_identifiers
997 
998  if new_connections is not UNDEFINED:
999  new_values["connections"] = self._validate_connections_validate_connections(
1000  device_id, new_connections, False
1001  )
1002  old_values["connections"] = old.connections
1003 
1004  if new_identifiers is not UNDEFINED:
1005  new_values["identifiers"] = self._validate_identifiers_validate_identifiers(
1006  device_id, new_identifiers, False
1007  )
1008  old_values["identifiers"] = old.identifiers
1009 
1010  if configuration_url is not UNDEFINED:
1011  configuration_url = _validate_configuration_url(configuration_url)
1012 
1013  for attr_name, value in (
1014  ("area_id", area_id),
1015  ("configuration_url", configuration_url),
1016  ("disabled_by", disabled_by),
1017  ("entry_type", entry_type),
1018  ("hw_version", hw_version),
1019  ("labels", labels),
1020  ("manufacturer", manufacturer),
1021  ("model", model),
1022  ("model_id", model_id),
1023  ("name", name),
1024  ("name_by_user", name_by_user),
1025  ("serial_number", serial_number),
1026  ("suggested_area", suggested_area),
1027  ("sw_version", sw_version),
1028  ("via_device_id", via_device_id),
1029  ):
1030  if value is not UNDEFINED and value != getattr(old, attr_name):
1031  new_values[attr_name] = value
1032  old_values[attr_name] = getattr(old, attr_name)
1033 
1034  if old.is_new:
1035  new_values["is_new"] = False
1036 
1037  if not new_values:
1038  return old
1039 
1040  if not RUNTIME_ONLY_ATTRS.issuperset(new_values):
1041  # Change modified_at if we are changing something that we store
1042  new_values["modified_at"] = utcnow()
1043 
1044  self.hasshass.verify_event_loop_thread("device_registry.async_update_device")
1045  new = attr.evolve(old, **new_values)
1046  self.devicesdevices[device_id] = new
1047 
1048  # If its only run time attributes (suggested_area)
1049  # that do not get saved we do not want to write
1050  # to disk or fire an event as we would end up
1051  # firing events for data we have nothing to compare
1052  # against since its never saved on disk
1053  if RUNTIME_ONLY_ATTRS.issuperset(new_values):
1054  return new
1055 
1056  self.async_schedule_save()
1057 
1058  data: EventDeviceRegistryUpdatedData
1059  if old.is_new:
1060  data = {"action": "create", "device_id": new.id}
1061  else:
1062  data = {"action": "update", "device_id": new.id, "changes": old_values}
1063 
1064  self.hasshass.bus.async_fire_internal(EVENT_DEVICE_REGISTRY_UPDATED, data)
1065 
1066  return new
1067 
1068  @callback
1070  self,
1071  device_id: str,
1072  connections: set[tuple[str, str]],
1073  allow_collisions: bool,
1074  ) -> set[tuple[str, str]]:
1075  """Normalize and validate connections, raise on collision with other devices."""
1076  normalized_connections = _normalize_connections(connections)
1077  if allow_collisions:
1078  return normalized_connections
1079 
1080  for connection in normalized_connections:
1081  # We need to iterate over each connection because if there is a
1082  # conflict, the index will only see the last one and we will not
1083  # be able to tell which one caused the conflict
1084  if (
1085  existing_device := self.async_get_deviceasync_get_device(connections={connection})
1086  ) and existing_device.id != device_id:
1088  normalized_connections, existing_device
1089  )
1090 
1091  return normalized_connections
1092 
1093  @callback
1095  self,
1096  device_id: str,
1097  identifiers: set[tuple[str, str]],
1098  allow_collisions: bool,
1099  ) -> set[tuple[str, str]]:
1100  """Validate identifiers, raise on collision with other devices."""
1101  if allow_collisions:
1102  return identifiers
1103 
1104  for identifier in identifiers:
1105  # We need to iterate over each identifier because if there is a
1106  # conflict, the index will only see the last one and we will not
1107  # be able to tell which one caused the conflict
1108  if (
1109  existing_device := self.async_get_deviceasync_get_device(identifiers={identifier})
1110  ) and existing_device.id != device_id:
1111  raise DeviceIdentifierCollisionError(identifiers, existing_device)
1112 
1113  return identifiers
1114 
1115  @callback
1116  def async_remove_device(self, device_id: str) -> None:
1117  """Remove a device from the device registry."""
1118  self.hasshass.verify_event_loop_thread("device_registry.async_remove_device")
1119  device = self.devicesdevices.pop(device_id)
1120  self.deleted_devicesdeleted_devices[device_id] = DeletedDeviceEntry(
1121  config_entries=device.config_entries,
1122  connections=device.connections,
1123  created_at=device.created_at,
1124  identifiers=device.identifiers,
1125  id=device.id,
1126  orphaned_timestamp=None,
1127  )
1128  for other_device in list(self.devicesdevices.values()):
1129  if other_device.via_device_id == device_id:
1130  self.async_update_deviceasync_update_device(other_device.id, via_device_id=None)
1131  self.hasshass.bus.async_fire_internal(
1132  EVENT_DEVICE_REGISTRY_UPDATED,
1134  action="remove", device_id=device_id
1135  ),
1136  )
1137  self.async_schedule_save()
1138 
1139  async def async_load(self) -> None:
1140  """Load the device registry."""
1141  async_setup_cleanup(self.hasshass, self)
1142 
1143  data = await self._store_store.async_load()
1144 
1145  devices = ActiveDeviceRegistryItems()
1146  deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] = DeviceRegistryItems()
1147 
1148  if data is not None:
1149  for device in data["devices"]:
1150  devices[device["id"]] = DeviceEntry(
1151  area_id=device["area_id"],
1152  config_entries=set(device["config_entries"]),
1153  configuration_url=device["configuration_url"],
1154  # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625
1155  connections={
1156  tuple(conn) # type: ignore[misc]
1157  for conn in device["connections"]
1158  },
1159  created_at=datetime.fromisoformat(device["created_at"]),
1160  disabled_by=(
1161  DeviceEntryDisabler(device["disabled_by"])
1162  if device["disabled_by"]
1163  else None
1164  ),
1165  entry_type=(
1166  DeviceEntryType(device["entry_type"])
1167  if device["entry_type"]
1168  else None
1169  ),
1170  hw_version=device["hw_version"],
1171  id=device["id"],
1172  identifiers={
1173  tuple(iden) # type: ignore[misc]
1174  for iden in device["identifiers"]
1175  },
1176  labels=set(device["labels"]),
1177  manufacturer=device["manufacturer"],
1178  model=device["model"],
1179  model_id=device["model_id"],
1180  modified_at=datetime.fromisoformat(device["modified_at"]),
1181  name_by_user=device["name_by_user"],
1182  name=device["name"],
1183  primary_config_entry=device["primary_config_entry"],
1184  serial_number=device["serial_number"],
1185  sw_version=device["sw_version"],
1186  via_device_id=device["via_device_id"],
1187  )
1188  # Introduced in 0.111
1189  for device in data["deleted_devices"]:
1190  deleted_devices[device["id"]] = DeletedDeviceEntry(
1191  config_entries=set(device["config_entries"]),
1192  connections={tuple(conn) for conn in device["connections"]},
1193  created_at=datetime.fromisoformat(device["created_at"]),
1194  identifiers={tuple(iden) for iden in device["identifiers"]},
1195  id=device["id"],
1196  modified_at=datetime.fromisoformat(device["modified_at"]),
1197  orphaned_timestamp=device["orphaned_timestamp"],
1198  )
1199 
1200  self.devicesdevices = devices
1201  self.deleted_devicesdeleted_devices = deleted_devices
1202  self._device_data_device_data = devices.data
1203 
1204  @callback
1205  def _data_to_save(self) -> dict[str, Any]:
1206  """Return data of device registry to store in a file."""
1207  return {
1208  "devices": [entry.as_storage_fragment for entry in self.devicesdevices.values()],
1209  "deleted_devices": [
1210  entry.as_storage_fragment for entry in self.deleted_devicesdeleted_devices.values()
1211  ],
1212  }
1213 
1214  @callback
1215  def async_clear_config_entry(self, config_entry_id: str) -> None:
1216  """Clear config entry from registry entries."""
1217  now_time = time.time()
1218  for device in self.devicesdevices.get_devices_for_config_entry_id(config_entry_id):
1219  self.async_update_deviceasync_update_device(device.id, remove_config_entry_id=config_entry_id)
1220  for deleted_device in list(self.deleted_devicesdeleted_devices.values()):
1221  config_entries = deleted_device.config_entries
1222  if config_entry_id not in config_entries:
1223  continue
1224  if config_entries == {config_entry_id}:
1225  # Add a time stamp when the deleted device became orphaned
1226  self.deleted_devicesdeleted_devices[deleted_device.id] = attr.evolve(
1227  deleted_device, orphaned_timestamp=now_time, config_entries=set()
1228  )
1229  else:
1230  config_entries = config_entries - {config_entry_id}
1231  # No need to reindex here since we currently
1232  # do not have a lookup by config entry
1233  self.deleted_devicesdeleted_devices[deleted_device.id] = attr.evolve(
1234  deleted_device, config_entries=config_entries
1235  )
1236  self.async_schedule_save()
1237 
1238  @callback
1240  """Purge expired orphaned devices from the registry.
1241 
1242  We need to purge these periodically to avoid the database
1243  growing without bound.
1244  """
1245  now_time = time.time()
1246  for deleted_device in list(self.deleted_devicesdeleted_devices.values()):
1247  if deleted_device.orphaned_timestamp is None:
1248  continue
1249 
1250  if (
1251  deleted_device.orphaned_timestamp + ORPHANED_DEVICE_KEEP_SECONDS
1252  < now_time
1253  ):
1254  del self.deleted_devicesdeleted_devices[deleted_device.id]
1255 
1256  @callback
1257  def async_clear_area_id(self, area_id: str) -> None:
1258  """Clear area id from registry entries."""
1259  for device in self.devicesdevices.get_devices_for_area_id(area_id):
1260  self.async_update_deviceasync_update_device(device.id, area_id=None)
1261 
1262  @callback
1263  def async_clear_label_id(self, label_id: str) -> None:
1264  """Clear label from registry entries."""
1265  for device in self.devicesdevices.get_devices_for_label(label_id):
1266  self.async_update_deviceasync_update_device(device.id, labels=device.labels - {label_id})
1267 
1268 
1269 @callback
1270 @singleton(DATA_REGISTRY)
1271 def async_get(hass: HomeAssistant) -> DeviceRegistry:
1272  """Get device registry."""
1273  return DeviceRegistry(hass)
1274 
1275 
1276 async def async_load(hass: HomeAssistant) -> None:
1277  """Load device registry."""
1278  assert DATA_REGISTRY not in hass.data
1279  await async_get(hass).async_load()
1280 
1281 
1282 @callback
1283 def async_entries_for_area(registry: DeviceRegistry, area_id: str) -> list[DeviceEntry]:
1284  """Return entries that match an area."""
1285  return registry.devices.get_devices_for_area_id(area_id)
1286 
1287 
1288 @callback
1290  registry: DeviceRegistry, label_id: str
1291 ) -> list[DeviceEntry]:
1292  """Return entries that match a label."""
1293  return registry.devices.get_devices_for_label(label_id)
1294 
1295 
1296 @callback
1298  registry: DeviceRegistry, config_entry_id: str
1299 ) -> list[DeviceEntry]:
1300  """Return entries that match a config entry."""
1301  return registry.devices.get_devices_for_config_entry_id(config_entry_id)
1302 
1303 
1304 @callback
1306  registry: DeviceRegistry, config_entry: ConfigEntry
1307 ) -> None:
1308  """Handle a config entry being disabled or enabled.
1309 
1310  Disable devices in the registry that are associated with a config entry when
1311  the config entry is disabled, enable devices in the registry that are associated
1312  with a config entry when the config entry is enabled and the devices are marked
1313  DeviceEntryDisabler.CONFIG_ENTRY.
1314  Only disable a device if all associated config entries are disabled.
1315  """
1316 
1317  devices = async_entries_for_config_entry(registry, config_entry.entry_id)
1318 
1319  if not config_entry.disabled_by:
1320  for device in devices:
1321  if device.disabled_by is not DeviceEntryDisabler.CONFIG_ENTRY:
1322  continue
1323  registry.async_update_device(device.id, disabled_by=None)
1324  return
1325 
1326  enabled_config_entries = {
1327  entry.entry_id
1328  for entry in registry.hass.config_entries.async_entries()
1329  if not entry.disabled_by
1330  }
1331 
1332  for device in devices:
1333  if device.disabled:
1334  # Device already disabled, do not overwrite
1335  continue
1336  if len(device.config_entries) > 1 and device.config_entries.intersection(
1337  enabled_config_entries
1338  ):
1339  continue
1340  registry.async_update_device(
1341  device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY
1342  )
1343 
1344 
1345 @callback
1347  hass: HomeAssistant,
1348  dev_reg: DeviceRegistry,
1350 ) -> None:
1351  """Clean up device registry."""
1352  # Find all devices that are referenced by a config_entry.
1353  config_entry_ids = set(hass.config_entries.async_entry_ids())
1354  references_config_entries = {
1355  device.id
1356  for device in dev_reg.devices.values()
1357  for config_entry_id in device.config_entries
1358  if config_entry_id in config_entry_ids
1359  }
1360 
1361  # Find all devices that are referenced in the entity registry.
1362  device_ids_referenced_by_entities = set(ent_reg.entities.get_device_ids())
1363 
1364  orphan = (
1365  set(dev_reg.devices)
1366  - device_ids_referenced_by_entities
1367  - references_config_entries
1368  )
1369 
1370  for dev_id in orphan:
1371  dev_reg.async_remove_device(dev_id)
1372 
1373  # Find all referenced config entries that no longer exist
1374  # This shouldn't happen but have not been able to track down the bug :(
1375  for device in list(dev_reg.devices.values()):
1376  for config_entry_id in device.config_entries:
1377  if config_entry_id not in config_entry_ids:
1378  dev_reg.async_update_device(
1379  device.id, remove_config_entry_id=config_entry_id
1380  )
1381 
1382  # Periodic purge of orphaned devices to avoid the registry
1383  # growing without bounds when there are lots of deleted devices
1384  dev_reg.async_purge_expired_orphaned_devices()
1385 
1386 
1387 @callback
1388 def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None:
1389  """Clean up device registry when entities removed."""
1390  # pylint: disable-next=import-outside-toplevel
1391  from . import entity_registry, label_registry as lr
1392 
1393  @callback
1394  def _label_removed_from_registry_filter(
1395  event_data: lr.EventLabelRegistryUpdatedData,
1396  ) -> bool:
1397  """Filter all except for the remove action from label registry events."""
1398  return event_data["action"] == "remove"
1399 
1400  @callback
1401  def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None:
1402  """Update devices that have a label that has been removed."""
1403  dev_reg.async_clear_label_id(event.data["label_id"])
1404 
1405  hass.bus.async_listen(
1406  event_type=lr.EVENT_LABEL_REGISTRY_UPDATED,
1407  event_filter=_label_removed_from_registry_filter,
1408  listener=_handle_label_registry_update,
1409  )
1410 
1411  @callback
1412  def _async_cleanup() -> None:
1413  """Cleanup."""
1414  ent_reg = entity_registry.async_get(hass)
1415  async_cleanup(hass, dev_reg, ent_reg)
1416 
1417  debounced_cleanup: Debouncer[None] = Debouncer(
1418  hass, _LOGGER, cooldown=CLEANUP_DELAY, immediate=False, function=_async_cleanup
1419  )
1420 
1421  @callback
1422  def _async_entity_registry_changed(
1423  event: Event[entity_registry.EventEntityRegistryUpdatedData],
1424  ) -> None:
1425  """Handle entity updated or removed dispatch."""
1426  debounced_cleanup.async_schedule_call()
1427 
1428  @callback
1429  def entity_registry_changed_filter(
1430  event_data: entity_registry.EventEntityRegistryUpdatedData,
1431  ) -> bool:
1432  """Handle entity updated or removed filter."""
1433  if (
1434  event_data["action"] == "update"
1435  and "device_id" not in event_data["changes"]
1436  ) or event_data["action"] == "create":
1437  return False
1438 
1439  return True
1440 
1441  def _async_listen_for_cleanup() -> None:
1442  """Listen for entity registry changes."""
1443  hass.bus.async_listen(
1444  entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
1445  _async_entity_registry_changed,
1446  event_filter=entity_registry_changed_filter,
1447  )
1448 
1449  if hass.is_running:
1450  _async_listen_for_cleanup()
1451  return
1452 
1453  async def startup_clean(event: Event) -> None:
1454  """Clean up on startup."""
1455  _async_listen_for_cleanup()
1456  await debounced_cleanup.async_call()
1457 
1458  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean)
1459 
1460  @callback
1461  def _on_homeassistant_stop(event: Event) -> None:
1462  """Cancel debounced cleanup."""
1463  debounced_cleanup.async_cancel()
1464 
1465  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop)
1466 
1467 
1468 def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]:
1469  """Normalize connections to ensure we can match mac addresses."""
1470  return {
1471  (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
1472  for key, value in connections
1473  }
1474 
1475 
1476 # These can be removed if no deprecated constant are in this module anymore
1477 __getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
1478 __dir__ = partial(
1479  dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
1480 )
1481 __all__ = all_with_deprecated_constants(globals())
None _unindex_entry(self, str key, DeviceEntry|None replacement_entry=None)
list[DeviceEntry] get_devices_for_config_entry_id(self, str config_entry_id)
DeviceEntry to_device_entry(self, str config_entry_id, set[tuple[str, str]] connections, set[tuple[str, str]] identifiers)
None __init__(self, set[tuple[str, str]] normalized_connections, DeviceEntry existing_device)
None __init__(self, set[tuple[str, str]] identifiers, DeviceEntry existing_device)
None __init__(self, str domain, DeviceInfo device_info, str message)
dict[str, Any] _async_migrate_func(self, int old_major_version, int old_minor_version, dict[str, list[dict[str, Any]]] old_data)
DeviceEntry|None async_get_device(self, set[tuple[str, str]]|None identifiers=None, set[tuple[str, str]]|None connections=None)
DeviceEntry|None async_get(self, str device_id)
DeviceEntry async_get_or_create(self, *str config_entry_id, str|URL|None|UndefinedType configuration_url=UNDEFINED, set[tuple[str, str]]|None|UndefinedType connections=UNDEFINED, str|datetime|UndefinedType created_at=UNDEFINED, str|None|UndefinedType default_manufacturer=UNDEFINED, str|None|UndefinedType default_model=UNDEFINED, str|None|UndefinedType default_name=UNDEFINED, DeviceEntryDisabler|None|UndefinedType disabled_by=UNDEFINED, DeviceEntryType|None|UndefinedType entry_type=UNDEFINED, str|None|UndefinedType hw_version=UNDEFINED, set[tuple[str, str]]|None|UndefinedType identifiers=UNDEFINED, str|None|UndefinedType manufacturer=UNDEFINED, str|None|UndefinedType model=UNDEFINED, str|None|UndefinedType model_id=UNDEFINED, str|datetime|UndefinedType modified_at=UNDEFINED, str|None|UndefinedType name=UNDEFINED, str|None|UndefinedType serial_number=UNDEFINED, str|None|UndefinedType suggested_area=UNDEFINED, str|None|UndefinedType sw_version=UNDEFINED, str|None translation_key=None, Mapping[str, str]|None translation_placeholders=None, tuple[str, str]|None|UndefinedType via_device=UNDEFINED)
DeletedDeviceEntry|None _async_get_deleted_device(self, set[tuple[str, str]] identifiers, set[tuple[str, str]] connections)
set[tuple[str, str]] _validate_identifiers(self, str device_id, set[tuple[str, str]] identifiers, bool allow_collisions)
set[tuple[str, str]] _validate_connections(self, str device_id, set[tuple[str, str]] connections, bool allow_collisions)
str _substitute_name_placeholders(self, str domain, str name, Mapping[str, str] translation_placeholders)
DeviceEntry|None async_update_device(self, str device_id, *str|UndefinedType add_config_entry_id=UNDEFINED, bool allow_collisions=False, str|None|UndefinedType area_id=UNDEFINED, str|URL|None|UndefinedType configuration_url=UNDEFINED, str|UndefinedType device_info_type=UNDEFINED, DeviceEntryDisabler|None|UndefinedType disabled_by=UNDEFINED, DeviceEntryType|None|UndefinedType entry_type=UNDEFINED, str|None|UndefinedType hw_version=UNDEFINED, set[str]|UndefinedType labels=UNDEFINED, str|None|UndefinedType manufacturer=UNDEFINED, set[tuple[str, str]]|UndefinedType merge_connections=UNDEFINED, set[tuple[str, str]]|UndefinedType merge_identifiers=UNDEFINED, str|None|UndefinedType model=UNDEFINED, str|None|UndefinedType model_id=UNDEFINED, str|None|UndefinedType name_by_user=UNDEFINED, str|None|UndefinedType name=UNDEFINED, set[tuple[str, str]]|UndefinedType new_connections=UNDEFINED, set[tuple[str, str]]|UndefinedType new_identifiers=UNDEFINED, str|UndefinedType remove_config_entry_id=UNDEFINED, str|None|UndefinedType serial_number=UNDEFINED, str|None|UndefinedType suggested_area=UNDEFINED, str|None|UndefinedType sw_version=UNDEFINED, str|None|UndefinedType via_device_id=UNDEFINED)
None async_clear_config_entry(self, str config_entry_id)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
ReleaseChannel get_release_channel()
Definition: core.py:315
list[str] all_with_deprecated_constants(dict[str, Any] module_globals)
Definition: deprecation.py:356
None _index_entry(self, str key, _EntryTypeT entry)
list[DeviceEntry] async_entries_for_area(DeviceRegistry registry, str area_id)
None async_config_entry_disabled_by_changed(DeviceRegistry registry, ConfigEntry config_entry)
DeviceRegistry async_get(HomeAssistant hass)
list[DeviceEntry] async_entries_for_label(DeviceRegistry registry, str label_id)
list[DeviceEntry] async_entries_for_config_entry(DeviceRegistry registry, str config_entry_id)
None async_cleanup(HomeAssistant hass, DeviceRegistry dev_reg, entity_registry.EntityRegistry ent_reg)
set[tuple[str, str]] _normalize_connections(set[tuple[str, str]] connections)
str|None _validate_configuration_url(Any value)
_EntryTypeT|None get_entry(self, set[tuple[str, str]]|None identifiers, set[tuple[str, str]]|None connections)
None _unindex_entry(self, str key, _EntryTypeT|None replacement_entry=None)
str _validate_device_info(ConfigEntry config_entry, DeviceInfo device_info)
None async_setup_cleanup(HomeAssistant hass, DeviceRegistry dev_reg)
dict[str, Any] find_paths_unserializable_data(Any bad_data, *Callable[[Any], str] dump=json.dumps)
Definition: json.py:233
str async_suggest_report_issue(HomeAssistant|None hass, *Integration|None integration=None, str|None integration_domain=None, str|None module=None)
Definition: loader.py:1752
str format_unserializable_data(dict[str, Any] data)
Definition: json.py:126