mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 18:24:50 -05:00
3377 lines
143 KiB
Python
3377 lines
143 KiB
Python
"""Provide a way to connect entities belonging to one device."""
|
|
|
|
import asyncio
|
|
from collections import defaultdict
|
|
from collections.abc import Iterable, Mapping, Set as AbstractSet
|
|
import copy
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from enum import StrEnum
|
|
from functools import lru_cache
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import time
|
|
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, Unpack, override
|
|
|
|
import attr
|
|
from yarl import URL
|
|
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
|
|
from homeassistant.core import (
|
|
Event,
|
|
HomeAssistant,
|
|
ReleaseChannel,
|
|
callback,
|
|
get_release_channel,
|
|
)
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.loader import async_suggest_report_issue
|
|
from homeassistant.util import uuid as uuid_util
|
|
from homeassistant.util.dt import utc_from_timestamp, utcnow
|
|
from homeassistant.util.event_type import EventType
|
|
from homeassistant.util.hass_dict import HassKey
|
|
from homeassistant.util.json import format_unserializable_data
|
|
|
|
from . import storage, translation
|
|
from .debounce import Debouncer
|
|
from .deprecation import deprecated_function
|
|
from .frame import (
|
|
MissingIntegrationFrame,
|
|
ReportBehavior,
|
|
get_integration_frame,
|
|
report_usage,
|
|
)
|
|
from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment
|
|
from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType
|
|
from .typing import UNDEFINED, UndefinedType
|
|
|
|
if TYPE_CHECKING:
|
|
# mypy cannot workout _cache Protocol with attrs
|
|
from propcache.api import cached_property as under_cached_property
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
|
from . import entity_registry
|
|
else:
|
|
from propcache.api import under_cached_property
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
DATA_REGISTRY: HassKey[DeviceRegistry] = HassKey("device_registry")
|
|
EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = EventType(
|
|
"device_registry_updated"
|
|
)
|
|
STORAGE_KEY = "core.device_registry"
|
|
STORAGE_VERSION_MAJOR = 3
|
|
STORAGE_VERSION_MINOR = 2
|
|
|
|
CLEANUP_DELAY = 10
|
|
|
|
CONNECTION_BLUETOOTH = "bluetooth"
|
|
CONNECTION_NETWORK_MAC = "mac"
|
|
CONNECTION_UPNP = "upnp"
|
|
CONNECTION_ZIGBEE = "zigbee"
|
|
|
|
ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30
|
|
|
|
# suggested_area can be removed when suggested_area is removed from DeviceEntry.
|
|
# pending_move can be removed once add_config_entry_id and remove_config_entry_id
|
|
# are removed from the device registry API.
|
|
RUNTIME_ONLY_ATTRS = {"suggested_area", "pending_move"}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _PendingMove:
|
|
"""A deferred config-entry move recorded by add_config_entry_id.
|
|
|
|
A later remove_config_entry_id from the same integration (origin_domain) completes
|
|
the move; one from a different integration cancels it. Runtime-only, never stored.
|
|
"""
|
|
|
|
config_entry_id: str
|
|
config_subentry_id: str | None
|
|
origin_domain: str | None
|
|
|
|
|
|
def _current_integration_domain() -> str | None:
|
|
"""Return the domain of the integration in the current call stack, if any."""
|
|
try:
|
|
return get_integration_frame().integration
|
|
except MissingIntegrationFrame:
|
|
return None
|
|
|
|
|
|
CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"}
|
|
|
|
|
|
class DeviceEntryDisabler(StrEnum):
|
|
"""What disabled a device entry."""
|
|
|
|
CONFIG_ENTRY = "config_entry"
|
|
INTEGRATION = "integration"
|
|
USER = "user"
|
|
|
|
|
|
class DeviceInfo(TypedDict, total=False):
|
|
"""Entity device information for device registry."""
|
|
|
|
configuration_url: str | URL | None
|
|
connections: set[tuple[str, str]]
|
|
created_at: str
|
|
default_manufacturer: str
|
|
default_model: str
|
|
default_name: str
|
|
entry_type: DeviceEntryType | None
|
|
identifiers: set[tuple[str, str]]
|
|
manufacturer: str | None
|
|
model: str | None
|
|
model_id: str | None
|
|
modified_at: str
|
|
name: str | None
|
|
serial_number: str | None
|
|
suggested_area: str | None
|
|
sw_version: str | None
|
|
hw_version: str | None
|
|
translation_key: str | None
|
|
translation_placeholders: Mapping[str, str] | None
|
|
via_device: tuple[str, str] # Deprecated, use via_device_id instead
|
|
via_device_id: str
|
|
|
|
|
|
DEVICE_INFO_TYPES = {
|
|
# Device info is categorized by finding the first device info type which has all
|
|
# the keys of the device info. The link device info type must be kept first
|
|
# to make it preferred over primary.
|
|
"link": {
|
|
"connections",
|
|
"identifiers",
|
|
},
|
|
"primary": {
|
|
"configuration_url",
|
|
"connections",
|
|
"entry_type",
|
|
"hw_version",
|
|
"identifiers",
|
|
"manufacturer",
|
|
"model",
|
|
"model_id",
|
|
"name",
|
|
"serial_number",
|
|
"suggested_area",
|
|
"sw_version",
|
|
"via_device",
|
|
"via_device_id",
|
|
},
|
|
"secondary": {
|
|
"connections",
|
|
"default_manufacturer",
|
|
"default_model",
|
|
"default_name",
|
|
# Used by Fritz
|
|
"via_device",
|
|
"via_device_id",
|
|
},
|
|
}
|
|
|
|
|
|
class _EventDeviceRegistryUpdatedData_Create(TypedDict):
|
|
"""EventDeviceRegistryUpdated data for action type 'create'."""
|
|
|
|
action: Literal["create"]
|
|
device_id: str
|
|
|
|
|
|
class _EventDeviceRegistryUpdatedData_Remove(TypedDict):
|
|
"""EventDeviceRegistryUpdated data for action type 'remove'."""
|
|
|
|
action: Literal["remove"]
|
|
device_id: str
|
|
device: dict[str, Any]
|
|
|
|
|
|
class _EventDeviceRegistryUpdatedData_Update(TypedDict):
|
|
"""EventDeviceRegistryUpdated data for action type 'update'."""
|
|
|
|
action: Literal["update"]
|
|
device_id: str
|
|
changes: dict[str, Any]
|
|
|
|
|
|
type EventDeviceRegistryUpdatedData = (
|
|
_EventDeviceRegistryUpdatedData_Create
|
|
| _EventDeviceRegistryUpdatedData_Remove
|
|
| _EventDeviceRegistryUpdatedData_Update
|
|
)
|
|
|
|
|
|
class DeviceEntryType(StrEnum):
|
|
"""Device entry type."""
|
|
|
|
SERVICE = "service"
|
|
|
|
|
|
class DeviceInfoError(HomeAssistantError):
|
|
"""Raised when device info is invalid."""
|
|
|
|
def __init__(self, domain: str, device_info: DeviceInfo, message: str) -> None:
|
|
"""Initialize error."""
|
|
super().__init__(
|
|
f"Invalid device info {device_info} for '{domain}' config entry: {message}",
|
|
)
|
|
self.device_info = device_info
|
|
self.domain = domain
|
|
|
|
|
|
class DeviceCollisionError(HomeAssistantError):
|
|
"""Raised when a device collision is detected."""
|
|
|
|
|
|
class DeviceIdentifierCollisionError(DeviceCollisionError):
|
|
"""Raised when a device identifier collision is detected."""
|
|
|
|
def __init__(
|
|
self, identifiers: set[tuple[str, str]], existing_device: DeviceEntry
|
|
) -> None:
|
|
"""Initialize error."""
|
|
super().__init__(
|
|
f"Identifiers {identifiers} already registered with {existing_device}"
|
|
)
|
|
|
|
|
|
class DeviceConnectionCollisionError(DeviceCollisionError):
|
|
"""Raised when a device connection collision is detected."""
|
|
|
|
def __init__(
|
|
self, normalized_connections: set[tuple[str, str]], existing_device: DeviceEntry
|
|
) -> None:
|
|
"""Initialize error."""
|
|
super().__init__(
|
|
f"Connections {normalized_connections} "
|
|
f"already registered with {existing_device}"
|
|
)
|
|
|
|
|
|
def _determine_device_info_type(
|
|
config_entry: ConfigEntry,
|
|
device_info: DeviceInfo,
|
|
) -> str:
|
|
"""Determine the type of a device info."""
|
|
keys = set(device_info)
|
|
|
|
# If no keys or not enough info to match up, abort
|
|
if not device_info.get("connections") and not device_info.get("identifiers"):
|
|
raise DeviceInfoError(
|
|
config_entry.domain,
|
|
device_info,
|
|
"device info must include at least one of identifiers or connections",
|
|
)
|
|
|
|
device_info_type: str | None = None
|
|
|
|
# Find the first device info type which has all keys in the device info
|
|
for possible_type, allowed_keys in DEVICE_INFO_TYPES.items():
|
|
if keys <= allowed_keys:
|
|
device_info_type = possible_type
|
|
break
|
|
|
|
if device_info_type is None:
|
|
raise DeviceInfoError(
|
|
config_entry.domain,
|
|
device_info,
|
|
(
|
|
"device info needs to either describe a device, "
|
|
"link to existing device or provide extra information."
|
|
),
|
|
)
|
|
|
|
return device_info_type
|
|
|
|
|
|
class _ValidatedDeviceInfoFields(TypedDict):
|
|
"""Device info fields validated on create and update."""
|
|
|
|
configuration_url: str | URL | UndefinedType | None
|
|
hw_version: str | UndefinedType | None
|
|
manufacturer: str | UndefinedType | None
|
|
model: str | UndefinedType | None
|
|
model_id: str | UndefinedType | None
|
|
serial_number: str | UndefinedType | None
|
|
sw_version: str | UndefinedType | None
|
|
|
|
|
|
_cached_parse_url = lru_cache(maxsize=512)(URL)
|
|
"""Parse a URL and cache the result."""
|
|
|
|
|
|
def _validate_str(name: str, value: Any) -> str | UndefinedType | None:
|
|
"""Validate that a device registry string field has correct type."""
|
|
if (
|
|
value is UNDEFINED
|
|
or value is None
|
|
or type(value) is str # fast path for exact str
|
|
or isinstance(value, str)
|
|
):
|
|
return value
|
|
report_usage(
|
|
f"passes a non-string value of type {type(value).__name__} "
|
|
f"as {name} to the device registry",
|
|
core_behavior=ReportBehavior.LOG,
|
|
breaks_in_ha_version="2026.12.0",
|
|
)
|
|
return str(value)
|
|
|
|
|
|
def _validate_device_info_fields(
|
|
**fields: Unpack[_ValidatedDeviceInfoFields],
|
|
) -> _ValidatedDeviceInfoFields:
|
|
"""Validate device-info field values."""
|
|
configuration_url = fields["configuration_url"]
|
|
url: URL | None = None
|
|
if type(configuration_url) is URL:
|
|
url = configuration_url
|
|
configuration_url = str(configuration_url)
|
|
else:
|
|
configuration_url = _validate_str("configuration_url", configuration_url)
|
|
if isinstance(configuration_url, str):
|
|
url = _cached_parse_url(configuration_url)
|
|
if url is not None and (
|
|
url.scheme not in CONFIGURATION_URL_SCHEMES or not url.host
|
|
):
|
|
raise ValueError(f"invalid configuration_url '{configuration_url}'")
|
|
return {
|
|
"configuration_url": configuration_url,
|
|
"hw_version": _validate_str("hw_version", fields["hw_version"]),
|
|
"manufacturer": _validate_str("manufacturer", fields["manufacturer"]),
|
|
"model": _validate_str("model", fields["model"]),
|
|
"model_id": _validate_str("model_id", fields["model_id"]),
|
|
"serial_number": _validate_str("serial_number", fields["serial_number"]),
|
|
"sw_version": _validate_str("sw_version", fields["sw_version"]),
|
|
}
|
|
|
|
|
|
@lru_cache(maxsize=512)
|
|
def format_mac(mac: str) -> str:
|
|
"""Format the mac address string for entry into dev reg."""
|
|
to_test = mac
|
|
|
|
if len(to_test) == 17 and to_test.count(":") == 5:
|
|
return to_test.lower()
|
|
|
|
if len(to_test) == 17 and to_test.count("-") == 5:
|
|
to_test = to_test.replace("-", "")
|
|
elif len(to_test) == 14 and to_test.count(".") == 2:
|
|
to_test = to_test.replace(".", "")
|
|
|
|
if len(to_test) == 12:
|
|
# no : included
|
|
return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2))
|
|
|
|
# Not sure how formatted, return original
|
|
return mac
|
|
|
|
|
|
def _normalize_connections(
|
|
connections: Iterable[tuple[str, str]],
|
|
) -> set[tuple[str, str]]:
|
|
"""Normalize connections to ensure we can match mac addresses."""
|
|
return {
|
|
(key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
|
|
for key, value in connections
|
|
}
|
|
|
|
|
|
def _normalize_connections_validator(
|
|
instance: Any,
|
|
attribute: Any,
|
|
connections: Iterable[tuple[str, str]],
|
|
) -> None:
|
|
"""Check connections normalization used as attrs validator."""
|
|
for key, value in connections:
|
|
if key == CONNECTION_NETWORK_MAC and format_mac(value) != value:
|
|
raise ValueError(f"Invalid mac address format: {value}")
|
|
|
|
|
|
@attr.s(frozen=True, slots=True)
|
|
class DeviceEntry:
|
|
"""Device Registry Entry."""
|
|
|
|
config_entry_id: str = attr.ib()
|
|
|
|
area_id: str | None = attr.ib(default=None)
|
|
config_subentry_id: str | None = attr.ib(default=None)
|
|
configuration_url: str | None = attr.ib(default=None)
|
|
connections: set[tuple[str, str]] = attr.ib(
|
|
converter=set, factory=set, validator=_normalize_connections_validator
|
|
)
|
|
created_at: datetime = attr.ib(factory=utcnow)
|
|
disabled_by: DeviceEntryDisabler | None = attr.ib(default=None)
|
|
entry_type: DeviceEntryType | None = attr.ib(default=None)
|
|
hw_version: str | None = attr.ib(default=None)
|
|
id: str = attr.ib(factory=uuid_util.random_uuid_hex)
|
|
identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
|
|
labels: set[str] = attr.ib(converter=set, factory=set)
|
|
# composite_device_id is the id of the pre-migration composite device this device was
|
|
# split from; composite_primary_config_entry is that composite's former
|
|
# primary_config_entry, so a restored composite device can report it.
|
|
# split_at records when the split happened.
|
|
composite_device_id: str | None = attr.ib(default=None)
|
|
composite_primary_config_entry: str | None = attr.ib(default=None)
|
|
split_at: datetime | None = attr.ib(default=None)
|
|
manufacturer: str | None = attr.ib(default=None)
|
|
model: str | None = attr.ib(default=None)
|
|
model_id: str | None = attr.ib(default=None)
|
|
modified_at: datetime = attr.ib(factory=utcnow)
|
|
name_by_user: str | None = attr.ib(default=None)
|
|
name: str | None = attr.ib(default=None)
|
|
# Set on devices created by splitting a pre-migration composite device: the
|
|
# identifiers and connections copied from the composite have not yet been reconciled.
|
|
# On the owning integration's first re-registration they are replaced with the ones
|
|
# it provides and this flag is cleared - a one-shot marker, unlike composite_device_id
|
|
# which is kept for the device's lifetime so old ids keep resolving; neither can be
|
|
# derived from the other. This flag and the replacement logic can be removed in HA
|
|
# Core 2027.8.
|
|
has_composite_identifiers: bool = attr.ib(default=False)
|
|
serial_number: str | None = attr.ib(default=None)
|
|
# Suggested area is deprecated and will be removed from DeviceEntry in HA Core 2026.9.
|
|
_suggested_area: str | None = attr.ib(default=None)
|
|
sw_version: str | None = attr.ib(default=None)
|
|
via_device_id: str | None = attr.ib(default=None)
|
|
# Transient pending move target (config_entry_id, config_subentry_id) initiated by
|
|
# add_config_entry_id and completed by a subsequent remove_config_entry_id. It is
|
|
# never stored and is not part of equality. Can be removed in HA Core 2027.8.
|
|
_pending_move: _PendingMove | None = attr.ib(default=None, eq=False)
|
|
# Set only on the read-only composite device that async_get synthesizes on demand
|
|
# for a pre-migration composite device id. It holds the union of the split
|
|
# devices' config entries and subentries so callers see the pre-split device. It is
|
|
# never stored and the composite is never added to the registry. Can be removed in
|
|
# HA Core 2027.8.
|
|
_composite_subentries: dict[str, set[str | None]] | None = attr.ib(
|
|
default=None, eq=False
|
|
)
|
|
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
|
|
|
|
@property
|
|
def config_entries(self) -> set[str]:
|
|
"""Return the config entries this device belongs to.
|
|
|
|
Deprecated compatibility shim: a device now belongs to a single config
|
|
entry, available as config_entry_id.
|
|
"""
|
|
if self._composite_subentries is not None:
|
|
return set(self._composite_subentries)
|
|
return {self.config_entry_id}
|
|
|
|
@property
|
|
def config_entries_subentries(self) -> dict[str, set[str | None]]:
|
|
"""Return the config subentries this device belongs to.
|
|
|
|
Deprecated compatibility shim: a device now belongs to a single config
|
|
entry and subentry, available as config_entry_id and config_subentry_id.
|
|
"""
|
|
if self._composite_subentries is not None:
|
|
return {
|
|
entry_id: set(subentries)
|
|
for entry_id, subentries in self._composite_subentries.items()
|
|
}
|
|
return {self.config_entry_id: {self.config_subentry_id}}
|
|
|
|
@property
|
|
def primary_config_entry(self) -> str:
|
|
"""Return the primary config entry of this device.
|
|
|
|
Deprecated compatibility shim: a device now belongs to a single config
|
|
entry, available as config_entry_id, which is its primary config entry.
|
|
|
|
For a restored composite device (synthesized on the fly by async_get for a
|
|
pre-migration composite device id), this returns the composite's former
|
|
primary_config_entry, which is recorded on the split devices during migration as
|
|
composite_primary_config_entry.
|
|
"""
|
|
return self.config_entry_id
|
|
|
|
@property
|
|
def disabled(self) -> bool:
|
|
"""Return if entry is disabled."""
|
|
return self.disabled_by is not None
|
|
|
|
@property
|
|
def dict_repr(self) -> dict[str, Any]:
|
|
"""Return a dict representation of the entry."""
|
|
# Convert sets and tuples to lists
|
|
# so the JSON serializer does not have to do
|
|
# it every time
|
|
return {
|
|
"area_id": self.area_id,
|
|
"configuration_url": self.configuration_url,
|
|
# config_entries and config_entries_subentries are deprecated and kept for
|
|
# backwards compatibility, they can be removed in HA Core 2027.8. They use the
|
|
# compatibility properties so a restored composite reports its merged entries.
|
|
"config_entries": list(self.config_entries),
|
|
"config_entries_subentries": {
|
|
entry_id: list(subentries)
|
|
for entry_id, subentries in self.config_entries_subentries.items()
|
|
},
|
|
"config_entry_id": self.config_entry_id,
|
|
"config_subentry_id": self.config_subentry_id,
|
|
"connections": list(self.connections),
|
|
"created_at": self.created_at.timestamp(),
|
|
"disabled_by": self.disabled_by,
|
|
"entry_type": self.entry_type,
|
|
"hw_version": self.hw_version,
|
|
"id": self.id,
|
|
"identifiers": list(self.identifiers),
|
|
"labels": list(self.labels),
|
|
"manufacturer": self.manufacturer,
|
|
"model": self.model,
|
|
"model_id": self.model_id,
|
|
"modified_at": self.modified_at.timestamp(),
|
|
"name_by_user": self.name_by_user,
|
|
"name": self.name,
|
|
"primary_config_entry": self.primary_config_entry,
|
|
"serial_number": self.serial_number,
|
|
"sw_version": self.sw_version,
|
|
"via_device_id": self.via_device_id,
|
|
}
|
|
|
|
@under_cached_property
|
|
def json_repr(self) -> bytes | None:
|
|
"""Return a cached JSON representation of the entry."""
|
|
try:
|
|
dict_repr = self.dict_repr
|
|
return json_bytes(dict_repr)
|
|
except ValueError, TypeError:
|
|
_LOGGER.error(
|
|
"Unable to serialize entry %s to JSON. Bad data found at %s",
|
|
self.id,
|
|
format_unserializable_data(
|
|
find_paths_unserializable_data(dict_repr, dump=JSON_DUMP)
|
|
),
|
|
)
|
|
return None
|
|
|
|
@under_cached_property
|
|
def as_storage_fragment(self) -> json_fragment:
|
|
"""Return a json fragment for storage."""
|
|
return json_fragment(
|
|
json_bytes(
|
|
{
|
|
"area_id": self.area_id,
|
|
"config_entry_id": self.config_entry_id,
|
|
"config_subentry_id": self.config_subentry_id,
|
|
"configuration_url": self.configuration_url,
|
|
"connections": list(self.connections),
|
|
"created_at": self.created_at,
|
|
"disabled_by": self.disabled_by,
|
|
"entry_type": self.entry_type,
|
|
"hw_version": self.hw_version,
|
|
"id": self.id,
|
|
"identifiers": list(self.identifiers),
|
|
"labels": list(self.labels),
|
|
"composite_device_id": self.composite_device_id,
|
|
"composite_primary_config_entry": (
|
|
self.composite_primary_config_entry
|
|
),
|
|
"split_at": self.split_at,
|
|
"manufacturer": self.manufacturer,
|
|
"model": self.model,
|
|
"model_id": self.model_id,
|
|
"modified_at": self.modified_at,
|
|
"name_by_user": self.name_by_user,
|
|
"name": self.name,
|
|
"has_composite_identifiers": (self.has_composite_identifiers),
|
|
"primary_config_entry": self.primary_config_entry,
|
|
"serial_number": self.serial_number,
|
|
"sw_version": self.sw_version,
|
|
"via_device_id": self.via_device_id,
|
|
}
|
|
)
|
|
)
|
|
|
|
@property
|
|
@deprecated_function(
|
|
"code which ignores suggested_area", breaks_in_ha_version="2026.9"
|
|
)
|
|
def suggested_area(self) -> str | None:
|
|
"""Return the suggested area for this device entry."""
|
|
return self._suggested_area
|
|
|
|
|
|
# async_update_device arguments that redefine which identifiers/connections a device is
|
|
# keyed by, or move it to another config entry. They are ambiguous on a synthesized
|
|
# composite (there is no single underlying device to retarget), so the composite shim
|
|
# drops them with a warning instead of fanning them out. serial_number is intentionally
|
|
# NOT here: it describes the physical device and is consistent across a composite's
|
|
# splits, so it fans out like sw_version. Can be removed in HA Core 2027.8.
|
|
_COMPOSITE_IGNORED_UPDATE_ARGS = (
|
|
"merge_connections",
|
|
"merge_identifiers",
|
|
"new_config_entry_id",
|
|
"new_config_subentry_id",
|
|
"new_connections",
|
|
"new_identifiers",
|
|
)
|
|
|
|
|
|
@attr.s(frozen=True, slots=True)
|
|
class DeletedDeviceEntry:
|
|
"""Deleted Device Registry Entry."""
|
|
|
|
# config_entry_id is None for orphaned deleted devices, i.e. devices whose owning
|
|
# config entry has been removed
|
|
config_entry_id: str | None = attr.ib()
|
|
config_subentry_id: str | None = attr.ib()
|
|
|
|
area_id: str | None = attr.ib()
|
|
connections: set[tuple[str, str]] = attr.ib(
|
|
validator=_normalize_connections_validator
|
|
)
|
|
created_at: datetime = attr.ib()
|
|
disabled_by: DeviceEntryDisabler | UndefinedType | None = attr.ib()
|
|
id: str = attr.ib()
|
|
identifiers: set[tuple[str, str]] = attr.ib()
|
|
labels: set[str] = attr.ib()
|
|
modified_at: datetime = attr.ib()
|
|
name_by_user: str | None = attr.ib()
|
|
orphaned_timestamp: float | None = attr.ib()
|
|
# Domain of the config entry that owns (or owned) this device, recorded when the
|
|
# device is deleted so a re-added config entry only restores an orphan from the same
|
|
# integration. None for legacy stores.
|
|
domain: str | None = attr.ib(default=None)
|
|
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
|
|
|
|
@property
|
|
def config_entries(self) -> set[str]:
|
|
"""Return the config entries this device belonged to.
|
|
|
|
Deprecated compatibility shim; empty for orphaned deleted devices.
|
|
"""
|
|
return {self.config_entry_id} if self.config_entry_id is not None else set()
|
|
|
|
@property
|
|
def config_entries_subentries(self) -> dict[str, set[str | None]]:
|
|
"""Return the config subentries this device belonged to.
|
|
|
|
Deprecated compatibility shim; empty for orphaned deleted devices.
|
|
"""
|
|
if self.config_entry_id is None:
|
|
return {}
|
|
return {self.config_entry_id: {self.config_subentry_id}}
|
|
|
|
def to_device_entry(
|
|
self,
|
|
config_entry: ConfigEntry,
|
|
config_subentry_id: str | None,
|
|
connections: set[tuple[str, str]],
|
|
identifiers: set[tuple[str, str]],
|
|
disabled_by: DeviceEntryDisabler | UndefinedType | None,
|
|
) -> DeviceEntry:
|
|
"""Create DeviceEntry from DeletedDeviceEntry."""
|
|
# Adjust disabled_by based on config entry state
|
|
if self.disabled_by is not UNDEFINED:
|
|
disabled_by = self.disabled_by
|
|
if config_entry.disabled_by:
|
|
if disabled_by is None:
|
|
disabled_by = DeviceEntryDisabler.CONFIG_ENTRY
|
|
elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY:
|
|
disabled_by = None
|
|
else:
|
|
disabled_by = disabled_by if disabled_by is not UNDEFINED else None
|
|
return DeviceEntry(
|
|
area_id=self.area_id,
|
|
config_entry_id=config_entry.entry_id,
|
|
config_subentry_id=config_subentry_id,
|
|
# type ignores: likely https://github.com/python/mypy/issues/8625
|
|
connections=connections, # type: ignore[arg-type]
|
|
created_at=self.created_at,
|
|
disabled_by=disabled_by,
|
|
identifiers=identifiers, # type: ignore[arg-type]
|
|
id=self.id,
|
|
labels=self.labels, # type: ignore[arg-type]
|
|
name_by_user=self.name_by_user,
|
|
)
|
|
|
|
@under_cached_property
|
|
def as_storage_fragment(self) -> json_fragment:
|
|
"""Return a json fragment for storage."""
|
|
return json_fragment(
|
|
json_bytes(
|
|
{
|
|
"area_id": self.area_id,
|
|
"config_entry_id": self.config_entry_id,
|
|
"config_subentry_id": self.config_subentry_id,
|
|
"connections": list(self.connections),
|
|
"created_at": self.created_at,
|
|
"disabled_by": self.disabled_by
|
|
if self.disabled_by is not UNDEFINED
|
|
else None,
|
|
"disabled_by_undefined": self.disabled_by is UNDEFINED,
|
|
"identifiers": list(self.identifiers),
|
|
"id": self.id,
|
|
"labels": list(self.labels),
|
|
"modified_at": self.modified_at,
|
|
"name_by_user": self.name_by_user,
|
|
"orphaned_timestamp": self.orphaned_timestamp,
|
|
"domain": self.domain,
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
def _copy_if_exists(source: str, destination: str) -> bool:
|
|
"""Copy source to destination when source exists (runs in the executor).
|
|
|
|
Returns whether the file was copied.
|
|
"""
|
|
if not os.path.isfile(source):
|
|
return False
|
|
shutil.copyfile(source, destination)
|
|
return True
|
|
|
|
|
|
class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
|
"""Store entity registry data."""
|
|
|
|
@override
|
|
async def _async_migrate_func( # noqa: C901
|
|
self,
|
|
old_major_version: int,
|
|
old_minor_version: int,
|
|
old_data: dict[str, list[dict[str, Any]]],
|
|
) -> dict[str, Any]:
|
|
"""Migrate to the new version."""
|
|
# Note: There's no version 2, it was planned and supported by previous versions
|
|
# of the migrator which treated version 2 like version 1.
|
|
if old_major_version < 3:
|
|
# Copy the store before the version 3 migrator rewrites every device, so a
|
|
# user can recover the pre-migration registry if the migration misbehaves.
|
|
await self._async_backup_store()
|
|
if old_minor_version < 2:
|
|
# Version 1.2 implements migration and freezes the available keys,
|
|
# populate keys which were introduced before version 1.2
|
|
for device in old_data["devices"]:
|
|
device.setdefault("area_id", None)
|
|
device.setdefault("configuration_url", None)
|
|
device.setdefault("disabled_by", None)
|
|
try:
|
|
device["entry_type"] = DeviceEntryType(
|
|
device.get("entry_type"), # type: ignore[arg-type]
|
|
)
|
|
except ValueError:
|
|
device["entry_type"] = None
|
|
device.setdefault("name_by_user", None)
|
|
# via_device_id was originally introduced as hub_device_id
|
|
device.setdefault("via_device_id", device.get("hub_device_id"))
|
|
old_data.setdefault("deleted_devices", [])
|
|
for device in old_data["deleted_devices"]:
|
|
device.setdefault("orphaned_timestamp", None)
|
|
if old_minor_version < 3:
|
|
# Version 1.3 adds hw_version
|
|
for device in old_data["devices"]:
|
|
device["hw_version"] = None
|
|
if old_minor_version < 4:
|
|
# Introduced in 2023.11
|
|
for device in old_data["devices"]:
|
|
device["serial_number"] = None
|
|
if old_minor_version < 5:
|
|
# Introduced in 2024.3
|
|
for device in old_data["devices"]:
|
|
device["labels"] = []
|
|
if old_minor_version < 6:
|
|
# Introduced in 2024.7
|
|
for device in old_data["devices"]:
|
|
device["primary_config_entry"] = None
|
|
if old_minor_version < 7:
|
|
# Introduced in 2024.8
|
|
for device in old_data["devices"]:
|
|
device["model_id"] = None
|
|
if old_minor_version < 8:
|
|
# Introduced in 2024.8
|
|
created_at = utc_from_timestamp(0).isoformat()
|
|
for device in old_data["devices"]:
|
|
device["created_at"] = device["modified_at"] = created_at
|
|
for device in old_data["deleted_devices"]:
|
|
device["created_at"] = device["modified_at"] = created_at
|
|
if old_minor_version < 9:
|
|
# Introduced in 2025.2
|
|
for device in old_data["devices"]:
|
|
device["config_entries_subentries"] = {
|
|
config_entry_id: {None}
|
|
for config_entry_id in device["config_entries"]
|
|
}
|
|
for device in old_data["deleted_devices"]:
|
|
device["config_entries_subentries"] = {
|
|
config_entry_id: {None}
|
|
for config_entry_id in device["config_entries"]
|
|
}
|
|
if old_minor_version < 10:
|
|
# Introduced in 2025.6
|
|
for device in old_data["deleted_devices"]:
|
|
device["area_id"] = None
|
|
device["disabled_by"] = None
|
|
device["labels"] = []
|
|
device["name_by_user"] = None
|
|
if old_minor_version < 11:
|
|
# Normalization of stored CONNECTION_NETWORK_MAC, introduced in 2025.8
|
|
for device in old_data["devices"]:
|
|
device["connections"] = _normalize_connections(
|
|
device["connections"]
|
|
)
|
|
for device in old_data["deleted_devices"]:
|
|
device["connections"] = _normalize_connections(
|
|
device["connections"]
|
|
)
|
|
if old_minor_version < 12:
|
|
# Version 1.12 adds undefined flags to deleted devices, this is a bugfix
|
|
# of version 1.10
|
|
for device in old_data["deleted_devices"]:
|
|
device["disabled_by_undefined"] = old_minor_version < 10
|
|
# Version 3 restricts a device to a single config entry and subentry,
|
|
# introduced in 2026.8. Composite devices which belonged to several
|
|
# config entries (or several subentries of one entry) are split into one
|
|
# device per (config entry, subentry). Each split device keeps a copy of
|
|
# the identifiers and connections and a reference (composite_device_id) to the original
|
|
# composite device id, so that actions targeting the old id still reach
|
|
# all split devices. Entities are moved to the matching split device when
|
|
# the registries are loaded.
|
|
migrated_at = utcnow().isoformat()
|
|
devices: list[dict[str, Any]] = []
|
|
# Active splits whose copied disabled_by must be reconciled against their
|
|
# single config entry once the config entries are loaded
|
|
migrated_active_splits: list[dict[str, Any]] = []
|
|
for device in old_data["devices"]:
|
|
# One target per config entry. config_entries_subentries was a set, so
|
|
# the old model allowed a device in several subentries of one config
|
|
# entry, but the single-owner model keeps one. Multi-subentry devices
|
|
# created by core integrations all come from broken subentry migrators
|
|
# (which left a device in both None and its real subentry), so prefer
|
|
# a real subentry over the main entry (None). Collapsing rather than
|
|
# splitting avoids duplicate devices which, sharing identifiers and
|
|
# connections within one config entry, would collide in the
|
|
# per-config-entry identifier/connection index.
|
|
pairs = [
|
|
(
|
|
config_entry_id,
|
|
next((s for s in subentry_ids if s is not None), None),
|
|
)
|
|
for config_entry_id, subentry_ids in device[
|
|
"config_entries_subentries"
|
|
].items()
|
|
]
|
|
if not pairs:
|
|
# Drop devices that have no config entry / subentry pairs
|
|
continue
|
|
if len(pairs) == 1:
|
|
config_entry_id, subentry_id = pairs[0]
|
|
device["config_entry_id"] = config_entry_id
|
|
device["config_subentry_id"] = subentry_id
|
|
device["composite_device_id"] = None
|
|
device["composite_primary_config_entry"] = None
|
|
device["split_at"] = None
|
|
device["has_composite_identifiers"] = False
|
|
devices.append(device)
|
|
continue
|
|
old_id = device["id"]
|
|
composite_primary = device.get("primary_config_entry")
|
|
for config_entry_id, subentry_id in pairs:
|
|
split = copy.deepcopy(device)
|
|
split["id"] = uuid_util.random_uuid_hex()
|
|
split["config_entry_id"] = config_entry_id
|
|
split["config_subentry_id"] = subentry_id
|
|
split["primary_config_entry"] = config_entry_id
|
|
split["composite_device_id"] = old_id
|
|
split["composite_primary_config_entry"] = composite_primary
|
|
split["split_at"] = migrated_at
|
|
split["has_composite_identifiers"] = True
|
|
devices.append(split)
|
|
migrated_active_splits.append(split)
|
|
old_data["devices"] = devices
|
|
# A split inherited the composite's disabled_by, which may not match its
|
|
# single config entry (e.g. a split owned by an enabled entry must not stay
|
|
# CONFIG_ENTRY disabled). Config entries load concurrently, so wait for them
|
|
# and reconcile each split against its own entry.
|
|
if migrated_active_splits:
|
|
await self.hass.config_entries.async_wait_initialized()
|
|
for split in migrated_active_splits:
|
|
config_entry = self.hass.config_entries.async_get_entry(
|
|
split["config_entry_id"]
|
|
)
|
|
if config_entry is not None:
|
|
_migrate_device_disabled_by(
|
|
split, config_entry.disabled_by is not None
|
|
)
|
|
|
|
deleted_devices: list[dict[str, Any]] = []
|
|
for device in old_data["deleted_devices"]:
|
|
# One target per config entry. config_entries_subentries was a set, so
|
|
# the old model allowed a device in several subentries of one config
|
|
# entry, but the single-owner model keeps one. Multi-subentry devices
|
|
# created by core integrations all come from broken subentry migrators
|
|
# (which left a device in both None and its real subentry), so prefer
|
|
# a real subentry over the main entry (None). Collapsing rather than
|
|
# splitting avoids duplicate devices which, sharing identifiers and
|
|
# connections within one config entry, would collide in the
|
|
# per-config-entry identifier/connection index.
|
|
pairs = [
|
|
(
|
|
config_entry_id,
|
|
next((s for s in subentry_ids if s is not None), None),
|
|
)
|
|
for config_entry_id, subentry_ids in device[
|
|
"config_entries_subentries"
|
|
].items()
|
|
]
|
|
if len(pairs) <= 1:
|
|
# Unlike active devices, config_entry_id=None is a valid
|
|
# (orphaned) state for a deleted device, so a deleted device with
|
|
# no config entries is kept rather than dropped.
|
|
config_entry_id, subentry_id = pairs[0] if pairs else (None, None)
|
|
device["config_entry_id"] = config_entry_id
|
|
device["config_subentry_id"] = subentry_id
|
|
device["domain"] = None
|
|
deleted_devices.append(device)
|
|
continue
|
|
# A deleted device that belonged to several config entries or subentries
|
|
# is split like an active one - each split keeps a copy of the
|
|
# identifiers/connections so every config entry can still restore its
|
|
# share when a matching device is re-registered.
|
|
for config_entry_id, subentry_id in pairs:
|
|
split = copy.deepcopy(device)
|
|
split["id"] = uuid_util.random_uuid_hex()
|
|
split["config_entry_id"] = config_entry_id
|
|
split["config_subentry_id"] = subentry_id
|
|
split["domain"] = None
|
|
deleted_devices.append(split)
|
|
old_data["deleted_devices"] = deleted_devices
|
|
# config_entries and config_entries_subentries are deprecated; v3 stores only
|
|
# the singular config_entry_id / config_subentry_id (single-entry devices kept
|
|
# the old keys, splits copied them via deepcopy).
|
|
for migrated in (*devices, *deleted_devices):
|
|
migrated.pop("config_entries", None)
|
|
migrated.pop("config_entries_subentries", None)
|
|
|
|
if old_major_version < 3 or (old_major_version == 3 and old_minor_version < 2):
|
|
# Version 3.2, introduced in 2026.8, rewrites via_device_id links that do
|
|
# not reference a live device. A link to a composite parent split by the
|
|
# version 3 migration is remapped to one of the splits; any other stale
|
|
# link is detached.
|
|
device_ids = {device["id"] for device in old_data["devices"]}
|
|
# old composite id -> {config entry id -> split id}
|
|
composite_splits: dict[str, dict[str, str]] = {}
|
|
for device in old_data["devices"]:
|
|
if (composite_id := device["composite_device_id"]) is not None:
|
|
composite_splits.setdefault(composite_id, {})[
|
|
device["config_entry_id"]
|
|
] = device["id"]
|
|
|
|
def _split_for_via_device(
|
|
config_entry_id: str, splits: dict[str, str]
|
|
) -> str:
|
|
"""Pick the split for via device: same entry, same domain, any."""
|
|
if (split_id := splits.get(config_entry_id)) is not None:
|
|
return split_id
|
|
config_entries = self.hass.config_entries
|
|
self_entry = config_entries.async_get_entry(config_entry_id)
|
|
if self_entry is not None:
|
|
for split_entry_id, split_id in splits.items():
|
|
split_entry = config_entries.async_get_entry(split_entry_id)
|
|
if (
|
|
split_entry is not None
|
|
and split_entry.domain == self_entry.domain
|
|
):
|
|
return split_id
|
|
return next(iter(splits.values()))
|
|
|
|
stale_via_devices = [
|
|
device
|
|
for device in old_data["devices"]
|
|
if device["via_device_id"] is not None
|
|
and device["via_device_id"] not in device_ids
|
|
]
|
|
# The domain rung of the split resolution needs the config entries, which
|
|
# load concurrently, so wait for them only when a link must be remapped
|
|
if any(
|
|
device["via_device_id"] in composite_splits
|
|
for device in stale_via_devices
|
|
):
|
|
await self.hass.config_entries.async_wait_initialized()
|
|
for device in stale_via_devices:
|
|
if (
|
|
splits := composite_splits.get(device["via_device_id"])
|
|
) is not None:
|
|
device["via_device_id"] = _split_for_via_device(
|
|
device["config_entry_id"], splits
|
|
)
|
|
else:
|
|
device["via_device_id"] = None
|
|
|
|
if old_major_version > 3:
|
|
raise NotImplementedError
|
|
return old_data
|
|
|
|
async def _async_backup_store(self) -> None:
|
|
"""Copy the store file to a timestamped backup before migrating."""
|
|
source = self.path
|
|
backup = f"{source}.{utcnow().strftime('%Y%m%d_%H%M%S')}.migration_backup"
|
|
try:
|
|
copied = await self.hass.async_add_executor_job(
|
|
_copy_if_exists, source, backup
|
|
)
|
|
except OSError as err:
|
|
_LOGGER.warning("Could not back up %s before migration: %s", source, err)
|
|
else:
|
|
if copied:
|
|
_LOGGER.info("Backed up %s to %s before migration", source, backup)
|
|
|
|
|
|
class _CollidingKeys(NamedTuple):
|
|
"""Identifiers and connections shared with a colliding device."""
|
|
|
|
identifiers: set[tuple[str, str]]
|
|
connections: set[tuple[str, str]]
|
|
|
|
|
|
class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)](
|
|
BaseRegistryItems[_EntryTypeT]
|
|
):
|
|
"""Container for device registry items, maps device id -> entry.
|
|
|
|
Maintains two additional indexes. An identifier or connection can be shared by
|
|
several devices, each belonging to a different config entry, so each index maps a
|
|
connection or identifier to the devices that have it, keyed by config entry id:
|
|
- (connection_type, connection identifier) -> {config_entry_id: entry}
|
|
- (DOMAIN, identifier) -> {config_entry_id: entry}
|
|
|
|
Registry bugs used to allow duplicate keys within a config entry, so old stores
|
|
can hold them. Only the last indexed device occupies the slot (matching historic
|
|
lookup behavior); the others are recorded as shadowed until reconciled:
|
|
- (config_entry_id, (connection_type, connection identifier)) -> {device_id}
|
|
- (config_entry_id, (DOMAIN, identifier)) -> {device_id}
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the container."""
|
|
super().__init__()
|
|
self._connections: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {}
|
|
self._identifiers: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {}
|
|
self._shadowed_connections: dict[
|
|
tuple[str | None, tuple[str, str]], set[str]
|
|
] = {}
|
|
self._shadowed_identifiers: dict[
|
|
tuple[str | None, tuple[str, str]], set[str]
|
|
] = {}
|
|
|
|
@override
|
|
def _index_entry(self, key: str, entry: _EntryTypeT) -> None:
|
|
"""Index an entry."""
|
|
for connection in entry.connections:
|
|
self._index_key(
|
|
connection,
|
|
entry,
|
|
self._connections,
|
|
self._shadowed_connections,
|
|
)
|
|
for identifier in entry.identifiers:
|
|
self._index_key(
|
|
identifier,
|
|
entry,
|
|
self._identifiers,
|
|
self._shadowed_identifiers,
|
|
)
|
|
|
|
def _index_key(
|
|
self,
|
|
key: tuple[str, str],
|
|
new_device: _EntryTypeT,
|
|
index: dict[tuple[str, str], dict[str | None, _EntryTypeT]],
|
|
shadowed_index: dict[tuple[str | None, tuple[str, str]], set[str]],
|
|
) -> None:
|
|
"""Index one key, recording a displaced device as shadowed."""
|
|
by_config_entry = index.setdefault(key, {})
|
|
config_entry_id = new_device.config_entry_id
|
|
if (
|
|
existing := by_config_entry.get(config_entry_id)
|
|
) is not None and existing.id != new_device.id:
|
|
shadowed_index.setdefault((config_entry_id, key), set()).add(existing.id)
|
|
by_config_entry[config_entry_id] = new_device
|
|
|
|
@override
|
|
def _unindex_entry(
|
|
self, key: str, replacement_entry: _EntryTypeT | None = None
|
|
) -> None:
|
|
"""Unindex an entry."""
|
|
old_device = self.data[key]
|
|
for connection in old_device.connections:
|
|
self._unindex_key(
|
|
connection,
|
|
old_device,
|
|
self._connections,
|
|
self._shadowed_connections,
|
|
)
|
|
for identifier in old_device.identifiers:
|
|
self._unindex_key(
|
|
identifier,
|
|
old_device,
|
|
self._identifiers,
|
|
self._shadowed_identifiers,
|
|
)
|
|
|
|
def _unindex_key(
|
|
self,
|
|
key: tuple[str, str],
|
|
old_device: _EntryTypeT,
|
|
index: dict[tuple[str, str], dict[str | None, _EntryTypeT]],
|
|
shadowed_index: dict[tuple[str | None, tuple[str, str]], set[str]],
|
|
) -> None:
|
|
"""Unindex one key, promoting a shadowed device into the slot."""
|
|
by_config_entry = index[key]
|
|
config_entry_id = old_device.config_entry_id
|
|
shadow_key = (config_entry_id, key)
|
|
shadowed_ids = shadowed_index.get(shadow_key)
|
|
|
|
if by_config_entry[config_entry_id] is old_device:
|
|
if shadowed_ids:
|
|
by_config_entry[config_entry_id] = self.data[shadowed_ids.pop()]
|
|
else:
|
|
del by_config_entry[config_entry_id]
|
|
if not by_config_entry:
|
|
del index[key]
|
|
else:
|
|
# Not the slot holder, so it must be shadowed
|
|
assert shadowed_ids is not None
|
|
shadowed_ids.remove(old_device.id)
|
|
|
|
if shadowed_ids is not None and not shadowed_ids:
|
|
del shadowed_index[shadow_key]
|
|
|
|
def get_entry(
|
|
self,
|
|
identifiers: set[tuple[str, str]] | None = None,
|
|
connections: set[tuple[str, str]] | None = None,
|
|
*,
|
|
config_entry_id: str | UndefinedType | None = UNDEFINED,
|
|
) -> _EntryTypeT | None:
|
|
"""Get the first entry matching identifiers or connections.
|
|
|
|
If config_entry_id is given, only an entry belonging to that config entry is
|
|
returned. Otherwise the first matching entry from any config entry is returned.
|
|
"""
|
|
if identifiers:
|
|
for identifier in identifiers:
|
|
if (by_config_entry := self._identifiers.get(identifier)) is not None:
|
|
if config_entry_id is UNDEFINED:
|
|
return next(iter(by_config_entry.values()))
|
|
if config_entry_id in by_config_entry:
|
|
return by_config_entry[config_entry_id]
|
|
if not connections:
|
|
return None
|
|
for connection in _normalize_connections(connections):
|
|
if (by_config_entry := self._connections.get(connection)) is not None:
|
|
if config_entry_id is UNDEFINED:
|
|
return next(iter(by_config_entry.values()))
|
|
if config_entry_id in by_config_entry:
|
|
return by_config_entry[config_entry_id]
|
|
return None
|
|
|
|
def get_entries(
|
|
self,
|
|
identifiers: AbstractSet[tuple[str, str]] | None = None,
|
|
connections: AbstractSet[tuple[str, str]] | None = None,
|
|
*,
|
|
config_entry_id: str | None = None,
|
|
) -> list[_EntryTypeT]:
|
|
"""Get all entries matching identifiers or connections.
|
|
|
|
Matches across all config entries, or only within one if config_entry_id
|
|
is given.
|
|
"""
|
|
entries: dict[str, _EntryTypeT] = {}
|
|
if identifiers:
|
|
for identifier in identifiers:
|
|
if (by_config_entry := self._identifiers.get(identifier)) is not None:
|
|
if config_entry_id is None:
|
|
for entry in by_config_entry.values():
|
|
entries[entry.id] = entry
|
|
elif (scoped := by_config_entry.get(config_entry_id)) is not None:
|
|
entries[scoped.id] = scoped
|
|
if connections:
|
|
for connection in _normalize_connections(connections):
|
|
if (by_config_entry := self._connections.get(connection)) is not None:
|
|
if config_entry_id is None:
|
|
for entry in by_config_entry.values():
|
|
entries[entry.id] = entry
|
|
elif (scoped := by_config_entry.get(config_entry_id)) is not None:
|
|
entries[scoped.id] = scoped
|
|
return list(entries.values())
|
|
|
|
def get_colliding_device_ids(
|
|
self,
|
|
identifiers: set[tuple[str, str]],
|
|
connections: set[tuple[str, str]],
|
|
*,
|
|
config_entry_id: str,
|
|
exclude_device_id: str | None,
|
|
) -> dict[str, _CollidingKeys]:
|
|
"""Get the ids of other same-config-entry devices holding the given keys.
|
|
|
|
Returns a map from the id of each colliding device to the identifiers and
|
|
connections it shares with the given ones. Includes devices shadowed in the
|
|
index. connections must be normalized.
|
|
"""
|
|
colliding: dict[str, _CollidingKeys] = {}
|
|
for identifier in identifiers:
|
|
for holder_id in self._holder_device_ids(
|
|
identifier,
|
|
config_entry_id,
|
|
self._identifiers,
|
|
self._shadowed_identifiers,
|
|
):
|
|
if holder_id != exclude_device_id:
|
|
colliding.setdefault(
|
|
holder_id, _CollidingKeys(set(), set())
|
|
).identifiers.add(identifier)
|
|
for connection in connections:
|
|
for holder_id in self._holder_device_ids(
|
|
connection,
|
|
config_entry_id,
|
|
self._connections,
|
|
self._shadowed_connections,
|
|
):
|
|
if holder_id != exclude_device_id:
|
|
colliding.setdefault(
|
|
holder_id, _CollidingKeys(set(), set())
|
|
).connections.add(connection)
|
|
return colliding
|
|
|
|
def _holder_device_ids(
|
|
self,
|
|
key: tuple[str, str],
|
|
config_entry_id: str,
|
|
index: dict[tuple[str, str], dict[str | None, _EntryTypeT]],
|
|
shadowed_index: dict[tuple[str | None, tuple[str, str]], set[str]],
|
|
) -> list[str]:
|
|
"""Get a list of ids of the config entry's devices holding a key."""
|
|
holder_device_ids: list[str] = []
|
|
if (by_config_entry := index.get(key)) is not None and (
|
|
slot_holder := by_config_entry.get(config_entry_id)
|
|
) is not None:
|
|
holder_device_ids.append(slot_holder.id)
|
|
holder_device_ids.extend(shadowed_index.get((config_entry_id, key), ()))
|
|
return holder_device_ids
|
|
|
|
def count_shadowed_keys(self) -> int:
|
|
"""Count keys registered to multiple devices of one config entry."""
|
|
return sum(
|
|
len(device_ids)
|
|
for shadowed_index in (
|
|
self._shadowed_connections,
|
|
self._shadowed_identifiers,
|
|
)
|
|
for device_ids in shadowed_index.values()
|
|
)
|
|
|
|
|
|
class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]):
|
|
"""Container for active (non-deleted) device registry entries."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the container.
|
|
|
|
Maintains four additional indexes:
|
|
|
|
- area_id -> dict[key, True]
|
|
- config_entry_id -> dict[key, True]
|
|
- label -> dict[key, True]
|
|
- composite_device_id -> dict[key, True]
|
|
"""
|
|
super().__init__()
|
|
self._area_id_index: RegistryIndexType = defaultdict(dict)
|
|
self._config_entry_id_index: RegistryIndexType = defaultdict(dict)
|
|
self._labels_index: RegistryIndexType = defaultdict(dict)
|
|
self._composite_device_id_index: RegistryIndexType = defaultdict(dict)
|
|
|
|
@override
|
|
def _index_entry(self, key: str, entry: DeviceEntry) -> None:
|
|
"""Index an entry."""
|
|
super()._index_entry(key, entry)
|
|
if (area_id := entry.area_id) is not None:
|
|
self._area_id_index[area_id][key] = True
|
|
for label in entry.labels:
|
|
self._labels_index[label][key] = True
|
|
self._config_entry_id_index[entry.config_entry_id][key] = True
|
|
if entry.composite_device_id is not None:
|
|
self._composite_device_id_index[entry.composite_device_id][key] = True
|
|
|
|
@override
|
|
def _unindex_entry(
|
|
self, key: str, replacement_entry: DeviceEntry | None = None
|
|
) -> None:
|
|
"""Unindex an entry."""
|
|
entry = self.data[key]
|
|
if area_id := entry.area_id:
|
|
self._unindex_entry_value(key, area_id, self._area_id_index)
|
|
if labels := entry.labels:
|
|
for label in labels:
|
|
self._unindex_entry_value(key, label, self._labels_index)
|
|
self._unindex_entry_value(
|
|
key, entry.config_entry_id, self._config_entry_id_index
|
|
)
|
|
if entry.composite_device_id is not None:
|
|
self._unindex_entry_value(
|
|
key, entry.composite_device_id, self._composite_device_id_index
|
|
)
|
|
super()._unindex_entry(key, replacement_entry)
|
|
|
|
def get_devices_for_area_id(self, area_id: str) -> list[DeviceEntry]:
|
|
"""Get devices for area."""
|
|
data = self.data
|
|
return [data[key] for key in self._area_id_index.get(area_id, ())]
|
|
|
|
def get_devices_for_label(self, label: str) -> list[DeviceEntry]:
|
|
"""Get devices for label."""
|
|
data = self.data
|
|
return [data[key] for key in self._labels_index.get(label, ())]
|
|
|
|
def get_devices_for_config_entry_id(
|
|
self, config_entry_id: str
|
|
) -> list[DeviceEntry]:
|
|
"""Get devices for config entry."""
|
|
data = self.data
|
|
return [
|
|
data[key] for key in self._config_entry_id_index.get(config_entry_id, ())
|
|
]
|
|
|
|
def get_devices_for_composite_device_id(
|
|
self, composite_device_id: str
|
|
) -> list[DeviceEntry]:
|
|
"""Get the devices a pre-migration composite device was split into."""
|
|
data = self.data
|
|
return [
|
|
data[key]
|
|
for key in self._composite_device_id_index.get(composite_device_id, ())
|
|
]
|
|
|
|
def get_composite_splits(self) -> dict[str, list[DeviceEntry]]:
|
|
"""Get the pre-migration composite device ids and the devices split from them."""
|
|
data = self.data
|
|
return {
|
|
composite_device_id: [data[key] for key in keys]
|
|
for composite_device_id, keys in self._composite_device_id_index.items()
|
|
}
|
|
|
|
|
|
class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]):
|
|
"""Container for deleted device registry entries.
|
|
|
|
A deleted device that still belongs to a config entry is indexed by config entry id in
|
|
the base class, like an active device. An orphaned deleted device (its config entry
|
|
removed) has no config entry id and would collide with every other orphan in the base
|
|
config_entry_id=None slot, so orphans are kept out of the base index and tracked in a
|
|
separate index keyed by device id, which is unique so orphans never shadow each other.
|
|
Orphans are matched on restore by get_orphaned_entry.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the container."""
|
|
super().__init__()
|
|
self._orphaned_connections: dict[
|
|
tuple[str, str], dict[str, DeletedDeviceEntry]
|
|
] = {}
|
|
self._orphaned_identifiers: dict[
|
|
tuple[str, str], dict[str, DeletedDeviceEntry]
|
|
] = {}
|
|
|
|
@override
|
|
def _index_entry(self, key: str, entry: DeletedDeviceEntry) -> None:
|
|
"""Index an entry, keeping orphans in the separate id-keyed index."""
|
|
if entry.config_entry_id is not None:
|
|
super()._index_entry(key, entry)
|
|
return
|
|
for connection in entry.connections:
|
|
self._orphaned_connections.setdefault(connection, {})[entry.id] = entry
|
|
for identifier in entry.identifiers:
|
|
self._orphaned_identifiers.setdefault(identifier, {})[entry.id] = entry
|
|
|
|
@override
|
|
def _unindex_entry(
|
|
self, key: str, replacement_entry: DeletedDeviceEntry | None = None
|
|
) -> None:
|
|
"""Unindex an entry from the base or the orphan index."""
|
|
entry = self.data[key]
|
|
if entry.config_entry_id is not None:
|
|
super()._unindex_entry(key, replacement_entry)
|
|
return
|
|
for connection in entry.connections:
|
|
if connection in self._orphaned_connections:
|
|
del self._orphaned_connections[connection][entry.id]
|
|
if not self._orphaned_connections[connection]:
|
|
del self._orphaned_connections[connection]
|
|
for identifier in entry.identifiers:
|
|
if identifier in self._orphaned_identifiers:
|
|
del self._orphaned_identifiers[identifier][entry.id]
|
|
if not self._orphaned_identifiers[identifier]:
|
|
del self._orphaned_identifiers[identifier]
|
|
|
|
def get_orphaned_entry(
|
|
self,
|
|
identifiers: set[tuple[str, str]] | None,
|
|
connections: set[tuple[str, str]] | None,
|
|
domain: str,
|
|
) -> DeletedDeviceEntry | None:
|
|
"""Return an orphan of the given domain to restore.
|
|
|
|
Orphans are matched on their recorded domain so a chance identifier or connection
|
|
collision doesn't restore another integration's device. A domain-less orphan
|
|
(carried over by the migration with no recoverable domain) is left for the
|
|
periodic purge rather than restored.
|
|
"""
|
|
orphans: dict[str, DeletedDeviceEntry] = {}
|
|
for identifier in identifiers or ():
|
|
orphans.update(self._orphaned_identifiers.get(identifier, {}))
|
|
for connection in _normalize_connections(connections or set()):
|
|
orphans.update(self._orphaned_connections.get(connection, {}))
|
|
for entry in orphans.values():
|
|
if entry.domain == domain:
|
|
return entry
|
|
return None
|
|
|
|
|
|
class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
|
|
"""Class to hold a registry of devices."""
|
|
|
|
devices: ActiveDeviceRegistryItems
|
|
deleted_devices: DeletedDeviceRegistryItems
|
|
_device_data: dict[str, DeviceEntry]
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize the device registry."""
|
|
self.hass = hass
|
|
# Devices registered through async_get_or_create in the current setup session
|
|
# of their config entry, keyed by config entry id. A key collision with one of
|
|
# these raises; one with a not yet registered device is reconciled.
|
|
self._live_device_ids: dict[str, set[str]] = {}
|
|
self._loaded_event = asyncio.Event()
|
|
self._store = DeviceRegistryStore(
|
|
hass,
|
|
STORAGE_VERSION_MAJOR,
|
|
STORAGE_KEY,
|
|
atomic_writes=True,
|
|
minor_version=STORAGE_VERSION_MINOR,
|
|
serialize_in_event_loop=False,
|
|
)
|
|
|
|
@callback
|
|
def async_get(self, device_id: str) -> DeviceEntry | None:
|
|
"""Get device.
|
|
|
|
We retrieve the DeviceEntry from the underlying dict to avoid
|
|
the overhead of the UserDict __getitem__.
|
|
|
|
For a pre-migration composite device id, a read-only composite device
|
|
merged from the split devices is returned, so integration code that resolves a
|
|
device by id (e.g. in a service handler) keeps working. The composite is
|
|
synthesized on demand and never stored, so it stays invisible to enumeration,
|
|
identifier search and the frontend device list.
|
|
"""
|
|
if (device := self._device_data.get(device_id)) is not None:
|
|
return device
|
|
if split_devices := self.devices.get_devices_for_composite_device_id(device_id):
|
|
return self._restore_composite_device(device_id, split_devices)
|
|
return None
|
|
|
|
@callback
|
|
def _restore_composite_device(
|
|
self, device_id: str, split_devices: list[DeviceEntry]
|
|
) -> DeviceEntry:
|
|
"""Synthesize a read-only composite device from its split devices."""
|
|
composite_subentries: dict[str, set[str | None]] = {}
|
|
identifiers: set[tuple[str, str]] = set()
|
|
connections: set[tuple[str, str]] = set()
|
|
for split_device in split_devices:
|
|
composite_subentries.setdefault(split_device.config_entry_id, set()).add(
|
|
split_device.config_subentry_id
|
|
)
|
|
identifiers |= split_device.identifiers
|
|
connections |= split_device.connections
|
|
# Functional identity (identifiers, connections, serial_number) is consistent
|
|
# across splits of the same physical device. Use the split owning the composite's
|
|
# former primary config entry as the base, so config_entry_id - and thus
|
|
# primary_config_entry - reports the composite's former primary.
|
|
primary_config_entry = split_devices[0].composite_primary_config_entry
|
|
base = next(
|
|
(
|
|
split_device
|
|
for split_device in split_devices
|
|
if split_device.config_entry_id == primary_config_entry
|
|
),
|
|
split_devices[0],
|
|
)
|
|
return attr.evolve(
|
|
base,
|
|
composite_subentries=composite_subentries,
|
|
connections=connections, # type: ignore[arg-type]
|
|
id=device_id,
|
|
identifiers=identifiers, # type: ignore[arg-type]
|
|
)
|
|
|
|
@callback
|
|
def async_get_device(
|
|
self,
|
|
identifiers: set[tuple[str, str]] | None = None,
|
|
connections: set[tuple[str, str]] | None = None,
|
|
) -> DeviceEntry | None:
|
|
"""Check if a device is registered.
|
|
|
|
Identifiers and connections are unique per config entry. If several config
|
|
entries share the looked-up identifier or connection, the match is resolved to a
|
|
single device when possible - preferring the device whose config entry domain
|
|
matches the looked-up identifier. If the remaining matches are the splits of one
|
|
pre-migration composite device, a read-only composite spanning them is returned
|
|
(async_update_device and async_remove_device fan it out to the underlying
|
|
devices). Otherwise, for independent devices sharing an identifier or connection,
|
|
one owned by the calling integration is preferred, falling back to the first
|
|
match.
|
|
"""
|
|
matches = self._async_matching_devices(identifiers, connections)
|
|
if len(matches) <= 1:
|
|
return matches[0] if matches else None
|
|
# If the matches are the splits of one pre-migration composite device, return a
|
|
# read-only composite over them, reusing the composite's id so stored references
|
|
# (an automation, a fired event, or an entity holding the old device id) keep
|
|
# resolving to it as before the split.
|
|
composite_device_ids = {match.composite_device_id for match in matches}
|
|
if (
|
|
len(composite_device_ids) == 1
|
|
and (pre_migration_id := next(iter(composite_device_ids))) is not None
|
|
):
|
|
return self._restore_composite_device(pre_migration_id, matches)
|
|
# Otherwise they are independent devices sharing an identifier or connection.
|
|
# Prefer one owned by the calling integration so the caller resolves to its own
|
|
# device rather than an insertion-order-dependent one; fall back to the first.
|
|
if (domain := _current_integration_domain()) is not None and (
|
|
device := self._first_device_in_domain(matches, domain)
|
|
) is not None:
|
|
return device
|
|
return matches[0]
|
|
|
|
@callback
|
|
def async_get_device_by_identifier(
|
|
self, identifier: tuple[str, str], config_entry_id: str
|
|
) -> DeviceEntry | None:
|
|
"""Get the device with the identifier, owned by the config entry.
|
|
|
|
Identifiers are unique within a config entry, so unlike async_get_device
|
|
the lookup cannot be ambiguous.
|
|
"""
|
|
return self.devices.get_entry(
|
|
identifiers={identifier}, config_entry_id=config_entry_id
|
|
)
|
|
|
|
@callback
|
|
def async_get_device_by_connection(
|
|
self, connection: tuple[str, str], config_entry_id: str
|
|
) -> DeviceEntry | None:
|
|
"""Get the device with the connection, owned by the config entry.
|
|
|
|
Connections are unique within a config entry, so unlike async_get_device
|
|
the lookup cannot be ambiguous.
|
|
"""
|
|
return self.devices.get_entry(
|
|
connections={connection}, config_entry_id=config_entry_id
|
|
)
|
|
|
|
@callback
|
|
def async_get_devices(
|
|
self,
|
|
*,
|
|
identifiers: set[tuple[str, str]] | None = None,
|
|
connections: set[tuple[str, str]] | None = None,
|
|
config_entry_id: str | None = None,
|
|
) -> list[DeviceEntry]:
|
|
"""Get all devices matching any of the identifiers or connections.
|
|
|
|
If config_entry_id is given, only devices owned by that config entry are
|
|
returned.
|
|
"""
|
|
return self.devices.get_entries(
|
|
identifiers, connections, config_entry_id=config_entry_id
|
|
)
|
|
|
|
def _first_device_in_domain(
|
|
self, devices: Iterable[DeviceEntry], domain: str
|
|
) -> DeviceEntry | None:
|
|
"""Return the first device whose config entry belongs to domain."""
|
|
for device in devices:
|
|
entry = self.hass.config_entries.async_get_entry(device.config_entry_id)
|
|
if entry is not None and entry.domain == domain:
|
|
return device
|
|
return None
|
|
|
|
@callback
|
|
def _async_matching_devices(
|
|
self,
|
|
identifiers: AbstractSet[tuple[str, str]] | None,
|
|
connections: AbstractSet[tuple[str, str]] | None,
|
|
) -> list[DeviceEntry]:
|
|
"""Return devices matching the lookup, narrowed by identifier-domain priority."""
|
|
matches = self.devices.get_entries(identifiers, connections)
|
|
if len(matches) > 1 and identifiers:
|
|
domains = {identifier[0] for identifier in identifiers}
|
|
preferred = [
|
|
device
|
|
for device in matches
|
|
if (
|
|
entry := self.hass.config_entries.async_get_entry(
|
|
device.config_entry_id
|
|
)
|
|
)
|
|
and entry.domain in domains
|
|
]
|
|
if preferred:
|
|
return preferred
|
|
return matches
|
|
|
|
@callback
|
|
def _async_device_ids_for_composite_device_id(
|
|
self, device_id: str
|
|
) -> list[str] | None:
|
|
"""Return the underlying real device ids if device_id is a composite."""
|
|
if device_id in self.devices:
|
|
return None
|
|
if split_devices := self.devices.get_devices_for_composite_device_id(device_id):
|
|
return [split_device.id for split_device in split_devices]
|
|
return None
|
|
|
|
@callback
|
|
def async_get_devices_for_composite_device_id(
|
|
self, composite_device_id: str
|
|
) -> list[DeviceEntry]:
|
|
"""Return the devices a composite device id represents.
|
|
|
|
A composite device id is a pre-migration composite id - a device that belonged to
|
|
several config entries, split into one device per config entry, each keeping the
|
|
original id as composite_device_id. The underlying live devices are returned so
|
|
that actions and entity lookups targeting the composite id still reach all of
|
|
them; unmodified integrations keep the pre-rewrite behaviour, where a shared
|
|
identifier/connection resolved to a single multi-config-entry device. Returns an
|
|
empty list for a device id which is not a composite device id.
|
|
"""
|
|
return self.devices.get_devices_for_composite_device_id(composite_device_id)
|
|
|
|
@callback
|
|
def async_is_composite_device_id(self, device_id: str) -> bool | None:
|
|
"""Return True if device_id is a pre-migration composite device id.
|
|
|
|
A composite device was split into one device per config entry; the
|
|
composite device id no longer refers to a registered device. Returns
|
|
False for a registered device id, and None for an unknown id.
|
|
"""
|
|
if device_id in self.devices:
|
|
return False
|
|
if self.devices.get_devices_for_composite_device_id(device_id):
|
|
return True
|
|
return None
|
|
|
|
@callback
|
|
def _resolve_via_device_id(
|
|
self, via_device_id: str, config_entry_id: str
|
|
) -> str | None:
|
|
"""Resolve a via_device_id to the id of a registered device.
|
|
|
|
The id of a pre-migration composite device is resolved to one of the devices
|
|
it was split into - preferring the split owned by config_entry_id, then one
|
|
owned by the same domain, then any of them. Returns None for an unknown id.
|
|
"""
|
|
if via_device_id in self.devices:
|
|
return via_device_id
|
|
if splits := self.devices.get_devices_for_composite_device_id(via_device_id):
|
|
# The composite resolution can be removed in HA Core 2027.8
|
|
report_usage(
|
|
f"passes the id of a pre-migration composite device {via_device_id} "
|
|
"as `via_device_id`; pass the id of a single device instead, e.g. "
|
|
"one returned by async_get_device_by_identifier",
|
|
core_behavior=ReportBehavior.LOG,
|
|
breaks_in_ha_version="2027.8",
|
|
)
|
|
for split in splits:
|
|
if split.config_entry_id == config_entry_id:
|
|
return split.id
|
|
if (
|
|
config_entry := self.hass.config_entries.async_get_entry(
|
|
config_entry_id
|
|
)
|
|
) is not None and (
|
|
split_in_domain := self._first_device_in_domain(
|
|
splits, config_entry.domain
|
|
)
|
|
) is not None:
|
|
return split_in_domain.id
|
|
return splits[0].id
|
|
return None
|
|
|
|
def _substitute_name_placeholders(
|
|
self,
|
|
domain: str,
|
|
name: str,
|
|
translation_placeholders: Mapping[str, str],
|
|
) -> str:
|
|
"""Substitute placeholders in entity name."""
|
|
try:
|
|
return name.format(**translation_placeholders)
|
|
except KeyError as err:
|
|
if get_release_channel() is not ReleaseChannel.STABLE:
|
|
raise HomeAssistantError(f"Missing placeholder {err}") from err
|
|
report_issue = async_suggest_report_issue(
|
|
self.hass, integration_domain=domain
|
|
)
|
|
_LOGGER.warning(
|
|
(
|
|
"Device from integration %s has translation placeholders '%s' "
|
|
"which do not match the name '%s', please %s"
|
|
),
|
|
domain,
|
|
translation_placeholders,
|
|
name,
|
|
report_issue,
|
|
)
|
|
return name
|
|
|
|
@callback
|
|
def async_get_or_create(
|
|
self,
|
|
*,
|
|
config_entry_id: str,
|
|
config_subentry_id: str | UndefinedType | None = UNDEFINED,
|
|
configuration_url: str | URL | UndefinedType | None = UNDEFINED,
|
|
connections: set[tuple[str, str]] | UndefinedType | None = UNDEFINED,
|
|
created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored
|
|
default_manufacturer: str | UndefinedType | None = UNDEFINED,
|
|
default_model: str | UndefinedType | None = UNDEFINED,
|
|
default_name: str | UndefinedType | None = UNDEFINED,
|
|
# To disable a device if it gets created, does not affect existing devices
|
|
disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED,
|
|
entry_type: DeviceEntryType | UndefinedType | None = UNDEFINED,
|
|
hw_version: str | UndefinedType | None = UNDEFINED,
|
|
identifiers: set[tuple[str, str]] | UndefinedType | None = UNDEFINED,
|
|
manufacturer: str | UndefinedType | None = UNDEFINED,
|
|
model: str | UndefinedType | None = UNDEFINED,
|
|
model_id: str | UndefinedType | None = UNDEFINED,
|
|
modified_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored
|
|
name: str | UndefinedType | None = UNDEFINED,
|
|
serial_number: str | UndefinedType | None = UNDEFINED,
|
|
suggested_area: str | UndefinedType | None = UNDEFINED,
|
|
sw_version: str | UndefinedType | None = UNDEFINED,
|
|
translation_key: str | None = None,
|
|
translation_placeholders: Mapping[str, str] | None = None,
|
|
# via_device is deprecated and will be removed in HA Core 2027.8, use
|
|
# via_device_id instead
|
|
via_device: tuple[str, str] | UndefinedType | None = UNDEFINED,
|
|
via_device_id: str | UndefinedType | None = UNDEFINED,
|
|
) -> DeviceEntry:
|
|
"""Get device. Create if it doesn't exist."""
|
|
default_manufacturer = _validate_str(
|
|
"default_manufacturer", default_manufacturer
|
|
)
|
|
default_model = _validate_str("default_model", default_model)
|
|
validated_fields = _validate_device_info_fields(
|
|
configuration_url=configuration_url,
|
|
hw_version=hw_version,
|
|
manufacturer=manufacturer,
|
|
model=model,
|
|
model_id=model_id,
|
|
serial_number=serial_number,
|
|
sw_version=sw_version,
|
|
)
|
|
|
|
config_entry = self.hass.config_entries.async_get_entry(config_entry_id)
|
|
if config_entry is None:
|
|
raise HomeAssistantError(
|
|
f"Can't link device to unknown config entry {config_entry_id}"
|
|
)
|
|
|
|
# Validate before mutating the registry below. `via_device=None` (an explicit
|
|
# "no via device") alongside a via_device_id is contradictory, so reject it too.
|
|
if via_device is not UNDEFINED and via_device_id is not UNDEFINED:
|
|
raise HomeAssistantError(
|
|
"Passing both `via_device` and `via_device_id` is not allowed; "
|
|
"`via_device` is deprecated, pass `via_device_id` only"
|
|
)
|
|
if (
|
|
config_subentry_id is not UNDEFINED
|
|
and config_subentry_id is not None
|
|
and config_subentry_id not in config_entry.subentries
|
|
):
|
|
raise HomeAssistantError(
|
|
f"Config entry {config_entry_id} has no subentry {config_subentry_id}"
|
|
)
|
|
|
|
if translation_key:
|
|
full_translation_key = (
|
|
f"component.{config_entry.domain}.device.{translation_key}.name"
|
|
)
|
|
translations = translation.async_get_cached_translations(
|
|
self.hass, self.hass.config.language, "device", config_entry.domain
|
|
)
|
|
translated_name = translations.get(full_translation_key, translation_key)
|
|
name = self._substitute_name_placeholders(
|
|
config_entry.domain, translated_name, translation_placeholders or {}
|
|
)
|
|
|
|
# Reconstruct a DeviceInfo dict from the arguments.
|
|
# When we upgrade to Python 3.12, we can change this method to instead
|
|
# accept kwargs typed as a DeviceInfo dict (PEP 692)
|
|
device_info: DeviceInfo = { # type: ignore[assignment]
|
|
key: val
|
|
for key, val in (
|
|
("connections", connections),
|
|
("default_manufacturer", default_manufacturer),
|
|
("default_model", default_model),
|
|
("default_name", default_name),
|
|
("entry_type", entry_type),
|
|
("identifiers", identifiers),
|
|
("name", name),
|
|
("suggested_area", suggested_area),
|
|
("via_device", via_device),
|
|
("via_device_id", via_device_id),
|
|
*validated_fields.items(),
|
|
)
|
|
if val is not UNDEFINED
|
|
}
|
|
|
|
device_info_type = _determine_device_info_type(config_entry, device_info)
|
|
|
|
if identifiers is None or identifiers is UNDEFINED:
|
|
identifiers = set()
|
|
|
|
if connections is None or connections is UNDEFINED:
|
|
connections = set()
|
|
else:
|
|
connections = _normalize_connections(connections)
|
|
|
|
device = self.devices.get_entry(
|
|
connections=connections,
|
|
identifiers=identifiers,
|
|
config_entry_id=config_entry_id,
|
|
)
|
|
|
|
self._async_reconcile_collisions(
|
|
device, config_entry, device_info, identifiers, connections
|
|
)
|
|
if device is not None:
|
|
# Reconciliation can update the matched device (e.g. detach its via link)
|
|
device = self.devices[device.id]
|
|
|
|
# Resolved after reconciliation so a removed stale duplicate can't be linked
|
|
if via_device_id is not UNDEFINED and via_device_id is not None:
|
|
resolved_via_device_id = self._resolve_via_device_id(
|
|
via_device_id, config_entry_id
|
|
)
|
|
if resolved_via_device_id is None:
|
|
raise DeviceInfoError(
|
|
config_entry.domain,
|
|
device_info,
|
|
f"via_device_id {via_device_id} is not a registered device id",
|
|
)
|
|
via_device_id = resolved_via_device_id
|
|
|
|
is_new = False
|
|
|
|
if device is None:
|
|
is_new = True
|
|
|
|
deleted_device = self.deleted_devices.get_entry(
|
|
connections=connections,
|
|
identifiers=identifiers,
|
|
config_entry_id=config_entry_id,
|
|
)
|
|
if deleted_device is None:
|
|
# Fall back to an orphan (its owning config entry was removed)
|
|
# so re-adding an integration restores the device id, area, labels and name
|
|
# rather than create a fresh device. Matching on the recorded domain keeps
|
|
# a chance identifier/connection collision from restoring another
|
|
# integration's device.
|
|
deleted_device = self.deleted_devices.get_orphaned_entry(
|
|
identifiers, connections, config_entry.domain
|
|
)
|
|
if deleted_device is None:
|
|
area_id: str | None = None
|
|
if (
|
|
suggested_area is not None
|
|
and suggested_area is not UNDEFINED
|
|
and suggested_area != ""
|
|
):
|
|
# Circular dep
|
|
from . import area_registry as ar # noqa: PLC0415
|
|
|
|
area = ar.async_get(self.hass).async_get_or_create(suggested_area)
|
|
area_id = area.id
|
|
device = DeviceEntry(
|
|
area_id=area_id,
|
|
config_entry_id=config_entry_id,
|
|
# Interpret not specifying a subentry as None
|
|
config_subentry_id=(
|
|
config_subentry_id
|
|
if config_subentry_id is not UNDEFINED
|
|
else None
|
|
),
|
|
)
|
|
|
|
else:
|
|
self.deleted_devices.pop(deleted_device.id)
|
|
device = deleted_device.to_device_entry(
|
|
config_entry,
|
|
# Interpret not specifying a subentry as None
|
|
config_subentry_id if config_subentry_id is not UNDEFINED else None,
|
|
connections,
|
|
identifiers,
|
|
disabled_by,
|
|
)
|
|
disabled_by = UNDEFINED
|
|
|
|
self.devices[device.id] = device
|
|
# If creating a new device, default to the config entry name
|
|
if device_info_type == "primary" and (not name or name is UNDEFINED):
|
|
name = config_entry.title
|
|
|
|
elif (
|
|
config_subentry_id is not UNDEFINED
|
|
and device.config_subentry_id != config_subentry_id
|
|
):
|
|
# A device belongs to a single config subentry. Re-registering an existing
|
|
# device under a different subentry of the same config entry (e.g. entities
|
|
# from several subentries sharing one device_info identity) silently moves
|
|
# it. This is deprecated since moves should be explicit via
|
|
# async_update_device(new_config_subentry_id=...).
|
|
# For now warn and fall through to the move below, but it will raise in HA
|
|
# Core 2027.8.
|
|
report_usage(
|
|
"assigns an existing device to a different config subentry, by calling "
|
|
"`async_get_or_create` or by adding entities from several subentries that "
|
|
"share a device; this silently moves the device. A device belongs to one "
|
|
"subentry - keep a shared device in a single subentry, or move it with "
|
|
"`async_update_device`",
|
|
core_behavior=ReportBehavior.LOG,
|
|
breaks_in_ha_version="2027.8.0",
|
|
)
|
|
|
|
self._async_purge_colliding_deleted_devices(device, identifiers, connections)
|
|
|
|
if default_manufacturer is not UNDEFINED and device.manufacturer is None:
|
|
validated_fields["manufacturer"] = default_manufacturer
|
|
|
|
if default_model is not UNDEFINED and device.model is None:
|
|
validated_fields["model"] = default_model
|
|
|
|
if default_name is not UNDEFINED and device.name is None:
|
|
name = default_name
|
|
|
|
if via_device is not None and via_device is not UNDEFINED:
|
|
# Resolve the deprecated via_device to a device id. The identifier is not
|
|
# unique across config entries, so prefer a via device in the same config
|
|
# entry, then one from the same integration (domain), falling back to any
|
|
# config entry (a via device may legitimately belong to a different config
|
|
# entry). This ambiguity is why via_device is deprecated.
|
|
via = (
|
|
self.devices.get_entry(
|
|
identifiers={via_device}, config_entry_id=config_entry_id
|
|
)
|
|
or self._first_device_in_domain(
|
|
self.devices.get_entries(identifiers={via_device}),
|
|
config_entry.domain,
|
|
)
|
|
or self.devices.get_entry(identifiers={via_device})
|
|
)
|
|
if via is None:
|
|
report_usage(
|
|
"calls `device_registry.async_get_or_create` referencing a "
|
|
f"non existing `via_device` {via_device}, "
|
|
f"with device info: {device_info}",
|
|
core_behavior=ReportBehavior.LOG,
|
|
breaks_in_ha_version="2025.12.0",
|
|
)
|
|
via_device_id = via.id if via else UNDEFINED
|
|
elif via_device is None:
|
|
# An explicit `via_device=None` means "no via device" (a via_device_id
|
|
# alongside it is rejected above).
|
|
via_device_id = None
|
|
|
|
# On the owning integration's first re-registration of a device created by
|
|
# splitting a pre-migration composite device, replace the identifiers and
|
|
# connections copied from the composite with the ones the integration provides,
|
|
# instead of merging. This block and the has_composite_identifiers flag
|
|
# can be removed in HA Core 2027.8.
|
|
identifiers_connections: dict[str, Any]
|
|
has_composite_identifiers: bool | UndefinedType = UNDEFINED
|
|
if device.has_composite_identifiers:
|
|
identifiers_connections = {
|
|
"new_connections": connections,
|
|
"new_identifiers": identifiers,
|
|
}
|
|
has_composite_identifiers = False
|
|
else:
|
|
identifiers_connections = {
|
|
"merge_connections": connections or UNDEFINED,
|
|
"merge_identifiers": identifiers or UNDEFINED,
|
|
}
|
|
|
|
device = self._async_update_device(
|
|
device.id,
|
|
disabled_by=disabled_by,
|
|
entry_type=entry_type,
|
|
is_new=is_new,
|
|
name=name,
|
|
has_composite_identifiers=has_composite_identifiers,
|
|
new_config_subentry_id=config_subentry_id,
|
|
suggested_area=suggested_area,
|
|
via_device_id=via_device_id,
|
|
**identifiers_connections,
|
|
**validated_fields,
|
|
)
|
|
|
|
# This is safe because _async_update_device will always return a device
|
|
# in this use case.
|
|
assert device
|
|
self._live_device_ids.setdefault(device.config_entry_id, set()).add(device.id)
|
|
return device
|
|
|
|
@callback
|
|
def _async_update_device( # noqa: C901
|
|
self,
|
|
device_id: str,
|
|
*,
|
|
add_config_entry_id: str | UndefinedType = UNDEFINED,
|
|
add_config_subentry_id: str | UndefinedType | None = UNDEFINED,
|
|
# Only set when stripping colliding keys from a stale device: its retained
|
|
# keys can still be duplicated in other stale devices and must not validate.
|
|
allow_collisions: bool = False,
|
|
area_id: str | UndefinedType | None = UNDEFINED,
|
|
configuration_url: str | URL | UndefinedType | None = UNDEFINED,
|
|
disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED,
|
|
entry_type: DeviceEntryType | UndefinedType | None = UNDEFINED,
|
|
hw_version: str | UndefinedType | None = UNDEFINED,
|
|
is_new: bool = False,
|
|
labels: set[str] | UndefinedType = UNDEFINED,
|
|
manufacturer: str | UndefinedType | None = UNDEFINED,
|
|
merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
model: str | UndefinedType | None = UNDEFINED,
|
|
model_id: str | UndefinedType | None = UNDEFINED,
|
|
name_by_user: str | UndefinedType | None = UNDEFINED,
|
|
name: str | UndefinedType | None = UNDEFINED,
|
|
# has_composite_identifiers can be removed in HA Core 2027.8
|
|
has_composite_identifiers: bool | UndefinedType = UNDEFINED,
|
|
new_config_entry_id: str | UndefinedType = UNDEFINED,
|
|
new_config_subentry_id: str | UndefinedType | None = UNDEFINED,
|
|
new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
remove_config_entry_id: str | UndefinedType = UNDEFINED,
|
|
remove_config_subentry_id: str | UndefinedType | None = UNDEFINED,
|
|
serial_number: str | UndefinedType | None = UNDEFINED,
|
|
# Can be removed when suggested_area is removed from DeviceEntry
|
|
suggested_area: str | UndefinedType | None = UNDEFINED,
|
|
sw_version: str | UndefinedType | None = UNDEFINED,
|
|
via_device_id: str | UndefinedType | None = UNDEFINED,
|
|
) -> DeviceEntry | None:
|
|
"""Private update device attributes.
|
|
|
|
:param add_config_subentry_id: Add the device to a specific
|
|
subentry of add_config_entry_id
|
|
:param remove_config_subentry_id: Remove the device from a
|
|
specific subentry of remove_config_entry_id
|
|
"""
|
|
old = self.devices[device_id]
|
|
|
|
new_values: dict[str, Any] = {} # Dict with new key/value pairs
|
|
old_values: dict[str, Any] = {} # Dict with old key/value pairs
|
|
|
|
if add_config_entry_id is not UNDEFINED:
|
|
if (
|
|
add_config_entry := self.hass.config_entries.async_get_entry(
|
|
add_config_entry_id
|
|
)
|
|
) is None:
|
|
raise HomeAssistantError(
|
|
f"Can't link device to unknown config entry {add_config_entry_id}"
|
|
)
|
|
|
|
if add_config_subentry_id is not UNDEFINED:
|
|
if add_config_entry_id is UNDEFINED:
|
|
raise HomeAssistantError(
|
|
"Can't add config subentry without specifying config entry"
|
|
)
|
|
if (
|
|
add_config_subentry_id
|
|
# mypy says add_config_entry can be None. That's impossible, because we
|
|
# raise above if that happens
|
|
and add_config_subentry_id not in add_config_entry.subentries # type: ignore[union-attr]
|
|
):
|
|
raise HomeAssistantError(
|
|
f"Config entry {add_config_entry_id} has no"
|
|
f" subentry {add_config_subentry_id}"
|
|
)
|
|
|
|
if (
|
|
remove_config_subentry_id is not UNDEFINED
|
|
and remove_config_entry_id is UNDEFINED
|
|
):
|
|
raise HomeAssistantError(
|
|
"Can't remove config subentry without specifying config entry"
|
|
)
|
|
|
|
if (
|
|
new_config_entry_id is not UNDEFINED
|
|
and self.hass.config_entries.async_get_entry(new_config_entry_id) is None
|
|
):
|
|
raise HomeAssistantError(
|
|
f"Can't move device to unknown config entry {new_config_entry_id}"
|
|
)
|
|
|
|
if (
|
|
new_config_entry_id is not UNDEFINED
|
|
or new_config_subentry_id is not UNDEFINED
|
|
) and (
|
|
add_config_entry_id is not UNDEFINED
|
|
or remove_config_entry_id is not UNDEFINED
|
|
):
|
|
raise HomeAssistantError(
|
|
"Can't combine new_config_entry_id or new_config_subentry_id with "
|
|
"add_config_entry_id or remove_config_entry_id"
|
|
)
|
|
|
|
if not new_connections and not new_identifiers:
|
|
raise HomeAssistantError(
|
|
"A device must have at least one of identifiers or connections"
|
|
)
|
|
|
|
if merge_connections is not UNDEFINED and new_connections is not UNDEFINED:
|
|
raise HomeAssistantError(
|
|
"Cannot define both merge_connections and new_connections"
|
|
)
|
|
|
|
if merge_identifiers is not UNDEFINED and new_identifiers is not UNDEFINED:
|
|
raise HomeAssistantError(
|
|
"Cannot define both merge_identifiers and new_identifiers"
|
|
)
|
|
|
|
if (
|
|
via_device_id is not UNDEFINED
|
|
and via_device_id is not None
|
|
and via_device_id not in self.devices
|
|
and not self.devices.get_devices_for_composite_device_id(via_device_id)
|
|
):
|
|
raise HomeAssistantError(
|
|
f"Can't link device to unknown via device {via_device_id}"
|
|
)
|
|
|
|
# A device belongs to exactly one config entry and subentry:
|
|
# - add_config_entry_id (with an optional add_config_subentry_id) records a
|
|
# transient pending move to that config entry and subentry; on its own it does
|
|
# not move the device. Integrations move a device by adding the new config
|
|
# entry and then removing the current one, often in separate calls; the removal
|
|
# of the current config entry performs the pending move.
|
|
# - remove_config_entry_id on the owning entry performs a pending move if there
|
|
# is one, otherwise it removes the device, since it has no other config entry.
|
|
# - new_config_entry_id / new_config_subentry_id move the device immediately.
|
|
target_config_entry_id: str | UndefinedType = UNDEFINED
|
|
target_config_subentry_id: str | UndefinedType | None = UNDEFINED
|
|
pending_move: _PendingMove | UndefinedType | None = UNDEFINED
|
|
if new_config_entry_id is not UNDEFINED:
|
|
target_config_entry_id = new_config_entry_id
|
|
target_config_subentry_id = (
|
|
new_config_subentry_id
|
|
if new_config_subentry_id is not UNDEFINED
|
|
else None
|
|
)
|
|
# An immediate move to a new config entry supersedes a deferred move from an
|
|
# earlier add_config_entry_id; clear it so a later removal of the new owner
|
|
# deletes the device instead of performing the stale move.
|
|
pending_move = None
|
|
elif new_config_subentry_id is not UNDEFINED:
|
|
target_config_subentry_id = new_config_subentry_id
|
|
else:
|
|
if add_config_entry_id is not UNDEFINED:
|
|
# Adding the config entry (and subentry) the device already belongs to is a
|
|
# no-op; recording it as a pending move would make a later removal of that
|
|
# sole owner move the device to itself instead of deleting it.
|
|
already_owner = add_config_entry_id == old.config_entry_id and (
|
|
add_config_subentry_id is UNDEFINED
|
|
or add_config_subentry_id == old.config_subentry_id
|
|
)
|
|
if not already_owner:
|
|
pending_move = _PendingMove(
|
|
add_config_entry_id,
|
|
add_config_subentry_id
|
|
if add_config_subentry_id is not UNDEFINED
|
|
else None,
|
|
_current_integration_domain(),
|
|
)
|
|
if remove_config_entry_id == old.config_entry_id and (
|
|
remove_config_subentry_id is UNDEFINED
|
|
or remove_config_subentry_id == old.config_subentry_id
|
|
):
|
|
move_from_prior_call = pending_move is UNDEFINED
|
|
move_target = (
|
|
pending_move if pending_move is not UNDEFINED else old._pending_move # noqa: SLF001
|
|
)
|
|
# A deferred move armed by an earlier add_config_entry_id only completes
|
|
# if the integration now removing the owning entry is the one that armed
|
|
# it. A removal from a different integration (e.g. device_tracker
|
|
# attaching a shared MAC) is unrelated, so cancel the move and delete the
|
|
# device instead of silently transferring it. Origins from core/tests are
|
|
# undetermined (None) and never cancel.
|
|
if (
|
|
move_target is not None
|
|
and move_from_prior_call
|
|
and move_target.origin_domain is not None
|
|
and (current_domain := _current_integration_domain()) is not None
|
|
and current_domain != move_target.origin_domain
|
|
):
|
|
move_target = None
|
|
if move_target is None:
|
|
self.async_remove_device(device_id)
|
|
return None
|
|
target_config_entry_id = move_target.config_entry_id
|
|
target_config_subentry_id = move_target.config_subentry_id
|
|
pending_move = None
|
|
# A pre-migration composite's splits share identity, so once one split
|
|
# completes the move to the target entry the others must not also move
|
|
# there and collide; clear their pending moves.
|
|
if old.composite_device_id is not None:
|
|
for sibling in self.devices.get_devices_for_composite_device_id(
|
|
old.composite_device_id
|
|
):
|
|
if (
|
|
sibling.id != device_id
|
|
and sibling._pending_move is not None # noqa: SLF001
|
|
):
|
|
self.devices[sibling.id] = attr.evolve(
|
|
sibling, pending_move=None
|
|
)
|
|
|
|
if target_config_subentry_id not in (UNDEFINED, None):
|
|
resolved_config_entry_id = (
|
|
target_config_entry_id
|
|
if target_config_entry_id is not UNDEFINED
|
|
else old.config_entry_id
|
|
)
|
|
resolved_config_entry = self.hass.config_entries.async_get_entry(
|
|
resolved_config_entry_id
|
|
)
|
|
if (
|
|
resolved_config_entry is None
|
|
or target_config_subentry_id not in resolved_config_entry.subentries
|
|
):
|
|
raise HomeAssistantError(
|
|
f"Config entry {resolved_config_entry_id} has no"
|
|
f" subentry {target_config_subentry_id}"
|
|
)
|
|
|
|
if (
|
|
target_config_entry_id is not UNDEFINED
|
|
and target_config_entry_id != old.config_entry_id
|
|
):
|
|
new_values["config_entry_id"] = target_config_entry_id
|
|
old_values["config_entry_id"] = old.config_entry_id
|
|
if (
|
|
target_config_subentry_id is not UNDEFINED
|
|
and target_config_subentry_id != old.config_subentry_id
|
|
):
|
|
new_values["config_subentry_id"] = target_config_subentry_id
|
|
old_values["config_subentry_id"] = old.config_subentry_id
|
|
# pending_move is a transient runtime-only attribute; it is not reported in the
|
|
# update event (not added to old_values) and never stored
|
|
if pending_move is not UNDEFINED and pending_move != old._pending_move: # noqa: SLF001
|
|
new_values["pending_move"] = pending_move
|
|
|
|
# The config entry owning the device after the update. Identifiers and
|
|
# connections are unique per config entry, so they are validated against the
|
|
# owning entry, as is the disabled state.
|
|
effective_config_entry_id = (
|
|
target_config_entry_id
|
|
if target_config_entry_id is not UNDEFINED
|
|
else old.config_entry_id
|
|
)
|
|
is_move = effective_config_entry_id != old.config_entry_id
|
|
|
|
if via_device_id is not UNDEFINED and via_device_id is not None:
|
|
# Existence was already validated, so this cannot be None
|
|
via_device_id = self._resolve_via_device_id(
|
|
via_device_id, effective_config_entry_id
|
|
)
|
|
|
|
added_connections: set[tuple[str, str]] | None = None
|
|
added_identifiers: set[tuple[str, str]] | None = None
|
|
|
|
if merge_connections is not UNDEFINED:
|
|
normalized_connections = self._validate_connections(
|
|
device_id,
|
|
effective_config_entry_id,
|
|
merge_connections,
|
|
allow_collisions,
|
|
)
|
|
old_connections = old.connections
|
|
if not normalized_connections.issubset(old_connections):
|
|
added_connections = normalized_connections
|
|
new_values["connections"] = old_connections | normalized_connections
|
|
old_values["connections"] = old_connections
|
|
|
|
if merge_identifiers is not UNDEFINED:
|
|
merge_identifiers = self._validate_identifiers(
|
|
device_id,
|
|
effective_config_entry_id,
|
|
merge_identifiers,
|
|
allow_collisions,
|
|
)
|
|
old_identifiers = old.identifiers
|
|
if not merge_identifiers.issubset(old_identifiers):
|
|
added_identifiers = merge_identifiers
|
|
new_values["identifiers"] = old_identifiers | merge_identifiers
|
|
old_values["identifiers"] = old_identifiers
|
|
|
|
if new_connections is not UNDEFINED:
|
|
added_connections = new_values["connections"] = self._validate_connections(
|
|
device_id, effective_config_entry_id, new_connections, allow_collisions
|
|
)
|
|
old_values["connections"] = old.connections
|
|
|
|
if new_identifiers is not UNDEFINED:
|
|
added_identifiers = new_values["identifiers"] = self._validate_identifiers(
|
|
device_id, effective_config_entry_id, new_identifiers, allow_collisions
|
|
)
|
|
old_values["identifiers"] = old.identifiers
|
|
|
|
# On a move to another config entry, validate the identifiers and connections
|
|
# retained from the old entry against the new one, so the move can't silently
|
|
# overwrite the index slot of a device that already has the same identity there.
|
|
# A full new_identifiers / new_connections replacement is validated above;
|
|
# merge_* only adds, so the retained old values still need checking here.
|
|
if is_move:
|
|
if new_identifiers is UNDEFINED:
|
|
self._validate_identifiers(
|
|
device_id, effective_config_entry_id, old.identifiers, False
|
|
)
|
|
if new_connections is UNDEFINED:
|
|
self._validate_connections(
|
|
device_id, effective_config_entry_id, old.connections, False
|
|
)
|
|
|
|
# An explicit disabled_by must be consistent with the disabled state of the
|
|
# config entry owning the device after the update: a device can't be enabled
|
|
# when the owning config entry is disabled, and can't be disabled by
|
|
# CONFIG_ENTRY when the owning config entry is enabled. An inconsistent
|
|
# disabled_by is ignored; this will raise in HA Core 2027.8.
|
|
# On a move, reflect the new owning config entry's disabled state (as restoring
|
|
# a deleted device does) unless a consistent disabled_by was passed explicitly:
|
|
# disable an enabled device moved onto a disabled entry, and clear a
|
|
# CONFIG_ENTRY disable when moved onto an enabled entry. A USER disable is
|
|
# preserved. A new device is reconciled the same way, so a create can't
|
|
# leave the device's disabled state contradicting the owning entry's.
|
|
if (disabled_by is not UNDEFINED or is_move or is_new) and (
|
|
owning_entry := self.hass.config_entries.async_get_entry(
|
|
effective_config_entry_id
|
|
)
|
|
) is not None:
|
|
if is_move:
|
|
context = "when moving a device to"
|
|
elif is_new:
|
|
context = "when creating a device attached to"
|
|
else:
|
|
context = "on a device belonging to"
|
|
if disabled_by is None and owning_entry.disabled_by:
|
|
report_usage(
|
|
f"sets disabled_by to None {context} the disabled "
|
|
f"config entry {effective_config_entry_id}",
|
|
core_behavior=ReportBehavior.LOG,
|
|
breaks_in_ha_version="2027.8",
|
|
)
|
|
disabled_by = UNDEFINED
|
|
elif (
|
|
disabled_by is DeviceEntryDisabler.CONFIG_ENTRY
|
|
and not owning_entry.disabled_by
|
|
):
|
|
report_usage(
|
|
f"sets disabled_by to DeviceEntryDisabler.CONFIG_ENTRY {context} "
|
|
f"the enabled config entry {effective_config_entry_id}",
|
|
core_behavior=ReportBehavior.LOG,
|
|
breaks_in_ha_version="2027.8",
|
|
)
|
|
disabled_by = UNDEFINED
|
|
if (is_move or is_new) and disabled_by is UNDEFINED:
|
|
if owning_entry.disabled_by:
|
|
if old.disabled_by is None:
|
|
disabled_by = DeviceEntryDisabler.CONFIG_ENTRY
|
|
elif old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY:
|
|
disabled_by = None
|
|
|
|
for attr_name, value in (
|
|
("area_id", area_id),
|
|
("configuration_url", configuration_url),
|
|
("disabled_by", disabled_by),
|
|
("entry_type", entry_type),
|
|
("hw_version", hw_version),
|
|
("labels", labels),
|
|
("manufacturer", manufacturer),
|
|
("model", model),
|
|
("model_id", model_id),
|
|
("name", name),
|
|
("name_by_user", name_by_user),
|
|
("has_composite_identifiers", has_composite_identifiers),
|
|
("serial_number", serial_number),
|
|
("sw_version", sw_version),
|
|
("via_device_id", via_device_id),
|
|
):
|
|
if value is not UNDEFINED and value != getattr(old, attr_name):
|
|
new_values[attr_name] = value
|
|
old_values[attr_name] = getattr(old, attr_name)
|
|
|
|
# Can be removed when suggested_area is removed from DeviceEntry
|
|
if suggested_area is not UNDEFINED and suggested_area != old._suggested_area: # noqa: SLF001
|
|
new_values["suggested_area"] = suggested_area
|
|
old_values["suggested_area"] = old._suggested_area # noqa: SLF001
|
|
|
|
if not new_values and not is_new:
|
|
return old
|
|
|
|
# This condition can be removed when suggested_area is removed from DeviceEntry
|
|
if not RUNTIME_ONLY_ATTRS.issuperset(new_values):
|
|
# Change modified_at if we are changing something that we store
|
|
new_values["modified_at"] = utcnow()
|
|
|
|
self.hass.verify_event_loop_thread("device_registry._async_update_device")
|
|
new = attr.evolve(old, **new_values)
|
|
self.devices[device_id] = new
|
|
|
|
# On a move, the device's whole retained identity newly appears in the target
|
|
# config entry; added_identifiers/added_connections are empty on a retained-
|
|
# identity move, so match the target entry's deleted device by the full identity.
|
|
match_identifiers: set[tuple[str, str]] | None
|
|
match_connections: set[tuple[str, str]] | None
|
|
if is_move:
|
|
match_identifiers = new.identifiers
|
|
match_connections = new.connections
|
|
else:
|
|
match_identifiers = added_identifiers
|
|
match_connections = added_connections
|
|
# A deleted device holding an identity the device now owns can never restore
|
|
for deleted_device_id in self.deleted_devices.get_colliding_device_ids(
|
|
match_identifiers or set(),
|
|
match_connections or set(),
|
|
config_entry_id=effective_config_entry_id,
|
|
exclude_device_id=None,
|
|
):
|
|
del self.deleted_devices[deleted_device_id]
|
|
|
|
# If its only run time attributes (suggested_area)
|
|
# that do not get saved we do not want to write
|
|
# to disk or fire an event as we would end up
|
|
# firing events for data we have nothing to compare
|
|
# against since its never saved on disk
|
|
if RUNTIME_ONLY_ATTRS.issuperset(new_values):
|
|
# This can be removed when suggested_area is removed from DeviceEntry
|
|
return new
|
|
|
|
self.async_schedule_save()
|
|
|
|
data: EventDeviceRegistryUpdatedData
|
|
if is_new:
|
|
data = {"action": "create", "device_id": new.id}
|
|
else:
|
|
data = {"action": "update", "device_id": new.id, "changes": old_values}
|
|
|
|
self.hass.bus.async_fire_internal(EVENT_DEVICE_REGISTRY_UPDATED, data)
|
|
|
|
return new
|
|
|
|
@callback
|
|
def async_update_device(
|
|
self,
|
|
device_id: str,
|
|
*,
|
|
add_config_entry_id: str | UndefinedType = UNDEFINED,
|
|
add_config_subentry_id: str | UndefinedType | None = UNDEFINED,
|
|
area_id: str | UndefinedType | None = UNDEFINED,
|
|
configuration_url: str | URL | UndefinedType | None = UNDEFINED,
|
|
disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED,
|
|
entry_type: DeviceEntryType | UndefinedType | None = UNDEFINED,
|
|
hw_version: str | UndefinedType | None = UNDEFINED,
|
|
labels: set[str] | UndefinedType = UNDEFINED,
|
|
manufacturer: str | UndefinedType | None = UNDEFINED,
|
|
merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
model: str | UndefinedType | None = UNDEFINED,
|
|
model_id: str | UndefinedType | None = UNDEFINED,
|
|
name_by_user: str | UndefinedType | None = UNDEFINED,
|
|
name: str | UndefinedType | None = UNDEFINED,
|
|
new_config_entry_id: str | UndefinedType = UNDEFINED,
|
|
new_config_subentry_id: str | UndefinedType | None = UNDEFINED,
|
|
new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
|
|
remove_config_entry_id: str | UndefinedType = UNDEFINED,
|
|
remove_config_subentry_id: str | UndefinedType | None = UNDEFINED,
|
|
serial_number: str | UndefinedType | None = UNDEFINED,
|
|
# suggested_area is deprecated and will be removed in 2026.9
|
|
suggested_area: str | UndefinedType | None = UNDEFINED,
|
|
sw_version: str | UndefinedType | None = UNDEFINED,
|
|
via_device_id: str | UndefinedType | None = UNDEFINED,
|
|
) -> DeviceEntry | None:
|
|
"""Update device attributes.
|
|
|
|
A device belongs to a single config entry and subentry. To move a device to
|
|
another config entry or subentry, pass new_config_entry_id and/or
|
|
new_config_subentry_id. To remove a device, pass remove_config_entry_id with the
|
|
device's config entry.
|
|
|
|
:param add_config_entry_id: Deprecated. Combined with remove_config_entry_id it
|
|
moves the device; on its own it does nothing.
|
|
:param add_config_subentry_id: Deprecated. Combined with remove_config_subentry_id
|
|
it moves the device to another subentry; on its own it does nothing.
|
|
:param disabled_by: Disable or enable the device. Must be consistent with the
|
|
disabled state of the config entry owning the device after the update:
|
|
a device can't be enabled when the owning config entry is disabled, and
|
|
can't be disabled by CONFIG_ENTRY when the owning config entry is enabled.
|
|
An inconsistent disabled_by is deprecated and ignored; this will raise in
|
|
HA Core 2027.8.
|
|
:param new_config_entry_id: Move the device to this config entry. Unless a
|
|
disabled_by consistent with the new config entry's disabled state is
|
|
passed explicitly, the device's disabled state is updated to reflect the
|
|
new config entry's disabled state.
|
|
:param new_config_subentry_id: Move the device to this subentry.
|
|
:param remove_config_entry_id: Remove the device if it is the device's config
|
|
entry, unless combined with add_config_entry_id to move the device.
|
|
:param remove_config_subentry_id: Remove the device from a specific subentry of
|
|
remove_config_entry_id.
|
|
"""
|
|
if (
|
|
underlying_ids := self._async_device_ids_for_composite_device_id(device_id)
|
|
) is not None:
|
|
# Fan the update out to each underlying device; keep in sync with the
|
|
# update parameters above.
|
|
update_args = {
|
|
"add_config_entry_id": add_config_entry_id,
|
|
"add_config_subentry_id": add_config_subentry_id,
|
|
"area_id": area_id,
|
|
"configuration_url": configuration_url,
|
|
"disabled_by": disabled_by,
|
|
"entry_type": entry_type,
|
|
"hw_version": hw_version,
|
|
"labels": labels,
|
|
"manufacturer": manufacturer,
|
|
"merge_connections": merge_connections,
|
|
"merge_identifiers": merge_identifiers,
|
|
"model": model,
|
|
"model_id": model_id,
|
|
"name_by_user": name_by_user,
|
|
"name": name,
|
|
"new_config_entry_id": new_config_entry_id,
|
|
"new_config_subentry_id": new_config_subentry_id,
|
|
"new_connections": new_connections,
|
|
"new_identifiers": new_identifiers,
|
|
"remove_config_entry_id": remove_config_entry_id,
|
|
"remove_config_subentry_id": remove_config_subentry_id,
|
|
"serial_number": serial_number,
|
|
"suggested_area": suggested_area,
|
|
"sw_version": sw_version,
|
|
"via_device_id": via_device_id,
|
|
}
|
|
return self._async_update_composite_device(
|
|
device_id, underlying_ids, update_args
|
|
)
|
|
if suggested_area is not UNDEFINED:
|
|
report_usage(
|
|
"passes a suggested_area to device_registry.async_update device",
|
|
core_behavior=ReportBehavior.LOG,
|
|
breaks_in_ha_version="2026.9.0",
|
|
)
|
|
|
|
validated_fields = _validate_device_info_fields(
|
|
configuration_url=configuration_url,
|
|
hw_version=hw_version,
|
|
manufacturer=manufacturer,
|
|
model=model,
|
|
model_id=model_id,
|
|
serial_number=serial_number,
|
|
sw_version=sw_version,
|
|
)
|
|
|
|
return self._async_update_device(
|
|
device_id,
|
|
add_config_entry_id=add_config_entry_id,
|
|
add_config_subentry_id=add_config_subentry_id,
|
|
area_id=area_id,
|
|
disabled_by=disabled_by,
|
|
entry_type=entry_type,
|
|
labels=labels,
|
|
merge_connections=merge_connections,
|
|
merge_identifiers=merge_identifiers,
|
|
name_by_user=name_by_user,
|
|
name=name,
|
|
new_config_entry_id=new_config_entry_id,
|
|
new_config_subentry_id=new_config_subentry_id,
|
|
new_connections=new_connections,
|
|
new_identifiers=new_identifiers,
|
|
remove_config_entry_id=remove_config_entry_id,
|
|
remove_config_subentry_id=remove_config_subentry_id,
|
|
suggested_area=suggested_area,
|
|
via_device_id=via_device_id,
|
|
**validated_fields,
|
|
)
|
|
|
|
@callback
|
|
def _async_reconcile_collisions(
|
|
self,
|
|
matched_device: DeviceEntry | None,
|
|
config_entry: ConfigEntry,
|
|
device_info: DeviceInfo,
|
|
identifiers: set[tuple[str, str]],
|
|
connections: set[tuple[str, str]],
|
|
) -> None:
|
|
"""Resolve device key collisions with the registering device.
|
|
|
|
Shared keys are stripped from stale duplicates (devices not registered this
|
|
setup session); a duplicate left without any keys is removed. A collision
|
|
with a device registered this setup session raises.
|
|
"""
|
|
matched_device_id: str | None = None
|
|
if matched_device is not None:
|
|
matched_device_id = matched_device.id
|
|
if not matched_device.has_composite_identifiers:
|
|
identifiers = matched_device.identifiers | identifiers
|
|
connections = matched_device.connections | connections
|
|
colliding = self.devices.get_colliding_device_ids(
|
|
identifiers,
|
|
connections,
|
|
config_entry_id=config_entry.entry_id,
|
|
exclude_device_id=matched_device_id,
|
|
)
|
|
live_device_ids = self._live_device_ids.get(config_entry.entry_id, ())
|
|
for holder_id, (shared_identifiers, shared_connections) in colliding.items():
|
|
if holder_id not in live_device_ids:
|
|
continue
|
|
raise DeviceInfoError(
|
|
config_entry.domain,
|
|
device_info,
|
|
f"identifiers or connections "
|
|
f"{sorted(shared_identifiers | shared_connections)} are already "
|
|
f"registered for device {holder_id} of the same config entry",
|
|
)
|
|
for holder_id, (shared_identifiers, shared_connections) in colliding.items():
|
|
holder = self.devices[holder_id]
|
|
remaining_identifiers = holder.identifiers - shared_identifiers
|
|
remaining_connections = holder.connections - shared_connections
|
|
if not remaining_identifiers and not remaining_connections:
|
|
_LOGGER.debug(
|
|
"Removing device %s, its identifiers and connections are all "
|
|
"registered by another device of the same config entry",
|
|
holder_id,
|
|
)
|
|
self.async_remove_device(holder_id)
|
|
continue
|
|
_LOGGER.debug(
|
|
"Stripping %s from device %s, registered by another device of the "
|
|
"same config entry",
|
|
sorted(shared_identifiers | shared_connections),
|
|
holder_id,
|
|
)
|
|
strip_values: dict[str, Any] = {}
|
|
if shared_identifiers:
|
|
strip_values["new_identifiers"] = remaining_identifiers
|
|
if shared_connections:
|
|
strip_values["new_connections"] = remaining_connections
|
|
self._async_update_device(holder_id, allow_collisions=True, **strip_values)
|
|
|
|
@callback
|
|
def _async_purge_colliding_deleted_devices(
|
|
self,
|
|
device: DeviceEntry,
|
|
identifiers: set[tuple[str, str]],
|
|
connections: set[tuple[str, str]],
|
|
) -> None:
|
|
"""Purge deleted devices with key collisions."""
|
|
if not device.has_composite_identifiers:
|
|
identifiers = device.identifiers | identifiers
|
|
connections = device.connections | connections
|
|
colliding = self.deleted_devices.get_colliding_device_ids(
|
|
identifiers,
|
|
connections,
|
|
config_entry_id=device.config_entry_id,
|
|
exclude_device_id=None,
|
|
)
|
|
if not colliding:
|
|
return
|
|
for deleted_device_id in colliding:
|
|
_LOGGER.debug(
|
|
"Removing deleted device %s, its identifiers or connections are "
|
|
"registered by device %s of the same config entry",
|
|
deleted_device_id,
|
|
device.id,
|
|
)
|
|
del self.deleted_devices[deleted_device_id]
|
|
self.async_schedule_save()
|
|
|
|
@callback
|
|
def _validate_connections(
|
|
self,
|
|
device_id: str,
|
|
config_entry_id: str,
|
|
connections: set[tuple[str, str]],
|
|
allow_collisions: bool,
|
|
) -> set[tuple[str, str]]:
|
|
"""Normalize and validate connections, raise on collision with other devices.
|
|
|
|
Connections are unique per config entry, so only collisions with other devices
|
|
of the same config entry are considered.
|
|
"""
|
|
normalized_connections = _normalize_connections(connections)
|
|
if allow_collisions:
|
|
return normalized_connections
|
|
|
|
for connection in normalized_connections:
|
|
# We need to iterate over each connection because if there is a
|
|
# conflict, the index will only see the last one and we will not
|
|
# be able to tell which one caused the conflict
|
|
if (
|
|
existing_device := self.devices.get_entry(
|
|
connections={connection}, config_entry_id=config_entry_id
|
|
)
|
|
) and existing_device.id != device_id:
|
|
raise DeviceConnectionCollisionError(
|
|
normalized_connections, existing_device
|
|
)
|
|
|
|
return normalized_connections
|
|
|
|
@callback
|
|
def _validate_identifiers(
|
|
self,
|
|
device_id: str,
|
|
config_entry_id: str,
|
|
identifiers: set[tuple[str, str]],
|
|
allow_collisions: bool,
|
|
) -> set[tuple[str, str]]:
|
|
"""Validate identifiers, raise on collision with other devices.
|
|
|
|
Identifiers are unique per config entry, so only collisions with other devices
|
|
of the same config entry are considered.
|
|
"""
|
|
if allow_collisions:
|
|
return identifiers
|
|
|
|
for identifier in identifiers:
|
|
# We need to iterate over each identifier because if there is a
|
|
# conflict, the index will only see the last one and we will not
|
|
# be able to tell which one caused the conflict
|
|
if (
|
|
existing_device := self.devices.get_entry(
|
|
identifiers={identifier}, config_entry_id=config_entry_id
|
|
)
|
|
) and existing_device.id != device_id:
|
|
raise DeviceIdentifierCollisionError(identifiers, existing_device)
|
|
|
|
return identifiers
|
|
|
|
@callback
|
|
def _async_update_composite_device(
|
|
self,
|
|
composite_id: str,
|
|
underlying_ids: list[str],
|
|
update_args: dict[str, Any],
|
|
) -> DeviceEntry | None:
|
|
"""Fan an async_update_device call on a composite out to its real devices."""
|
|
forward = {
|
|
name: value for name, value in update_args.items() if value is not UNDEFINED
|
|
}
|
|
if ignored := [
|
|
name for name in _COMPOSITE_IGNORED_UPDATE_ARGS if name in forward
|
|
]:
|
|
# These rewrite a device's functional identity or move it, which is ambiguous
|
|
# across the composite's underlying devices; drop them rather than corrupt or
|
|
# collide, and report the offending integration.
|
|
report_usage(
|
|
f"passed {', '.join(ignored)} to device_registry.async_update_device "
|
|
"for a composite device that spans several config entries (returned for "
|
|
"an ambiguous async_get_device lookup, or "
|
|
"resolved from a stored device id of a pre-migration composite); the "
|
|
"argument cannot be applied to the merged device and was ignored - "
|
|
"target a single device, e.g. one returned by "
|
|
"async_entries_for_config_entry",
|
|
core_behavior=ReportBehavior.LOG,
|
|
)
|
|
for name in ignored:
|
|
del forward[name]
|
|
for underlying_id in underlying_ids:
|
|
self.async_update_device(underlying_id, **forward)
|
|
remaining = [
|
|
self.devices[underlying_id]
|
|
for underlying_id in underlying_ids
|
|
if underlying_id in self.devices
|
|
]
|
|
if not remaining:
|
|
return None
|
|
return self._restore_composite_device(composite_id, remaining)
|
|
|
|
@callback
|
|
def async_remove_device(self, device_id: str) -> None:
|
|
"""Remove a device from the device registry."""
|
|
if (
|
|
underlying_ids := self._async_device_ids_for_composite_device_id(device_id)
|
|
) is not None:
|
|
for underlying_id in underlying_ids:
|
|
self.async_remove_device(underlying_id)
|
|
return
|
|
self.hass.verify_event_loop_thread("device_registry.async_remove_device")
|
|
device = self.devices.pop(device_id)
|
|
config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id)
|
|
self.deleted_devices[device_id] = DeletedDeviceEntry(
|
|
area_id=device.area_id,
|
|
config_entry_id=device.config_entry_id,
|
|
config_subentry_id=device.config_subentry_id,
|
|
connections=device.connections,
|
|
created_at=device.created_at,
|
|
disabled_by=device.disabled_by,
|
|
identifiers=device.identifiers,
|
|
id=device.id,
|
|
labels=device.labels,
|
|
modified_at=utcnow(),
|
|
name_by_user=device.name_by_user,
|
|
orphaned_timestamp=None,
|
|
domain=config_entry.domain if config_entry is not None else None,
|
|
)
|
|
for other_device in list(self.devices.values()):
|
|
if other_device.via_device_id == device_id:
|
|
self._async_update_device(other_device.id, via_device_id=None)
|
|
self.hass.bus.async_fire_internal(
|
|
EVENT_DEVICE_REGISTRY_UPDATED,
|
|
_EventDeviceRegistryUpdatedData_Remove(
|
|
action="remove", device_id=device_id, device=device.dict_repr
|
|
),
|
|
)
|
|
self.async_schedule_save()
|
|
|
|
@override
|
|
async def _async_load(self) -> None:
|
|
"""Load the device registry."""
|
|
if self._loaded_event.is_set():
|
|
raise RuntimeError("Device registry is already loaded")
|
|
|
|
async_setup_cleanup(self.hass, self)
|
|
|
|
data = await self._store.async_load()
|
|
|
|
devices = ActiveDeviceRegistryItems()
|
|
deleted_devices = DeletedDeviceRegistryItems()
|
|
|
|
if data is not None:
|
|
for device in data["devices"]:
|
|
devices[device["id"]] = DeviceEntry(
|
|
area_id=device["area_id"],
|
|
config_entry_id=device["config_entry_id"],
|
|
config_subentry_id=device["config_subentry_id"],
|
|
configuration_url=device["configuration_url"],
|
|
# type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625
|
|
connections={
|
|
tuple(conn) # type: ignore[misc]
|
|
for conn in device["connections"]
|
|
},
|
|
created_at=datetime.fromisoformat(device["created_at"]),
|
|
disabled_by=(
|
|
DeviceEntryDisabler(device["disabled_by"])
|
|
if device["disabled_by"]
|
|
else None
|
|
),
|
|
entry_type=(
|
|
DeviceEntryType(device["entry_type"])
|
|
if device["entry_type"]
|
|
else None
|
|
),
|
|
hw_version=device["hw_version"],
|
|
id=device["id"],
|
|
identifiers={
|
|
tuple(iden) # type: ignore[misc]
|
|
for iden in device["identifiers"]
|
|
},
|
|
labels=set(device["labels"]),
|
|
composite_device_id=device["composite_device_id"],
|
|
composite_primary_config_entry=device[
|
|
"composite_primary_config_entry"
|
|
],
|
|
split_at=(
|
|
datetime.fromisoformat(device["split_at"])
|
|
if device["split_at"]
|
|
else None
|
|
),
|
|
manufacturer=device["manufacturer"],
|
|
model=device["model"],
|
|
model_id=device["model_id"],
|
|
modified_at=datetime.fromisoformat(device["modified_at"]),
|
|
name_by_user=device["name_by_user"],
|
|
name=device["name"],
|
|
has_composite_identifiers=device["has_composite_identifiers"],
|
|
serial_number=device["serial_number"],
|
|
sw_version=device["sw_version"],
|
|
via_device_id=device["via_device_id"],
|
|
)
|
|
|
|
# Introduced in 0.111
|
|
def get_optional_enum[_EnumT: StrEnum](
|
|
cls: type[_EnumT], value: str | None, undefined: bool
|
|
) -> _EnumT | UndefinedType | None:
|
|
"""Convert string to the passed enum, UNDEFINED or None."""
|
|
if undefined:
|
|
return UNDEFINED
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return cls(value)
|
|
except ValueError:
|
|
return None
|
|
|
|
for device in data["deleted_devices"]:
|
|
deleted_devices[device["id"]] = DeletedDeviceEntry(
|
|
area_id=device["area_id"],
|
|
config_entry_id=device["config_entry_id"],
|
|
config_subentry_id=device["config_subentry_id"],
|
|
connections={tuple(conn) for conn in device["connections"]},
|
|
created_at=datetime.fromisoformat(device["created_at"]),
|
|
disabled_by=get_optional_enum(
|
|
DeviceEntryDisabler,
|
|
device["disabled_by"],
|
|
device["disabled_by_undefined"],
|
|
),
|
|
identifiers={tuple(iden) for iden in device["identifiers"]},
|
|
id=device["id"],
|
|
labels=set(device["labels"]),
|
|
modified_at=datetime.fromisoformat(device["modified_at"]),
|
|
name_by_user=device["name_by_user"],
|
|
orphaned_timestamp=device["orphaned_timestamp"],
|
|
domain=device["domain"],
|
|
)
|
|
|
|
if (
|
|
shadowed_count := devices.count_shadowed_keys()
|
|
+ deleted_devices.count_shadowed_keys()
|
|
):
|
|
_LOGGER.info(
|
|
"Loaded %d identifiers/connections registered to multiple devices of "
|
|
"one config entry; they will be reconciled as integrations register "
|
|
"their devices",
|
|
shadowed_count,
|
|
)
|
|
|
|
self.devices = devices
|
|
self.deleted_devices = deleted_devices
|
|
self._device_data = devices.data
|
|
|
|
self._loaded_event.set()
|
|
|
|
async def async_wait_loaded(self) -> None:
|
|
"""Wait until the device registry is fully loaded."""
|
|
await self._loaded_event.wait()
|
|
|
|
@callback
|
|
@override
|
|
def _data_to_save(self) -> dict[str, Any]:
|
|
"""Return data of device registry to store in a file."""
|
|
# Create intermediate lists to allow this method to be called from a thread
|
|
# other than the event loop.
|
|
return {
|
|
"devices": [
|
|
entry.as_storage_fragment for entry in list(self.devices.values())
|
|
],
|
|
"deleted_devices": [
|
|
entry.as_storage_fragment
|
|
for entry in list(self.deleted_devices.values())
|
|
],
|
|
}
|
|
|
|
@callback
|
|
def _resolve_orphan_domain(
|
|
self, config_entry_id: str, domain: str | None
|
|
) -> str | None:
|
|
"""Return the domain to record on devices orphaned from a config entry."""
|
|
if domain is not None:
|
|
return domain
|
|
if (
|
|
entry := self.hass.config_entries.async_get_entry(config_entry_id)
|
|
) is not None:
|
|
return entry.domain
|
|
return None
|
|
|
|
@callback
|
|
def _async_orphan_deleted_device(
|
|
self, deleted_device: DeletedDeviceEntry, domain: str | None, now_time: float
|
|
) -> None:
|
|
"""Mark a deleted device as orphaned, remembering its former domain."""
|
|
if domain is not None:
|
|
# Orphans are indexed by their recorded domain, so two orphans of the
|
|
# same domain sharing an identifier or connection would collide. When a
|
|
# device from the same integration is orphaned, drop any existing orphan
|
|
# it overlaps so the newest one wins deterministically instead of shadowing
|
|
# it.
|
|
for existing in list(self.deleted_devices.values()):
|
|
if (
|
|
existing.config_entry_id is None
|
|
and existing.domain == domain
|
|
and (
|
|
existing.connections & deleted_device.connections
|
|
or existing.identifiers & deleted_device.identifiers
|
|
)
|
|
):
|
|
del self.deleted_devices[existing.id]
|
|
self.deleted_devices[deleted_device.id] = attr.evolve(
|
|
deleted_device,
|
|
config_entry_id=None,
|
|
config_subentry_id=None,
|
|
orphaned_timestamp=now_time,
|
|
domain=domain,
|
|
)
|
|
self.async_schedule_save()
|
|
|
|
@callback
|
|
def async_config_entry_unloaded(self, config_entry_id: str) -> None:
|
|
"""Forget the live devices of a config entry that unloaded or failed setup."""
|
|
self._live_device_ids.pop(config_entry_id, None)
|
|
|
|
@callback
|
|
def async_clear_config_entry(
|
|
self, config_entry_id: str, domain: str | None = None
|
|
) -> None:
|
|
"""Clear config entry from registry entries."""
|
|
self._live_device_ids.pop(config_entry_id, None)
|
|
domain = self._resolve_orphan_domain(config_entry_id, domain)
|
|
now_time = time.time()
|
|
for device in self.devices.get_devices_for_config_entry_id(config_entry_id):
|
|
self.async_remove_device(device.id)
|
|
# A split device records the composite's former primary config entry; when that
|
|
# config entry is removed, clear the now-dangling reference so a restored
|
|
# composite no longer points at a config entry that no longer exists.
|
|
for device in list(self.devices.values()):
|
|
if device.composite_primary_config_entry == config_entry_id:
|
|
self.devices[device.id] = attr.evolve(
|
|
device, composite_primary_config_entry=None
|
|
)
|
|
self.async_schedule_save()
|
|
# A device owned by another config entry may hold a transient pending move
|
|
# targeting the entry being removed; clear it so a later completion deletes the
|
|
# device instead of moving it onto the removed entry.
|
|
for device in list(self.devices.values()):
|
|
pending_move = device._pending_move # noqa: SLF001
|
|
if (
|
|
pending_move is not None
|
|
and pending_move.config_entry_id == config_entry_id
|
|
):
|
|
self.devices[device.id] = attr.evolve(device, pending_move=None)
|
|
for deleted_device in list(self.deleted_devices.values()):
|
|
if deleted_device.config_entry_id != config_entry_id:
|
|
continue
|
|
self._async_orphan_deleted_device(deleted_device, domain, now_time)
|
|
|
|
@callback
|
|
def async_clear_config_subentry(
|
|
self, config_entry_id: str, config_subentry_id: str, domain: str | None = None
|
|
) -> None:
|
|
"""Clear config subentry from registry entries."""
|
|
domain = self._resolve_orphan_domain(config_entry_id, domain)
|
|
now_time = time.time()
|
|
for device in self.devices.get_devices_for_config_entry_id(config_entry_id):
|
|
if device.config_subentry_id != config_subentry_id:
|
|
continue
|
|
self.async_remove_device(device.id)
|
|
# A device may hold a transient pending move targeting the subentry being removed;
|
|
# clear it so a later completion deletes the device instead of validating against
|
|
# the removed subentry.
|
|
for device in list(self.devices.values()):
|
|
pending_move = device._pending_move # noqa: SLF001
|
|
if (
|
|
pending_move is not None
|
|
and pending_move.config_entry_id == config_entry_id
|
|
and pending_move.config_subentry_id == config_subentry_id
|
|
):
|
|
self.devices[device.id] = attr.evolve(device, pending_move=None)
|
|
for deleted_device in list(self.deleted_devices.values()):
|
|
if (
|
|
deleted_device.config_entry_id != config_entry_id
|
|
or deleted_device.config_subentry_id != config_subentry_id
|
|
):
|
|
continue
|
|
self._async_orphan_deleted_device(deleted_device, domain, now_time)
|
|
|
|
@callback
|
|
def async_purge_expired_orphaned_devices(self) -> None:
|
|
"""Purge expired orphaned devices from the registry.
|
|
|
|
We need to purge these periodically to avoid the database
|
|
growing without bound.
|
|
"""
|
|
now_time = time.time()
|
|
for deleted_device in list(self.deleted_devices.values()):
|
|
if deleted_device.orphaned_timestamp is None:
|
|
continue
|
|
|
|
if (
|
|
deleted_device.orphaned_timestamp + ORPHANED_DEVICE_KEEP_SECONDS
|
|
< now_time
|
|
):
|
|
del self.deleted_devices[deleted_device.id]
|
|
|
|
@callback
|
|
def async_clear_area_id(self, area_id: str) -> None:
|
|
"""Clear area id from registry entries."""
|
|
for device in self.devices.get_devices_for_area_id(area_id):
|
|
self._async_update_device(device.id, area_id=None)
|
|
for deleted_device in list(self.deleted_devices.values()):
|
|
if deleted_device.area_id != area_id:
|
|
continue
|
|
self.deleted_devices[deleted_device.id] = attr.evolve(
|
|
deleted_device, area_id=None
|
|
)
|
|
self.async_schedule_save()
|
|
|
|
@callback
|
|
def async_clear_label_id(self, label_id: str) -> None:
|
|
"""Clear label from registry entries."""
|
|
for device in self.devices.get_devices_for_label(label_id):
|
|
self._async_update_device(device.id, labels=device.labels - {label_id})
|
|
for deleted_device in list(self.deleted_devices.values()):
|
|
if label_id not in deleted_device.labels:
|
|
continue
|
|
self.deleted_devices[deleted_device.id] = attr.evolve(
|
|
deleted_device, labels=deleted_device.labels - {label_id}
|
|
)
|
|
self.async_schedule_save()
|
|
|
|
|
|
@callback
|
|
def async_get(hass: HomeAssistant) -> DeviceRegistry:
|
|
"""Get device registry."""
|
|
try:
|
|
return hass.data[DATA_REGISTRY]
|
|
except KeyError as ex:
|
|
raise RuntimeError("Device registry not set up") from ex
|
|
|
|
|
|
@callback
|
|
def async_get_device_id_by_identifier(
|
|
hass: HomeAssistant, identifier: tuple[str, str], *, config_entry_id: str
|
|
) -> str:
|
|
"""Get the id of the device with the identifier, owned by the config entry.
|
|
|
|
Convenience wrapper for linking a device to its via device through
|
|
via_device_id. Identifiers are unique within a config entry, so the lookup
|
|
cannot be ambiguous.
|
|
|
|
Raises ValueError if no such device exists.
|
|
"""
|
|
device = async_get(hass).async_get_device_by_identifier(identifier, config_entry_id)
|
|
if device is None:
|
|
raise ValueError(
|
|
f"There is no device with identifier {identifier} in config entry "
|
|
f"{config_entry_id}"
|
|
)
|
|
return device.id
|
|
|
|
|
|
def async_setup(hass: HomeAssistant) -> None:
|
|
"""Set up device registry."""
|
|
if DATA_REGISTRY in hass.data:
|
|
raise RuntimeError("Device registry is already set up")
|
|
hass.data[DATA_REGISTRY] = DeviceRegistry(hass)
|
|
|
|
|
|
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
|
|
"""Load device registry."""
|
|
await async_get(hass).async_load(load_empty=load_empty)
|
|
|
|
|
|
@callback
|
|
def async_entries_for_area(registry: DeviceRegistry, area_id: str) -> list[DeviceEntry]:
|
|
"""Return entries that match an area."""
|
|
return registry.devices.get_devices_for_area_id(area_id)
|
|
|
|
|
|
@callback
|
|
def async_entries_for_label(
|
|
registry: DeviceRegistry, label_id: str
|
|
) -> list[DeviceEntry]:
|
|
"""Return entries that match a label."""
|
|
return registry.devices.get_devices_for_label(label_id)
|
|
|
|
|
|
@callback
|
|
def async_entries_for_config_entry(
|
|
registry: DeviceRegistry, config_entry_id: str
|
|
) -> list[DeviceEntry]:
|
|
"""Return entries that match a config entry."""
|
|
return registry.devices.get_devices_for_config_entry_id(config_entry_id)
|
|
|
|
|
|
@callback
|
|
def async_config_entry_disabled_by_changed(
|
|
registry: DeviceRegistry, config_entry: ConfigEntry
|
|
) -> None:
|
|
"""Handle a config entry being disabled or enabled.
|
|
|
|
Disable devices in the registry that are associated with a config entry when
|
|
the config entry is disabled, enable devices in the registry that are associated
|
|
with a config entry when the config entry is enabled and the devices are marked
|
|
DeviceEntryDisabler.CONFIG_ENTRY.
|
|
"""
|
|
|
|
devices = async_entries_for_config_entry(registry, config_entry.entry_id)
|
|
|
|
if not config_entry.disabled_by:
|
|
for device in devices:
|
|
if device.disabled_by is not DeviceEntryDisabler.CONFIG_ENTRY:
|
|
continue
|
|
registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001
|
|
return
|
|
|
|
for device in devices:
|
|
if device.disabled:
|
|
# Device already disabled, do not overwrite
|
|
continue
|
|
registry._async_update_device( # noqa: SLF001
|
|
device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY
|
|
)
|
|
|
|
|
|
@callback
|
|
def _migrate_device_disabled_by(
|
|
device: dict[str, Any], config_entry_disabled: bool
|
|
) -> None:
|
|
"""Reconcile a stored device's disabled_by with its config entry's disabled state.
|
|
|
|
Reimplements async_config_entry_disabled_by_changed on stored data so the 1.13
|
|
migration can fix a split device that inherited the composite's disabled_by. Kept in
|
|
lockstep with that function by test_migrate_device_disabled_by_matches_runtime; can be
|
|
removed in HA Core 2027.8.
|
|
"""
|
|
disabled_by = device["disabled_by"]
|
|
if not config_entry_disabled:
|
|
# Config entry enabled: drop a config-entry disable, keep a user/integration one
|
|
if disabled_by == DeviceEntryDisabler.CONFIG_ENTRY:
|
|
device["disabled_by"] = None
|
|
return
|
|
# Config entry disabled: disable the device unless it is already disabled
|
|
if disabled_by is None:
|
|
device["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY
|
|
|
|
|
|
@callback
|
|
def async_cleanup(
|
|
hass: HomeAssistant,
|
|
dev_reg: DeviceRegistry,
|
|
ent_reg: entity_registry.EntityRegistry,
|
|
) -> None:
|
|
"""Clean up device registry."""
|
|
# Find all devices that are referenced by a config_entry.
|
|
config_entry_ids = set(hass.config_entries.async_entry_ids())
|
|
references_config_entries = {
|
|
device.id
|
|
for device in dev_reg.devices.values()
|
|
if device.config_entry_id in config_entry_ids
|
|
}
|
|
|
|
# Find all devices that are referenced in the entity registry.
|
|
device_ids_referenced_by_entities = set(ent_reg.entities.get_device_ids())
|
|
|
|
orphan = (
|
|
set(dev_reg.devices)
|
|
- device_ids_referenced_by_entities
|
|
- references_config_entries
|
|
)
|
|
|
|
for dev_id in orphan:
|
|
dev_reg.async_remove_device(dev_id)
|
|
|
|
# Find all referenced config entries that no longer exist
|
|
# This shouldn't happen but have not been able to track down the bug :(
|
|
for device in list(dev_reg.devices.values()):
|
|
if device.config_entry_id not in config_entry_ids:
|
|
dev_reg._async_update_device( # noqa: SLF001
|
|
device.id, remove_config_entry_id=device.config_entry_id
|
|
)
|
|
|
|
# Periodic purge of orphaned devices to avoid the registry
|
|
# growing without bounds when there are lots of deleted devices
|
|
dev_reg.async_purge_expired_orphaned_devices()
|
|
|
|
|
|
@callback
|
|
def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None:
|
|
"""Clean up device registry when entities removed."""
|
|
from . import entity_registry, label_registry as lr # noqa: PLC0415
|
|
|
|
@callback
|
|
def _label_removed_from_registry_filter(
|
|
event_data: lr.EventLabelRegistryUpdatedData,
|
|
) -> bool:
|
|
"""Filter all except for the remove action from label registry events."""
|
|
return event_data["action"] == "remove"
|
|
|
|
@callback
|
|
def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None:
|
|
"""Update devices that have a label that has been removed."""
|
|
dev_reg.async_clear_label_id(event.data["label_id"])
|
|
|
|
hass.bus.async_listen(
|
|
event_type=lr.EVENT_LABEL_REGISTRY_UPDATED,
|
|
event_filter=_label_removed_from_registry_filter,
|
|
listener=_handle_label_registry_update,
|
|
)
|
|
|
|
@callback
|
|
def _async_cleanup() -> None:
|
|
"""Cleanup."""
|
|
ent_reg = entity_registry.async_get(hass)
|
|
async_cleanup(hass, dev_reg, ent_reg)
|
|
|
|
debounced_cleanup: Debouncer[None] = Debouncer(
|
|
hass, _LOGGER, cooldown=CLEANUP_DELAY, immediate=False, function=_async_cleanup
|
|
)
|
|
|
|
@callback
|
|
def _async_entity_registry_changed(
|
|
event: Event[entity_registry.EventEntityRegistryUpdatedData],
|
|
) -> None:
|
|
"""Handle entity updated or removed dispatch."""
|
|
debounced_cleanup.async_schedule_call()
|
|
|
|
@callback
|
|
def entity_registry_changed_filter(
|
|
event_data: entity_registry.EventEntityRegistryUpdatedData,
|
|
) -> bool:
|
|
"""Handle entity updated or removed filter."""
|
|
if (
|
|
event_data["action"] == "update"
|
|
and "device_id" not in event_data["changes"]
|
|
) or event_data["action"] == "create":
|
|
return False
|
|
|
|
return True
|
|
|
|
def _async_listen_for_cleanup() -> None:
|
|
"""Listen for entity registry changes."""
|
|
hass.bus.async_listen(
|
|
entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
|
|
_async_entity_registry_changed,
|
|
event_filter=entity_registry_changed_filter,
|
|
)
|
|
|
|
if hass.is_running:
|
|
_async_listen_for_cleanup()
|
|
return
|
|
|
|
async def startup_clean(event: Event) -> None:
|
|
"""Clean up on startup."""
|
|
_async_listen_for_cleanup()
|
|
await debounced_cleanup.async_call()
|
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean)
|
|
|
|
@callback
|
|
def _on_homeassistant_stop(event: Event) -> None:
|
|
"""Cancel debounced cleanup."""
|
|
debounced_cleanup.async_cancel()
|
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop)
|