1 """Provide a way to connect entities belonging to one device."""
3 from __future__
import annotations
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
12 from typing
import TYPE_CHECKING, Any, Literal, TypedDict
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,
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
48 from propcache
import cached_property
as under_cached_property
52 from .
import entity_registry
54 from propcache
import under_cached_property
56 _LOGGER = logging.getLogger(__name__)
58 DATA_REGISTRY: HassKey[DeviceRegistry] =
HassKey(
"device_registry")
59 EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] =
EventType(
60 "device_registry_updated"
62 STORAGE_KEY =
"core.device_registry"
63 STORAGE_VERSION_MAJOR = 1
64 STORAGE_VERSION_MINOR = 8
68 CONNECTION_BLUETOOTH =
"bluetooth"
69 CONNECTION_NETWORK_MAC =
"mac"
70 CONNECTION_UPNP =
"upnp"
71 CONNECTION_ZIGBEE =
"zigbee"
73 ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30
75 RUNTIME_ONLY_ATTRS = {
"suggested_area"}
77 CONFIGURATION_URL_SCHEMES = {
"http",
"https",
"homeassistant"}
81 """What disabled a device entry."""
83 CONFIG_ENTRY =
"config_entry"
84 INTEGRATION =
"integration"
90 DeviceEntryDisabler.CONFIG_ENTRY,
"2025.1"
93 DeviceEntryDisabler.INTEGRATION,
"2025.1"
99 """Entity device information for device registry."""
101 configuration_url: str | URL |
None
102 connections: set[tuple[str, str]]
104 default_manufacturer: str
107 entry_type: DeviceEntryType |
None
108 identifiers: set[tuple[str, str]]
109 manufacturer: 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]
123 DEVICE_INFO_TYPES = {
148 "default_manufacturer",
156 DEVICE_INFO_KEYS = set.union(*(itm
for itm
in DEVICE_INFO_TYPES.values()))
159 LOW_PRIO_CONFIG_ENTRY_DOMAINS = {
"homekit_controller",
"matter",
"mqtt",
"upnp"}
163 """EventDeviceRegistryUpdated data for action type 'create' and 'remove'."""
165 action: Literal[
"create",
"remove"]
170 """EventDeviceRegistryUpdated data for action type 'update'."""
172 action: Literal[
"update"]
174 changes: dict[str, Any]
177 type EventDeviceRegistryUpdatedData = (
178 _EventDeviceRegistryUpdatedData_CreateRemove
179 | _EventDeviceRegistryUpdatedData_Update
184 """Device entry type."""
190 """Raised when device info is invalid."""
192 def __init__(self, domain: str, device_info: DeviceInfo, message: str) ->
None:
193 """Initialize error."""
195 f
"Invalid device info {device_info} for '{domain}' config entry: {message}",
202 """Raised when a device collision is detected."""
205 class DeviceIdentifierCollisionError(DeviceCollisionError):
206 """Raised when a device identifier collision is detected."""
209 self, identifiers: set[tuple[str, str]], existing_device: DeviceEntry
211 """Initialize error."""
213 f
"Identifiers {identifiers} already registered with {existing_device}"
218 """Raised when a device connection collision is detected."""
221 self, normalized_connections: set[tuple[str, str]], existing_device: DeviceEntry
223 """Initialize error."""
225 f
"Connections {normalized_connections} "
226 f
"already registered with {existing_device}"
231 config_entry: ConfigEntry,
232 device_info: DeviceInfo,
234 """Process a device info."""
235 keys = set(device_info)
238 if not device_info.get(
"connections")
and not device_info.get(
"identifiers"):
242 "device info must include at least one of identifiers or connections",
245 device_info_type: str |
None =
None
248 for possible_type, allowed_keys
in DEVICE_INFO_TYPES.items():
249 if keys <= allowed_keys:
250 device_info_type = possible_type
253 if device_info_type
is None:
258 "device info needs to either describe a device, "
259 "link to existing device or provide extra information."
263 return device_info_type
266 _cached_parse_url = lru_cache(maxsize=512)(URL)
267 """Parse a URL and cache the result."""
271 """Validate and convert configuration_url."""
275 url_as_str =
str(value)
278 if url.scheme
not in CONFIGURATION_URL_SCHEMES
or not url.host:
279 raise ValueError(f
"invalid configuration_url '{value}'")
284 @attr.s(frozen=True, slots=True)
286 """Device Registry Entry."""
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)
311 is_new: bool = attr.ib(default=
False)
312 _cache: dict[str, Any] = attr.ib(factory=dict, eq=
False, init=
False)
316 """Return if entry is disabled."""
317 return self.disabled_by
is not None
321 """Return a dict representation of the entry."""
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,
335 "identifiers":
list(self.identifiers),
336 "labels":
list(self.labels),
337 "manufacturer": self.manufacturer,
339 "model_id": self.model_id,
340 "modified_at": self.modified_at.timestamp(),
341 "name_by_user": self.name_by_user,
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,
349 @under_cached_property
351 """Return a cached JSON representation of the entry."""
355 except (ValueError, TypeError):
357 "Unable to serialize entry %s to JSON. Bad data found at %s",
365 @under_cached_property
367 """Return a json fragment for storage."""
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,
380 "identifiers":
list(self.identifiers),
381 "labels":
list(self.labels),
382 "manufacturer": self.manufacturer,
384 "model_id": self.model_id,
385 "modified_at": self.modified_at,
386 "name_by_user": self.name_by_user,
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,
397 @attr.s(frozen=True, slots=True)
399 """Deleted Device Registry Entry."""
401 config_entries: set[str] = attr.ib()
402 connections: set[tuple[str, str]] = attr.ib()
403 identifiers: set[tuple[str, 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)
412 config_entry_id: str,
413 connections: set[tuple[str, str]],
414 identifiers: set[tuple[str, str]],
416 """Create DeviceEntry from DeletedDeviceEntry."""
419 config_entries={config_entry_id},
420 connections=self.connections & connections,
421 created_at=self.created_at,
422 identifiers=self.identifiers & identifiers,
427 @under_cached_property
429 """Return a json fragment for storage."""
433 "config_entries":
list(self.config_entries),
434 "connections":
list(self.connections),
435 "created_at": self.created_at,
436 "identifiers":
list(self.identifiers),
438 "orphaned_timestamp": self.orphaned_timestamp,
439 "modified_at": self.modified_at,
445 @lru_cache(maxsize=512)
447 """Format the mac address string for entry into dev reg."""
450 if len(to_test) == 17
and to_test.count(
":") == 5:
451 return to_test.lower()
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(
".",
"")
458 if len(to_test) == 12:
460 return ":".join(to_test.lower()[i : i + 2]
for i
in range(0, 12, 2))
467 """Store entity registry data."""
471 old_major_version: int,
472 old_minor_version: int,
473 old_data: dict[str, list[dict[str, Any]]],
475 """Migrate to the new version."""
476 if old_major_version < 2:
477 if old_minor_version < 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)
486 device.get(
"entry_type"),
489 device[
"entry_type"] =
None
490 device.setdefault(
"name_by_user",
None)
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:
498 for device
in old_data[
"devices"]:
499 device[
"hw_version"] =
None
500 if old_minor_version < 4:
502 for device
in old_data[
"devices"]:
503 device[
"serial_number"] =
None
504 if old_minor_version < 5:
506 for device
in old_data[
"devices"]:
507 device[
"labels"] = []
508 if old_minor_version < 6:
510 for device
in old_data[
"devices"]:
511 device[
"primary_config_entry"] =
None
512 if old_minor_version < 7:
514 for device
in old_data[
"devices"]:
515 device[
"model_id"] =
None
516 if old_minor_version < 8:
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
524 if old_major_version > 1:
525 raise NotImplementedError
530 BaseRegistryItems[_EntryTypeT]
532 """Container for device registry items, maps device id -> entry.
534 Maintains two additional indexes:
535 - (connection_type, connection identifier) -> entry
536 - (DOMAIN, identifier) -> entry
540 """Initialize the container."""
542 self._connections: dict[tuple[str, str], _EntryTypeT] = {}
543 self._identifiers: dict[tuple[str, str], _EntryTypeT] = {}
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
553 self, key: str, replacement_entry: _EntryTypeT |
None =
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]
564 identifiers: set[tuple[str, str]] |
None,
565 connections: set[tuple[str, str]] |
None,
566 ) -> _EntryTypeT |
None:
567 """Get entry from identifiers or connections."""
569 for identifier
in identifiers:
570 if identifier
in self._identifiers:
571 return self._identifiers[identifier]
575 if connection
in self._connections:
576 return self._connections[connection]
581 """Container for active (non-deleted) device registry entries."""
584 """Initialize the container.
586 Maintains three additional indexes:
588 - area_id -> dict[key, True]
589 - config_entry_id -> dict[key, True]
590 - label -> dict[key, True]
593 self._area_id_index: RegistryIndexType = defaultdict(dict)
594 self._config_entry_id_index: RegistryIndexType = defaultdict(dict)
595 self._labels_index: RegistryIndexType = defaultdict(dict)
598 """Index an 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
608 self, key: str, replacement_entry: DeviceEntry |
None =
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:
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)
622 """Get devices for area."""
624 return [data[key]
for key
in self._area_id_index.
get(area_id, ())]
627 """Get devices for label."""
629 return [data[key]
for key
in self._labels_index.
get(label, ())]
632 self, config_entry_id: str
633 ) -> list[DeviceEntry]:
634 """Get devices for config entry."""
637 data[key]
for key
in self._config_entry_id_index.
get(config_entry_id, ())
642 """Class to hold a registry of devices."""
644 devices: ActiveDeviceRegistryItems
645 deleted_devices: DeviceRegistryItems[DeletedDeviceEntry]
646 _device_data: dict[str, DeviceEntry]
649 """Initialize the device registry."""
653 STORAGE_VERSION_MAJOR,
656 minor_version=STORAGE_VERSION_MINOR,
660 def async_get(self, device_id: str) -> DeviceEntry |
None:
663 We retrieve the DeviceEntry from the underlying dict to avoid
664 the overhead of the UserDict __getitem__.
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."""
679 identifiers: set[tuple[str, str]],
680 connections: set[tuple[str, str]],
681 ) -> DeletedDeviceEntry |
None:
682 """Check if device is deleted."""
689 translation_placeholders: Mapping[str, str],
691 """Substitute placeholders in entity name."""
693 return name.format(**translation_placeholders)
694 except KeyError
as err:
698 self.
hasshass, integration_domain=domain
702 "Device from integration %s has translation placeholders '%s' "
703 "which do not match the name '%s', please %s"
706 translation_placeholders,
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,
720 default_manufacturer: str |
None | UndefinedType = UNDEFINED,
721 default_model: str |
None | UndefinedType = UNDEFINED,
722 default_name: str |
None | UndefinedType = UNDEFINED,
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,
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,
740 """Get device. Create if it doesn't exist."""
741 if configuration_url
is not UNDEFINED:
744 config_entry = self.
hasshass.config_entries.async_get_entry(config_entry_id)
745 if config_entry
is None:
747 f
"Can't link device to unknown config entry {config_entry_id}"
751 full_translation_key = (
752 f
"component.{config_entry.domain}.device.{translation_key}.name"
754 translations = translation.async_get_cached_translations(
755 self.
hasshass, self.
hasshass.config.language,
"device", config_entry.domain
757 translated_name = translations.get(full_translation_key, translation_key)
759 config_entry.domain, translated_name, translation_placeholders
or {}
765 device_info: DeviceInfo = {
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),
778 (
"model_id", model_id),
780 (
"serial_number", serial_number),
781 (
"suggested_area", suggested_area),
782 (
"sw_version", sw_version),
783 (
"via_device", via_device),
785 if val
is not UNDEFINED
790 if identifiers
is None or identifiers
is UNDEFINED:
793 if connections
is None or connections
is UNDEFINED:
798 device = self.
async_get_deviceasync_get_device(identifiers=identifiers, connections=connections)
802 if deleted_device
is None:
806 device = deleted_device.to_device_entry(
807 config_entry_id, connections, identifiers
809 self.
devicesdevices[device.id] = device
811 if device_info_type ==
"primary" and (
not name
or name
is UNDEFINED):
812 name = config_entry.title
814 if default_manufacturer
is not UNDEFINED
and device.manufacturer
is None:
815 manufacturer = default_manufacturer
817 if default_model
is not UNDEFINED
and device.model
is None:
818 model = default_model
820 if default_name
is not UNDEFINED
and device.name
is None:
823 if via_device
is not None and via_device
is not UNDEFINED:
825 via_device_id: str | UndefinedType = via.id
if via
else UNDEFINED
827 via_device_id = UNDEFINED
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,
844 serial_number=serial_number,
845 suggested_area=suggested_area,
846 sw_version=sw_version,
847 via_device_id=via_device_id,
860 add_config_entry_id: str | UndefinedType = UNDEFINED,
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]
889 new_values: dict[str, Any] = {}
890 old_values: dict[str, Any] = {}
892 config_entries = old.config_entries
894 if add_config_entry_id
is not UNDEFINED:
895 if self.
hasshass.config_entries.async_get_entry(add_config_entry_id)
is None:
897 f
"Can't link device to unknown config entry {add_config_entry_id}"
900 if not new_connections
and not new_identifiers:
902 "A device must have at least one of identifiers or connections"
905 if merge_connections
is not UNDEFINED
and new_connections
is not UNDEFINED:
907 "Cannot define both merge_connections and new_connections"
910 if merge_identifiers
is not UNDEFINED
and new_identifiers
is not UNDEFINED:
912 "Cannot define both merge_identifiers and new_identifiers"
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
924 from .
import area_registry
as ar
929 if add_config_entry_id
is not UNDEFINED:
930 primary_entry_id = old.primary_config_entry
932 device_info_type ==
"primary"
933 and add_config_entry_id != primary_entry_id
936 primary_entry_id
is None
938 primary_entry := self.
hasshass.config_entries.async_get_entry(
942 or primary_entry.domain
in LOW_PRIO_CONFIG_ENTRY_DOMAINS
944 new_values[
"primary_config_entry"] = add_config_entry_id
945 old_values[
"primary_config_entry"] = primary_entry_id
947 if add_config_entry_id
not in old.config_entries:
948 config_entries = old.config_entries | {add_config_entry_id}
951 remove_config_entry_id
is not UNDEFINED
952 and remove_config_entry_id
in config_entries
954 if config_entries == {remove_config_entry_id}:
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
962 config_entries = config_entries - {remove_config_entry_id}
964 if config_entries != old.config_entries:
965 new_values[
"config_entries"] = config_entries
966 old_values[
"config_entries"] = old.config_entries
968 for attr_name, setvalue
in (
969 (
"connections", merge_connections),
970 (
"identifiers", merge_identifiers),
972 old_value = getattr(old, attr_name)
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
978 if merge_connections
is not UNDEFINED:
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
989 if merge_identifiers
is not UNDEFINED:
991 device_id, merge_identifiers, allow_collisions
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
998 if new_connections
is not UNDEFINED:
1000 device_id, new_connections,
False
1002 old_values[
"connections"] = old.connections
1004 if new_identifiers
is not UNDEFINED:
1006 device_id, new_identifiers,
False
1008 old_values[
"identifiers"] = old.identifiers
1010 if configuration_url
is not UNDEFINED:
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),
1020 (
"manufacturer", manufacturer),
1022 (
"model_id", model_id),
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),
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)
1035 new_values[
"is_new"] =
False
1040 if not RUNTIME_ONLY_ATTRS.issuperset(new_values):
1042 new_values[
"modified_at"] =
utcnow()
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
1053 if RUNTIME_ONLY_ATTRS.issuperset(new_values):
1056 self.async_schedule_save()
1058 data: EventDeviceRegistryUpdatedData
1060 data = {
"action":
"create",
"device_id": new.id}
1062 data = {
"action":
"update",
"device_id": new.id,
"changes": old_values}
1064 self.
hasshass.bus.async_fire_internal(EVENT_DEVICE_REGISTRY_UPDATED, data)
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."""
1077 if allow_collisions:
1078 return normalized_connections
1080 for connection
in normalized_connections:
1085 existing_device := self.
async_get_deviceasync_get_device(connections={connection})
1086 )
and existing_device.id != device_id:
1088 normalized_connections, existing_device
1091 return normalized_connections
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:
1104 for identifier
in identifiers:
1109 existing_device := self.
async_get_deviceasync_get_device(identifiers={identifier})
1110 )
and existing_device.id != device_id:
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)
1121 config_entries=device.config_entries,
1122 connections=device.connections,
1123 created_at=device.created_at,
1124 identifiers=device.identifiers,
1126 orphaned_timestamp=
None,
1128 for other_device
in list(self.
devicesdevices.values()):
1129 if other_device.via_device_id == device_id:
1131 self.
hasshass.bus.async_fire_internal(
1132 EVENT_DEVICE_REGISTRY_UPDATED,
1134 action=
"remove", device_id=device_id
1137 self.async_schedule_save()
1140 """Load the device registry."""
1148 if data
is not None:
1149 for device
in data[
"devices"]:
1151 area_id=device[
"area_id"],
1152 config_entries=set(device[
"config_entries"]),
1153 configuration_url=device[
"configuration_url"],
1157 for conn
in device[
"connections"]
1159 created_at=datetime.fromisoformat(device[
"created_at"]),
1162 if device[
"disabled_by"]
1167 if device[
"entry_type"]
1170 hw_version=device[
"hw_version"],
1174 for iden
in device[
"identifiers"]
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"],
1189 for device
in data[
"deleted_devices"]:
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"]},
1196 modified_at=datetime.fromisoformat(device[
"modified_at"]),
1197 orphaned_timestamp=device[
"orphaned_timestamp"],
1206 """Return data of device registry to store in a file."""
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()
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)
1221 config_entries = deleted_device.config_entries
1222 if config_entry_id
not in config_entries:
1224 if config_entries == {config_entry_id}:
1227 deleted_device, orphaned_timestamp=now_time, config_entries=set()
1230 config_entries = config_entries - {config_entry_id}
1234 deleted_device, config_entries=config_entries
1236 self.async_schedule_save()
1240 """Purge expired orphaned devices from the registry.
1242 We need to purge these periodically to avoid the database
1243 growing without bound.
1245 now_time = time.time()
1247 if deleted_device.orphaned_timestamp
is None:
1251 deleted_device.orphaned_timestamp + ORPHANED_DEVICE_KEEP_SECONDS
1258 """Clear area id from registry entries."""
1259 for device
in self.
devicesdevices.get_devices_for_area_id(area_id):
1264 """Clear label from registry entries."""
1265 for device
in self.
devicesdevices.get_devices_for_label(label_id):
1270 @singleton(DATA_REGISTRY)
1272 """Get device registry."""
1277 """Load device registry."""
1278 assert DATA_REGISTRY
not in hass.data
1284 """Return entries that match an area."""
1285 return registry.devices.get_devices_for_area_id(area_id)
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)
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)
1306 registry: DeviceRegistry, config_entry: ConfigEntry
1308 """Handle a config entry being disabled or enabled.
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.
1319 if not config_entry.disabled_by:
1320 for device
in devices:
1321 if device.disabled_by
is not DeviceEntryDisabler.CONFIG_ENTRY:
1323 registry.async_update_device(device.id, disabled_by=
None)
1326 enabled_config_entries = {
1328 for entry
in registry.hass.config_entries.async_entries()
1329 if not entry.disabled_by
1332 for device
in devices:
1336 if len(device.config_entries) > 1
and device.config_entries.intersection(
1337 enabled_config_entries
1340 registry.async_update_device(
1341 device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY
1347 hass: HomeAssistant,
1348 dev_reg: DeviceRegistry,
1351 """Clean up device registry."""
1353 config_entry_ids = set(hass.config_entries.async_entry_ids())
1354 references_config_entries = {
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
1362 device_ids_referenced_by_entities = set(ent_reg.entities.get_device_ids())
1365 set(dev_reg.devices)
1366 - device_ids_referenced_by_entities
1367 - references_config_entries
1370 for dev_id
in orphan:
1371 dev_reg.async_remove_device(dev_id)
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
1384 dev_reg.async_purge_expired_orphaned_devices()
1389 """Clean up device registry when entities removed."""
1391 from .
import entity_registry, label_registry
as lr
1394 def _label_removed_from_registry_filter(
1395 event_data: lr.EventLabelRegistryUpdatedData,
1397 """Filter all except for the remove action from label registry events."""
1398 return event_data[
"action"] ==
"remove"
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"])
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,
1412 def _async_cleanup() -> None:
1414 ent_reg = entity_registry.async_get(hass)
1417 debounced_cleanup: Debouncer[
None] =
Debouncer(
1418 hass, _LOGGER, cooldown=CLEANUP_DELAY, immediate=
False, function=_async_cleanup
1422 def _async_entity_registry_changed(
1423 event: Event[entity_registry.EventEntityRegistryUpdatedData],
1425 """Handle entity updated or removed dispatch."""
1426 debounced_cleanup.async_schedule_call()
1429 def entity_registry_changed_filter(
1430 event_data: entity_registry.EventEntityRegistryUpdatedData,
1432 """Handle entity updated or removed filter."""
1434 event_data[
"action"] ==
"update"
1435 and "device_id" not in event_data[
"changes"]
1436 )
or event_data[
"action"] ==
"create":
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,
1450 _async_listen_for_cleanup()
1453 async
def startup_clean(event: Event) ->
None:
1454 """Clean up on startup."""
1455 _async_listen_for_cleanup()
1456 await debounced_cleanup.async_call()
1458 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean)
1461 def _on_homeassistant_stop(event: Event) ->
None:
1462 """Cancel debounced cleanup."""
1463 debounced_cleanup.async_cancel()
1465 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop)
1469 """Normalize connections to ensure we can match mac addresses."""
1471 (key,
format_mac(value))
if key == CONNECTION_NETWORK_MAC
else (key, value)
1472 for key, value
in connections
1477 __getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
1479 dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
None _unindex_entry(self, str key, DeviceEntry|None replacement_entry=None)
None _index_entry(self, str key, DeviceEntry entry)
list[DeviceEntry] get_devices_for_label(self, str label)
list[DeviceEntry] get_devices_for_area_id(self, str area_id)
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)
json_fragment as_storage_fragment(self)
None __init__(self, set[tuple[str, str]] normalized_connections, DeviceEntry existing_device)
bytes|None json_repr(self)
json_fragment as_storage_fragment(self)
dict[str, Any] dict_repr(self)
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)
None async_clear_area_id(self, str area_id)
DeviceEntry|None async_get(self, str device_id)
None async_clear_label_id(self, str label_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)
None __init__(self, HomeAssistant hass)
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_purge_expired_orphaned_devices(self)
None async_clear_config_entry(self, str config_entry_id)
dict[str, Any] _data_to_save(self)
None async_remove_device(self, str device_id)
web.Response get(self, web.Request request, str config_key)
ReleaseChannel get_release_channel()
list[str] all_with_deprecated_constants(dict[str, Any] module_globals)
None async_load(HomeAssistant hass)
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)
str async_suggest_report_issue(HomeAssistant|None hass, *Integration|None integration=None, str|None integration_domain=None, str|None module=None)
str format_unserializable_data(dict[str, Any] data)