Restrict device registry entries to a single config entry and subentry (#175785)

This commit is contained in:
Erik Montnemery
2026-07-16 22:10:10 +02:00
committed by GitHub
parent 4917ba75ce
commit 1d885bd073
66 changed files with 6334 additions and 3066 deletions
@@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 1 -> 2: Unique ID format changed, so delete and re-import:
if version == 1:
dev_reg = dr.async_get(hass)
dev_reg.async_clear_config_entry(entry.entry_id)
dev_reg.async_clear_config_entry(entry.entry_id, entry.domain)
en_reg = er.async_get(hass)
en_reg.async_clear_config_entry(entry.entry_id)
@@ -2,9 +2,11 @@
from typing import Any
import attr
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.components.diagnostics import (
async_redact_data,
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics(
return data
data["device"] = {
**async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV),
**async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV),
"entities": {},
}
@@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics(
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
entity_dict = entity_entry_as_dict(entity_entry)
# The entity_id is already provided at root level (the key).
del entity_dict["entity_id"]
data["device"]["entities"][entity_entry.entity_id] = {
**async_redact_data(
attr.asdict(
entity_entry, filter=lambda attr, value: attr.name != "entity_id"
),
TO_REDACT,
),
**async_redact_data(entity_dict, TO_REDACT),
"state": state_dict,
}
+10 -10
View File
@@ -2,9 +2,11 @@
from typing import Any
import attr
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.components.diagnostics import (
async_redact_data,
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.const import (
ATTR_CONNECTIONS,
ATTR_IDENTIFIERS,
@@ -39,7 +41,7 @@ async def async_get_config_entry_diagnostics(
return data
data["device"] = {
**async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV),
**async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV),
"entities": {},
"tracked_devices": [],
}
@@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics(
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
entity_dict = entity_entry_as_dict(entity_entry)
# The entity_id is already provided at root level (the key).
del entity_dict["entity_id"]
data["device"]["entities"][entity_entry.entity_id] = {
**async_redact_data(
attr.asdict(
entity_entry, filter=lambda attr, value: attr.name != "entity_id"
),
TO_REDACT,
),
**async_redact_data(entity_dict, TO_REDACT),
"state": state_dict,
}
@@ -43,6 +43,32 @@ ENTITY_PLATFORMS = {
}
def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str:
"""Resolve a device automation device id, following a composite device id.
A device automation created when a device could be connected to more than one
config entry stores the id of the (now removed) composite device. When the
automation's domain owns one of the split devices' config entries, resolve to that
device - an integration may look the device up in its own registry, which only
knows the current device id, not the removed composite id.
"""
device_registry = dr.async_get(hass)
if device_id in device_registry.devices:
return device_id
if not (
split_devices := device_registry.async_get_devices_for_composite_device_id(
device_id
)
):
return device_id
# Resolve to the device owned by a config entry of the automation's domain
for split_device in split_devices:
entry = hass.config_entries.async_get_entry(split_device.config_entry_id)
if entry is not None and entry.domain == domain:
return split_device.id
return device_id
async def async_validate_device_automation_config(
hass: HomeAssistant,
config: ConfigType,
@@ -51,6 +77,17 @@ async def async_validate_device_automation_config(
) -> ConfigType:
"""Validate config."""
validated_config: ConfigType = automation_schema(config)
# A device automation may reference a pre-migration composite device id; resolve it
# to the split device for its domain so the device and its entities exist and the
# integration platform (validation and attach/call) receives a live device id
resolved_device_id = _resolve_device_id(
hass, validated_config[CONF_DEVICE_ID], validated_config[CONF_DOMAIN]
)
if resolved_device_id != validated_config[CONF_DEVICE_ID]:
config = {**config, CONF_DEVICE_ID: resolved_device_id}
validated_config = {**validated_config, CONF_DEVICE_ID: resolved_device_id}
platform = await async_get_device_automation_platform(
hass, validated_config[CONF_DOMAIN], automation_type
)
@@ -36,9 +36,14 @@ from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import format_unserializable_data
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
from .util import async_redact_data, entity_entry_as_dict
from .util import async_redact_data, device_entry_as_dict, entity_entry_as_dict
__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
__all__ = [
"REDACTED",
"async_redact_data",
"device_entry_as_dict",
"entity_entry_as_dict",
]
_LOGGER = logging.getLogger(__name__)
@@ -6,6 +6,7 @@ from typing import Any, cast, overload
import attr
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import RegistryEntry
from .const import REDACTED
@@ -45,6 +46,33 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
return cast(_T, redacted)
# DeviceEntry attributes that are internal bookkeeping and must not be exposed in
# diagnostics. Underscore attributes (_cache, _suggested_area, and the transient
# _pending_move / _composite_subentries) are excluded separately by _device_entry_filter.
# The composite-device migration attributes below can be removed in HA Core 2027.8.
_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = (
"composite_device_id",
"composite_primary_config_entry",
"has_composite_identifiers",
"split_at",
)
def _device_entry_filter(a: attr.Attribute, _: Any) -> bool:
return (
not a.name.startswith("_") and a.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES
)
@callback
def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]:
"""Convert a device registry entry to a dict for diagnostics.
This excludes internal fields that should not be exposed in diagnostics.
"""
return attr.asdict(entry, filter=_device_entry_filter)
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
return a.name not in (
"_cache",
@@ -13,7 +13,7 @@ async def async_setup_entry(
"""Set up a config entry."""
device_registry = dr.async_get(hass)
if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}):
device_registry.async_clear_config_entry(entry.entry_id)
device_registry.async_clear_config_entry(entry.entry_id, entry.domain)
coordinator = DwdWeatherWarningsCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
@@ -5,11 +5,14 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponse
from attr import asdict
from pyenphase.envoy import Envoy
from pyenphase.exceptions import EnvoyError
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
from homeassistant.components.diagnostics import (
async_redact_data,
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@@ -119,10 +122,7 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context", None)
entity_dict = entity_entry_as_dict(entity)
entities.append({"entity": entity_dict, "state": state_dict})
device_dict = asdict(device)
device_dict.pop("_cache", None)
# This can be removed when suggested_area is removed from DeviceEntry
device_dict.pop("_suggested_area")
device_dict = device_entry_as_dict(device)
device_entities.append({"device": device_dict, "entities": entities})
# remove envoy serial
@@ -2,9 +2,10 @@
from typing import Any
from attr import asdict
from homeassistant.components.diagnostics import entity_entry_as_dict
from homeassistant.components.diagnostics import (
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -53,7 +54,7 @@ async def async_get_config_entry_diagnostics(
{"entry": entity_entry_as_dict(entity_entry), "state": state_dict}
)
devices.append({"device": asdict(device), "entities": entities})
devices.append({"device": device_entry_as_dict(device), "entities": entities})
return {
"coordinator_data": coordinator.data.to_dict(),
@@ -3,9 +3,11 @@
from dataclasses import asdict
from typing import Any
import attr
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
from homeassistant.components.diagnostics import (
async_redact_data,
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -75,7 +77,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str,
# Gather information how this device is represented in Home Assistant
entity_registry = er.async_get(hass)
data = async_redact_data(attr.asdict(device), REDACT_CONFIG)
data = async_redact_data(device_entry_as_dict(device), REDACT_CONFIG)
data["entities"] = []
entities: list[dict[str, Any]] = data["entities"]
+10 -7
View File
@@ -2,9 +2,11 @@
from typing import Any
import attr
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.components.diagnostics import (
async_redact_data,
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -41,7 +43,7 @@ async def async_get_config_entry_diagnostics(
assert hass_device is not None
data["device"] = {
**attr.asdict(hass_device),
**device_entry_as_dict(hass_device),
"entities": {},
}
@@ -61,10 +63,11 @@ async def async_get_config_entry_diagnostics(
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
entity_dict = entity_entry_as_dict(entity_entry)
# The entity_id is already provided at root level (the key).
del entity_dict["entity_id"]
data["device"]["entities"][entity_entry.entity_id] = {
**attr.asdict(
entity_entry, filter=lambda attr, value: attr.name != "entity_id"
),
**entity_dict,
"state": state_dict,
}
@@ -244,7 +244,7 @@ async def async_migrate_entry(
# 1 -> 2: Unique ID format changed, so delete and re-import:
if version == 1:
dev_reg = dr.async_get(hass)
dev_reg.async_clear_config_entry(config_entry.entry_id)
dev_reg.async_clear_config_entry(config_entry.entry_id, config_entry.domain)
en_reg = er.async_get(hass)
en_reg.async_clear_config_entry(config_entry.entry_id)
@@ -708,13 +708,11 @@ async def async_migrate_entry(
updated,
)
# version 1.2 -> 1.3: move each chat's notify entity onto its own per-chat device
# (linked to the bot device) and strip the chat subentries from the bot device, leaving
# it associated with only (entry, None).
# version 1.2 -> 1.3: give each chat its own device, linked to the shared bot device,
# and make sure the bot device is tied to (entry, None).
if version == 1 and config_entry.minor_version < 3:
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
# Up to 1.2 the entry has a single device, the bot device, shared by every chat
devices = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
@@ -738,18 +736,16 @@ async def async_migrate_entry(
config_entry_id=config_entry.entry_id,
config_subentry_id=subentry_id,
identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")},
via_device=(DOMAIN, bot_id),
via_device_id=bot_device.id,
)
if entity := notify_entities.get(subentry_id):
entity_registry.async_update_entity(
entity.entity_id, device_id=per_chat_device.id
)
# Strip this chat's subentry from the bot device, leaving (entry, None)
device_registry.async_update_device(
bot_device.id,
remove_config_entry_id=config_entry.entry_id,
remove_config_subentry_id=subentry_id,
)
# Hand the bot device back to (entry, None), keeping the event entity
device_registry.async_update_device(
bot_device.id, new_config_subentry_id=None
)
hass.config_entries.async_update_entry(config_entry, minor_version=3)
return True
@@ -2,9 +2,10 @@
from typing import Any
from attr import asdict
from homeassistant.components.diagnostics import entity_entry_as_dict
from homeassistant.components.diagnostics import (
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -45,7 +46,7 @@ async def async_get_config_entry_diagnostics(
{"entry": entity_entry_as_dict(entity), "state": state_dict}
)
devices.append({"device": asdict(device), "entities": entities})
devices.append({"device": device_entry_as_dict(device), "entities": entities})
return {
"entry": config_entry.as_dict(),
+26 -15
View File
@@ -850,17 +850,22 @@ async def async_setup_entry(
if new_devices:
device_registry = dr.async_get(hass)
for device_id in new_devices:
if device := device_registry.async_get_device({(DOMAIN, device_id)}):
if any(
(
config_entry := hass.config_entries.async_get_entry(
config_entry_id
)
# The same sub-device can be reported by several config entries, each
# owning its own device registry entry. Its sensors share a unique id
# across config entries, so only create them if no other loaded config
# entry already provides them.
if any(
(
config_entry := hass.config_entries.async_get_entry(
device.config_entry_id
)
and config_entry.state is ConfigEntryState.LOADED
for config_entry_id in device.config_entries
):
continue
)
and config_entry.state is ConfigEntryState.LOADED
for device in device_registry.devices.get_entries(
identifiers={(DOMAIN, device_id)}
)
):
continue
async_add_entities(
WithingsDeviceSensor(device_coordinator, description, device_id)
for description in DEVICE_SENSORS
@@ -870,11 +875,17 @@ async def async_setup_entry(
if old_devices:
device_registry = dr.async_get(hass)
for device_id in old_devices:
if device := device_registry.async_get_device({(DOMAIN, device_id)}):
device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
current_devices.remove(device_id)
# Several config entries can share this identifier, each owning its own
# device registry entry, so only remove this entry's own device.
for device in device_registry.devices.get_entries(
identifiers={(DOMAIN, device_id)}
):
if device.config_entry_id == entry.entry_id:
device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
break
current_devices.remove(device_id)
device_coordinator.async_add_listener(_async_device_listener)
+10 -2
View File
@@ -2133,6 +2133,7 @@ class ConfigEntries:
self._hass_config = hass_config
self._entries = ConfigEntryItems(hass)
self._store = ConfigEntryStore(hass)
self._initialized = asyncio.Event()
EntityRegistryDisabledHandler(hass).async_setup()
@callback
@@ -2277,7 +2278,7 @@ class ConfigEntries:
dev_reg = dr.async_get(self.hass)
ent_reg = er.async_get(self.hass)
dev_reg.async_clear_config_entry(entry_id)
dev_reg.async_clear_config_entry(entry_id, entry.domain)
ent_reg.async_clear_config_entry(entry_id)
# If the configuration entry is removed during reauth, it should
@@ -2302,6 +2303,7 @@ class ConfigEntries:
if config is None:
self._entries = ConfigEntryItems(self.hass)
self._initialized.set()
return
entries: ConfigEntryItems = ConfigEntryItems(self.hass)
@@ -2341,6 +2343,12 @@ class ConfigEntries:
EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries
)
self._initialized.set()
async def async_wait_initialized(self) -> None:
"""Wait until the config entries are loaded from storage."""
await self._initialized.wait()
async def _async_scan_orphan_ignored_entries(
self, event: Event[NoEventData]
) -> None:
@@ -2686,7 +2694,7 @@ class ConfigEntries:
dev_reg = dr.async_get(self.hass)
ent_reg = er.async_get(self.hass)
dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id)
dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id, entry.domain)
ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id)
return result
File diff suppressed because it is too large Load Diff
+112 -27
View File
@@ -934,9 +934,14 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]):
Also maintains a count of enabled entries per config entry id.
"""
def __init__(self) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the container."""
super().__init__()
# hass is stored only so get_entries_for_device_id can expand a pre-migration
# composite device id to its split devices. Remove it, and restore the no-argument
# constructor, once the device registry deprecation period is over and composite
# device ids are no longer resolved.
self._hass = hass
self._entry_ids: dict[str, RegistryEntry] = {}
self._index: dict[tuple[str, str, str], str] = {}
self._config_entry_id_index: RegistryIndexType = defaultdict(dict)
@@ -1002,13 +1007,44 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]):
return self._entry_ids.get(key)
def get_entries_for_device_id(
self, device_id: str, include_disabled_entities: bool = False
self,
device_id: str,
include_disabled_entities: bool = False,
) -> list[RegistryEntry]:
"""Get entries for device."""
"""Get entries for device.
A device_id may be a pre-migration composite device id, which was split into one
device per config entry. The entries of the split devices are included, so a
lookup by the old composite id still finds the entities that were moved to the
split devices.
"""
data = self.data
device_registry = dr.async_get(self._hass)
if device_id in device_registry.devices:
# Fast path: a live device id resolves directly to its own entities
return [
entry
for key in self._device_id_index.get(device_id, ())
if not (entry := data[key]).disabled_by or include_disabled_entities
]
# A pre-migration composite device id resolves to the entities of the split
# devices it was migrated into. device_id is kept in the list because the slow
# path is also hit for a device that was just removed (no longer in
# device_registry.devices) whose entities still need to be found - e.g. when the
# entity registry prunes the entities of a removed device.
device_ids = [
device_id,
*(
device.id
for device in device_registry.async_get_devices_for_composite_device_id(
device_id
)
),
]
return [
entry
for key in self._device_id_index.get(device_id, ())
for a_device_id in device_ids
for key in self._device_id_index.get(a_device_id, ())
if not (entry := data[key]).disabled_by or include_disabled_entities
]
@@ -1629,36 +1665,32 @@ class EntityRegistry(BaseRegistry):
changes = event.data["changes"]
# Remove entities which belong to config entries no longer associated with the
# device
if old_config_entries := changes.get("config_entries"):
# Remove entities which belong to the config entry the device no longer belongs
# to. changes carries the old config_entry_id only when it changed (a move).
if "config_entry_id" in changes:
old_config_entry_id = changes["config_entry_id"]
entities = async_entries_for_device(
self, event.data["device_id"], include_disabled_entities=True
)
for entity in entities:
config_entry_id = entity.config_entry_id
if (
entity.config_entry_id in old_config_entries
and entity.config_entry_id not in device.config_entries
entity.config_entry_id == old_config_entry_id
and entity.config_entry_id != device.config_entry_id
):
self.async_remove(entity.entity_id)
# Remove entities which belong to config subentries no longer
# associated with the device
if old_config_entries_subentries := changes.get("config_entries_subentries"):
# Remove entities which belong to the config subentry the device no longer
# belongs to. changes carries the old config_subentry_id only when it changed.
if "config_subentry_id" in changes:
old_config_subentry_id = changes["config_subentry_id"]
entities = async_entries_for_device(
self, event.data["device_id"], include_disabled_entities=True
)
for entity in entities:
config_entry_id = entity.config_entry_id
config_subentry_id = entity.config_subentry_id
if (
config_entry_id in device.config_entries
and config_entry_id in old_config_entries_subentries
and config_subentry_id
in old_config_entries_subentries[config_entry_id]
and config_subentry_id
not in device.config_entries_subentries[config_entry_id]
entity.config_entry_id == device.config_entry_id
and entity.config_subentry_id == old_config_subentry_id
and entity.config_subentry_id != device.config_subentry_id
):
self.async_remove(entity.entity_id)
@@ -2011,16 +2043,53 @@ class EntityRegistry(BaseRegistry):
async def _async_load(self) -> None:
"""Load the entity registry."""
# Device registry must be loaded before entity registry because
# migration and entity processing reference device names.
await dr.async_get(self.hass).async_wait_loaded()
# migration and entity processing reference device names, and because entities
# are moved to the correct device when a pre-migration composite device was
# split into one device per config entry.
device_registry = dr.async_get(self.hass)
await device_registry.async_wait_loaded()
_async_setup_cleanup(self.hass, self)
_async_setup_entity_restore(self.hass, self)
data = await self._store.async_load()
entities = EntityRegistryItems()
entities = EntityRegistryItems(self.hass)
deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {}
# Move entities to the correct device when a pre-migration composite device was
# split into one device per config entry. This can be removed 12 months after
# the config entries split migration ships.
migrated_composite_device = False
def _split_device_id(
device_id: str | None,
config_entry_id: str | None,
config_subentry_id: str | None,
) -> str | None:
"""Map a device id to the split device matching the entity's config entry."""
# Note: check container membership, not async_get, which returns a restored
# composite for a composite device id
if device_id is None or device_id in device_registry.devices:
return device_id
successors = device_registry.async_get_devices_for_composite_device_id(
device_id
)
if not successors:
# The device is gone (e.g. the migration dropped a device with no config
# entry) and was not split; detach the entity rather than leave it pointing
# at a device id that no longer exists.
return None
for successor in successors:
if (
successor.config_entry_id == config_entry_id
and successor.config_subentry_id == config_subentry_id
):
return successor.id
for successor in successors:
if successor.config_entry_id == config_entry_id:
return successor.id
return successors[0].id
if data is not None:
for entity in data["entities"]:
try:
@@ -2048,11 +2117,19 @@ class EntityRegistry(BaseRegistry):
)
continue
device_id = _split_device_id(
entity["device_id"],
entity["config_entry_id"],
entity["config_subentry_id"],
)
if device_id != entity["device_id"]:
migrated_composite_device = True
original_name_unprefixed = _unprefix_original_name(
self.hass,
entity["original_name"],
entity["has_entity_name"],
entity["device_id"],
device_id,
)
entities[entity["entity_id"]] = RegistryEntry(
@@ -2065,7 +2142,7 @@ class EntityRegistry(BaseRegistry):
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
device_class=entity["device_class"],
device_id=entity["device_id"],
device_id=device_id,
disabled_by=RegistryEntryDisabler(entity["disabled_by"])
if entity["disabled_by"]
else None,
@@ -2164,6 +2241,10 @@ class EntityRegistry(BaseRegistry):
self.entities = entities
self._entities_data = entities.data
# Persist entities moved off a split pre-migration composite device
if migrated_composite_device:
self.async_schedule_save()
@override
def _data_to_save(self) -> dict[str, Any]:
"""Return data of entity registry to store in a file."""
@@ -2300,7 +2381,11 @@ async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
def async_entries_for_device(
registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False
) -> list[RegistryEntry]:
"""Return entries that match a device."""
"""Return entries that match a device.
A pre-migration composite device id resolves to the entries of the devices it was
split into.
"""
return registry.entities.get_entries_for_device_id(
device_id, include_disabled_entities
)
+12 -2
View File
@@ -206,8 +206,19 @@ def async_extract_referenced_entity_ids(
selected.missing_areas.add(area_id)
for device_id in target_selection.device_ids:
if device_id not in dev_reg.devices:
if device_id in dev_reg.devices:
selected.referenced_devices.add(device_id)
elif split_devices := dev_reg.async_get_devices_for_composite_device_id(
device_id
):
# A multi config entry composite device id is no longer a device itself;
# it resolves to the devices it was split into so actions targeting it
# still trickle down. Only the splits are referenced, not the composite id,
# so a device-id consumer does not act on the same underlying device twice.
selected.referenced_devices.update(device.id for device in split_devices)
else:
selected.missing_devices.add(device_id)
selected.referenced_devices.add(device_id)
if target_selection.label_ids:
label_reg = lr.async_get(hass)
@@ -234,7 +245,6 @@ def async_extract_referenced_entity_ids(
)
selected.referenced_areas.update(target_selection.area_ids)
selected.referenced_devices.update(target_selection.device_ids)
if not selected.referenced_areas and not selected.referenced_devices:
return selected
+4
View File
@@ -11,6 +11,7 @@ from homeassistant import runner
from homeassistant.auth import auth_manager_from_config
from homeassistant.auth.providers import homeassistant as hass_auth
from homeassistant.config import get_default_config_dir
from homeassistant.config_entries import ConfigEntries
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -55,6 +56,9 @@ def run(args: Sequence[str] | None) -> None:
async def run_command(args: argparse.Namespace) -> None:
"""Run the command."""
hass = HomeAssistant(os.path.join(os.getcwd(), args.config))
hass.config_entries = ConfigEntries(hass, {})
# The device registry migration waits for the config entries to load
await hass.config_entries.async_initialize()
dr.async_setup(hass)
await asyncio.gather(dr.async_load(hass), er.async_load(hass))
hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], [])
+2
View File
@@ -300,6 +300,8 @@ async def async_check_config(config_dir):
hass = core.HomeAssistant(config_dir)
loader.async_setup(hass)
hass.config_entries = ConfigEntries(hass, {})
# The device registry migration waits for the config entries to load
await hass.config_entries.async_initialize()
dr.async_setup(hass)
await ar.async_load(hass)
await dr.async_load(hass)
+8 -1
View File
@@ -204,7 +204,14 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None:
},
)
device_registry = mock_device_registry(
hass, {"mock-dev-id": DeviceEntry(id="mock-dev-id", area_id="mock-area-id")}
hass,
{
"mock-dev-id": DeviceEntry(
config_entry_id="mock-config-entry",
id="mock-dev-id",
area_id="mock-area-id",
)
},
)
policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}}
+3 -2
View File
@@ -292,6 +292,7 @@ async def async_test_home_assistant(
)
},
)
hass.config_entries._initialized.set()
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP,
hass.config_entries._async_shutdown,
@@ -677,7 +678,7 @@ def mock_registry(
if mock_entries is None:
mock_entries = {}
registry.deleted_entities = {}
registry.entities = er.EntityRegistryItems()
registry.entities = er.EntityRegistryItems(hass)
registry._entities_data = registry.entities.data
for key, entry in mock_entries.items():
registry.entities[key] = entry
@@ -763,7 +764,7 @@ def mock_device_registry(
mock_entries = {}
for key, entry in mock_entries.items():
registry.devices[key] = entry
registry.deleted_devices = dr.DeviceRegistryItems()
registry.deleted_devices = dr.DeletedDeviceRegistryItems()
hass.data[dr.DATA_REGISTRY] = registry
return registry
+28 -16
View File
@@ -158,7 +158,9 @@ async def test_invalid_parameters(
"""Test invalid service parameters."""
device_entry = dr.DeviceEntry(
id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
config_entry_id=mock_config_entry.entry_id,
id=TEST_DEVICE_1_ID,
identifiers={(DOMAIN, TEST_DEVICE_1_SN)},
)
mock_device_registry(
hass,
@@ -214,7 +216,9 @@ async def test_invalid_info_skillparameters(
"""Test invalid info skill service parameters."""
device_entry = dr.DeviceEntry(
id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
config_entry_id=mock_config_entry.entry_id,
id=TEST_DEVICE_1_ID,
identifiers={(DOMAIN, TEST_DEVICE_1_SN)},
)
mock_device_registry(
hass,
@@ -278,21 +282,21 @@ async def test_config_entry_not_loaded(
async def test_invalid_config_entry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that a non-existing entry ID in device config entries is skipped."""
"""Test that a device pointing to a non-existing config entry ID is skipped."""
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
device_entry = dr.DeviceEntry(
config_entry_id="non_existing_entry_id",
id=TEST_DEVICE_1_ID,
identifiers={(DOMAIN, TEST_DEVICE_1_SN)},
)
assert device_entry
device_entry.config_entries.clear()
device_entry.config_entries.add("non_existing_entry_id")
mock_device_registry(
hass,
{device_entry.id: device_entry},
)
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
@@ -300,14 +304,14 @@ async def test_invalid_config_entry(
"send_sound",
{
ATTR_SOUND: "bell_02",
ATTR_DEVICE_ID: device_entry.id,
ATTR_DEVICE_ID: TEST_DEVICE_1_ID,
},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "config_entry_not_found"
assert exc_info.value.translation_placeholders == {"device_id": device_entry.id}
assert exc_info.value.translation_placeholders == {"device_id": TEST_DEVICE_1_ID}
async def test_missing_config_entry(
@@ -316,7 +320,7 @@ async def test_missing_config_entry(
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test missing config entry."""
"""Test that a device not owned by an Alexa config entry is rejected."""
await setup_integration(hass, mock_config_entry)
@@ -325,7 +329,15 @@ async def test_missing_config_entry(
)
assert device_entry
device_entry.config_entries.clear()
# Move the device to a config entry from a different integration
other_entry = MockConfigEntry(domain="other_domain", data={})
other_entry.add_to_hass(hass)
device_registry.async_update_device(
device_entry.id, add_config_entry_id=other_entry.entry_id
)
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=mock_config_entry.entry_id
)
# Call Service
with pytest.raises(ServiceValidationError) as exc_info:
+1 -1
View File
@@ -716,7 +716,7 @@ async def test_migration_from_v2_1_to_v2_2(
device_1 = device_registry.async_update_device(
device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None
)
assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}}
assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}}
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
+6 -2
View File
@@ -331,10 +331,14 @@ def target_calendars(
label_on_devices = label_registry.async_create("label_on_devices")
device_calendar_1 = dr.DeviceEntry(
id="device_calendar_1", labels=[label_on_devices.label_id]
config_entry_id="mock-config-entry",
id="device_calendar_1",
labels=[label_on_devices.label_id],
)
device_calendar_2 = dr.DeviceEntry(
id="device_calendar_2", labels=[label_on_devices.label_id]
config_entry_id="mock-config-entry",
id="device_calendar_2",
labels=[label_on_devices.label_id],
)
mock_device_registry(
hass,
+6 -1
View File
@@ -90,7 +90,12 @@ async def target_entities(
"Test Label"
)
device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
device = dr.DeviceEntry(
config_entry_id=config_entry.entry_id,
id="test_device",
area_id=area.id,
labels={label.label_id},
)
mock_device_registry(hass, {device.id: device})
entity_reg = er.async_get(hass)
+57 -55
View File
@@ -61,6 +61,8 @@ async def test_list_devices(
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
"config_entry_id": entry.entry_id,
"config_subentry_id": None,
"configuration_url": None,
"connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]],
"created_at": utcnow().timestamp(),
@@ -84,6 +86,8 @@ async def test_list_devices(
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
"config_entry_id": entry.entry_id,
"config_subentry_id": None,
"configuration_url": None,
"connections": [],
"created_at": utcnow().timestamp(),
@@ -119,6 +123,8 @@ async def test_list_devices(
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
"config_entry_id": entry.entry_id,
"config_subentry_id": None,
"configuration_url": None,
"connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]],
"created_at": utcnow().timestamp(),
@@ -307,7 +313,7 @@ async def test_remove_config_entry_from_device(
entry_2.supports_remove_device = True
entry_2.add_to_hass(hass)
device_registry.async_get_or_create(
device_entry_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
@@ -315,11 +321,14 @@ async def test_remove_config_entry_from_device(
config_entry_id=entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id}
# Identifiers and connections are unique per config entry, so the two config
# entries get separate devices even though they share a connection
assert device_entry_1.id != device_entry.id
assert device_entry.config_entries == {entry_2.entry_id}
# Try removing a config entry from the device, it should fail because
# Try removing the config entry from the device, it should fail because
# async_remove_config_entry_device returns False
response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -327,26 +336,21 @@ async def test_remove_config_entry_from_device(
# Make async_remove_config_entry_device return True
can_remove = True
# Remove the 1st config entry
response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
assert response["success"]
assert response["result"]["config_entries"] == [entry_2.entry_id]
# Check that the config entry was removed from the device
assert device_registry.async_get(device_entry.id).config_entries == {
entry_2.entry_id
}
# Remove the 2nd config entry
# Remove the config entry, this was the device's only config entry so the
# device is removed
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert response["success"]
assert response["result"] is None
# This was the last config entry, the device is removed
# This was the only config entry, the device is removed
assert not device_registry.async_get(device_entry.id)
# The device belonging to the other config entry is untouched
assert device_registry.async_get(device_entry_1.id).config_entries == {
entry_1.entry_id
}
async def test_remove_config_entry_from_device_fails(
hass: HomeAssistant,
@@ -396,38 +400,38 @@ async def test_remove_config_entry_from_device_fails(
entry_3.supports_remove_device = True
entry_3.add_to_hass(hass)
device_registry.async_get_or_create(
device_entry_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
device_registry.async_get_or_create(
device_entry_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
device_entry = device_registry.async_get_or_create(
device_entry_3 = device_registry.async_get_or_create(
config_entry_id=entry_3.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert device_entry.config_entries == {
entry_1.entry_id,
entry_2.entry_id,
entry_3.entry_id,
}
# Identifiers and connections are unique per config entry, so each config entry
# gets its own device even though they share a connection
assert device_entry_1.config_entries == {entry_1.entry_id}
assert device_entry_2.config_entries == {entry_2.entry_id}
assert device_entry_3.config_entries == {entry_3.entry_id}
fake_entry_id = "abc123"
assert entry_1.entry_id != fake_entry_id
fake_device_id = "abc123"
assert device_entry.id != fake_device_id
assert device_entry_3.id != fake_device_id
# Try removing a non existing config entry from the device
response = await ws_client.remove_device(device_entry.id, fake_entry_id)
response = await ws_client.remove_device(device_entry_3.id, fake_entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
assert response["error"]["message"] == "Unknown config entry"
# Try removing a config entry which does not support removal from the device
response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
response = await ws_client.remove_device(device_entry_1.id, entry_1.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -443,22 +447,22 @@ async def test_remove_config_entry_from_device_fails(
assert response["error"]["message"] == "Unknown device"
# Try removing a config entry from a device which it's not connected to
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert response["success"]
assert set(response["result"]["config_entries"]) == {
entry_1.entry_id,
entry_3.entry_id,
}
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
response = await ws_client.remove_device(device_entry_3.id, entry_2.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
assert response["error"]["message"] == "Config entry not in device"
# Removing a config entry which supports removal removes the device, since it is
# the device's only config entry
response = await ws_client.remove_device(device_entry_2.id, entry_2.entry_id)
assert response["success"]
assert response["result"] is None
assert not device_registry.async_get(device_entry_2.id)
# Try removing a config entry which can't be loaded from a device - allowed
response = await ws_client.remove_device(device_entry.id, entry_3.entry_id)
response = await ws_client.remove_device(device_entry_3.id, entry_3.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -517,7 +521,7 @@ async def test_remove_config_entry_from_device_if_integration_remove(
entry_2.supports_remove_device = True
entry_2.add_to_hass(hass)
device_registry.async_get_or_create(
device_entry_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
@@ -525,11 +529,14 @@ async def test_remove_config_entry_from_device_if_integration_remove(
config_entry_id=entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id}
# Identifiers and connections are unique per config entry, so the two config
# entries get separate devices even though they share a connection
assert device_entry_1.id != device_entry.id
assert device_entry.config_entries == {entry_2.entry_id}
# Try removing a config entry from the device, it should fail because
# Try removing the config entry from the device, it should fail because
# async_remove_config_entry_device returns False
response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
@@ -537,22 +544,17 @@ async def test_remove_config_entry_from_device_if_integration_remove(
# Make async_remove_config_entry_device return True
can_remove = True
# Remove the 1st config entry
response = await ws_client.remove_device(device_entry.id, entry_1.entry_id)
assert response["success"]
assert response["result"]["config_entries"] == [entry_2.entry_id]
# Check that the config entry was removed from the device
assert device_registry.async_get(device_entry.id).config_entries == {
entry_2.entry_id
}
# Remove the 2nd config entry
# Remove the config entry, this was the device's only config entry so the
# device is removed
response = await ws_client.remove_device(device_entry.id, entry_2.entry_id)
assert response["success"]
assert response["result"] is None
# This was the last config entry, the device is removed
# This was the only config entry, the device is removed
assert not device_registry.async_get(device_entry.id)
# The device belonging to the other config entry is untouched
assert device_registry.async_get(device_entry_1.id).config_entries == {
entry_1.entry_id
}
+8 -27
View File
@@ -137,18 +137,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
derivative_config_entry: MockConfigEntry,
sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test the derivative config entry is removed when the source entity is removed."""
# Add another config entry to the sensor device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test the source device is not removed when the source entity is removed."""
assert await hass.config_entries.async_setup(derivative_config_entry.entry_id)
await hass.async_block_till_done()
@@ -160,15 +152,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id)
# Remove the source sensor's config entry from the device, this removes the
# source sensor
# Remove the source sensor
with patch(
"homeassistant.components.derivative.async_unload_entry",
wraps=derivative.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
)
entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
@@ -177,8 +166,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
derivative_entity_entry = entity_registry.async_get("sensor.my_derivative")
assert derivative_entity_entry.device_id is None
# Check that the derivative config entry is not in the device
# Check that the source device is not removed
sensor_device = device_registry.async_get(sensor_device.id)
assert sensor_device is not None
assert derivative_config_entry.entry_id not in sensor_device.config_entries
# Check that the derivative config entry is not removed
@@ -380,7 +370,7 @@ async def test_migration_1_2(
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test migration from v1.2 removes derivative config entry from device."""
"""Test migration from v1.2 keeps the derivative entity linked to the source device."""
derivative_config_entry = MockConfigEntry(
data={},
@@ -399,22 +389,13 @@ async def test_migration_1_2(
)
derivative_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id
)
# Check preconditions
sensor_device = device_registry.async_get(sensor_device.id)
assert derivative_config_entry.entry_id in sensor_device.config_entries
await hass.config_entries.async_setup(derivative_config_entry.entry_id)
await hass.async_block_till_done()
assert derivative_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# entity is linked to the source device
# Check that the derivative config entry is not on the source device and the
# derivative entity is linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert derivative_config_entry.entry_id not in sensor_device.config_entries
derivative_entity_entry = entity_registry.async_get("sensor.my_derivative")
+145 -1
View File
@@ -1,5 +1,6 @@
"""The test for light device automation."""
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import attr
@@ -11,14 +12,23 @@ from homeassistant import loader
from homeassistant.components import automation, device_automation
from homeassistant.components.device_automation import (
DOMAIN,
DeviceAutomationType,
InvalidDeviceAutomationConfig,
toggle_entity,
)
from homeassistant.components.device_automation.helpers import (
_resolve_device_id,
async_validate_device_automation_config,
)
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound
from homeassistant.requirements import RequirementsNotFound
@@ -1745,3 +1755,137 @@ async def test_async_get_device_automations_platform_reraises_exceptions(
await device_automation.async_get_device_automation_platform(
hass, "test", device_automation.DeviceAutomationType.TRIGGER
)
COMPOSITE_ID = "composite0000000000000000000000"
@pytest.mark.parametrize("load_registries", [False])
async def test_device_automation_resolves_legacy_id(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A device automation legacy id resolves to the split owning its domain's entry.
Automations for an entity platform domain are left as the composite id, which the
restored composite device and async_entries_for_device handle directly.
"""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 10,
"data": {
"devices": [
{
"area_id": "area_1",
"config_entries": [entry_a.entry_id, entry_b.entry_id],
"config_entries_subentries": {
entry_a.entry_id: [None],
entry_b.entry_id: [None],
},
"configuration_url": None,
"connections": [["mac", "12:34:56:ab:cd:ef"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": COMPOSITE_ID,
"identifiers": [["domain_a", "1"], ["domain_b", "1"]],
"labels": ["lab"],
"manufacturer": "man",
"model": "mod",
"name": "composite",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": "custom name",
"primary_config_entry": entry_a.entry_id,
"serial_number": "SERIAL",
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
await er.async_load(hass)
await ar.async_load(hass)
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
by_entry = {
d.config_entry_id: d.id
for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID)
}
# A config-entry domain resolves to the split owning that domain's config entry
assert (
_resolve_device_id(hass, COMPOSITE_ID, "domain_a") == by_entry[entry_a.entry_id]
)
assert (
_resolve_device_id(hass, COMPOSITE_ID, "domain_b") == by_entry[entry_b.entry_id]
)
# An entity platform domain is left unresolved, even when a split has such entities
entity_registry.async_get_or_create(
"light",
"domain_a",
"unique",
config_entry=entry_a,
device_id=by_entry[entry_a.entry_id],
)
assert _resolve_device_id(hass, COMPOSITE_ID, "light") == COMPOSITE_ID
# An unknown domain is returned unchanged
assert _resolve_device_id(hass, COMPOSITE_ID, "not_present") == COMPOSITE_ID
async def test_validate_config_rewrites_composite_device_id(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
fake_integration: None,
) -> None:
"""Validating a device automation rewrites a composite id to its domain's split."""
fake_entry = MockConfigEntry(domain="fake_integration")
fake_entry.add_to_hass(hass)
other_entry = MockConfigEntry(domain="other")
other_entry.add_to_hass(hass)
device_fake = device_registry.async_get_or_create(
config_entry_id=fake_entry.entry_id, identifiers={("fake_integration", "1")}
)
device_other = device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id, identifiers={("other", "1")}
)
entity = entity_registry.async_get_or_create(
"light", "fake_integration", "u", device_id=device_fake.id
)
old_id = "composite00000000000000000000ab"
# Simulate a migration split: both devices carry the pre-migration composite id
device_registry.devices[device_fake.id] = attr.evolve(
device_fake, composite_device_id=old_id
)
device_registry.devices[device_other.id] = attr.evolve(
device_other, composite_device_id=old_id
)
assert old_id not in device_registry.devices
validated = await async_validate_device_automation_config(
hass,
{
"platform": "device",
"domain": "fake_integration",
"device_id": old_id,
"entity_id": entity.entity_id,
"type": "turned_on",
},
vol.Schema(
{vol.Required("device_id"): str, vol.Required("domain"): str},
extra=vol.ALLOW_EXTRA,
),
DeviceAutomationType.TRIGGER,
)
assert validated["device_id"] == device_fake.id
+34
View File
@@ -5,8 +5,10 @@ from datetime import datetime
from homeassistant.components.diagnostics import (
REDACTED,
async_redact_data,
device_entry_as_dict,
entity_entry_as_dict,
)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import RegistryEntry
@@ -88,3 +90,35 @@ def test_entity_entry_as_dict() -> None:
assert result["original_name"] == "Test Sensor"
assert result["supported_features"] == 0
assert result["created_at"] == created
def test_device_entry_as_dict() -> None:
"""Test device_entry_as_dict."""
created = datetime.fromisoformat("2024-01-01T00:00:00+00:00")
entry = DeviceEntry(
config_entry_id="mock-config-entry-id",
created_at=created,
identifiers={("test", "unique123")},
modified_at=created,
name="Test Device",
)
result = device_entry_as_dict(entry)
assert isinstance(result, dict)
# Internal bookkeeping and composite-device migration attributes are excluded
for attribute in (
"_cache",
"_composite_subentries",
"_pending_move",
"_suggested_area",
"composite_device_id",
"composite_primary_config_entry",
"has_composite_identifiers",
"split_at",
):
assert attribute not in result
assert result["config_entry_id"] == "mock-config-entry-id"
assert result["identifiers"] == [["test", "unique123"]]
assert result["name"] == "Test Device"
assert result["created_at"] == created
@@ -30,14 +30,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -57,7 +51,6 @@
'model_id': None,
'name': 'Envoy <<envoyserial>>',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'sw_version': '7.6.175',
}),
@@ -284,14 +277,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -311,7 +298,6 @@
'model_id': None,
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'sw_version': None,
}),
@@ -944,14 +930,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -971,7 +951,6 @@
'model_id': None,
'name': 'Envoy <<envoyserial>>',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'sw_version': '7.6.175',
}),
@@ -1198,14 +1177,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -1225,7 +1198,6 @@
'model_id': None,
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'sw_version': None,
}),
@@ -1918,14 +1890,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -1945,7 +1911,6 @@
'model_id': None,
'name': 'Envoy <<envoyserial>>',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'sw_version': '7.6.175',
}),
@@ -2172,14 +2137,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -2199,7 +2158,6 @@
'model_id': None,
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'sw_version': None,
}),
@@ -2921,14 +2879,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -2948,7 +2900,6 @@
'model_id': None,
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'sw_version': None,
}),
@@ -3491,14 +3442,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
list([
@@ -3522,7 +3467,6 @@
'model_id': None,
'name': 'Envoy <<envoyserial>>',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'sw_version': '7.6.175',
}),
@@ -3844,14 +3788,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -3871,7 +3809,6 @@
'model_id': None,
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'sw_version': None,
}),
@@ -4414,14 +4351,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -4441,7 +4372,6 @@
'model_id': None,
'name': 'Collar 482520020939',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '482520020939',
'sw_version': '3.0.6-D0',
}),
@@ -4725,14 +4655,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -4752,7 +4676,6 @@
'model_id': None,
'name': 'C6 Combiner 482523040549',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '482523040549',
'sw_version': '0.1.20-D1',
}),
@@ -4852,14 +4775,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -4879,7 +4796,6 @@
'model_id': None,
'name': 'Enpower 654321',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '654321',
'sw_version': '1.2.2064_release/20.34',
}),
@@ -5273,14 +5189,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -5300,7 +5210,6 @@
'model_id': None,
'name': 'Envoy <<envoyserial>>',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'sw_version': '7.1.2',
}),
@@ -18167,14 +18076,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -18194,7 +18097,6 @@
'model_id': None,
'name': 'Encharge <<envoyserial>>56',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>56',
'sw_version': '2.6.5973_rel/22.11',
}),
@@ -18543,14 +18445,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -18570,7 +18466,6 @@
'model_id': None,
'name': 'NC1 Fixture',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': None,
'sw_version': '1.2.2064_release/20.34',
}),
@@ -18956,14 +18851,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -18983,7 +18872,6 @@
'model_id': None,
'name': 'NC2 Fixture',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': None,
'sw_version': '1.2.2064_release/20.34',
}),
@@ -19369,14 +19257,8 @@
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'45a36e55aaddb2007c5f6602e0c38e72',
]),
'config_entries_subentries': dict({
'45a36e55aaddb2007c5f6602e0c38e72': list([
None,
]),
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
@@ -19396,7 +19278,6 @@
'model_id': None,
'name': 'NC3 Fixture',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': None,
'sw_version': '1.2.2064_release/20.34',
}),
@@ -242,13 +242,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
"""Test config entry is removed when the source entity is removed."""
source_entity_entry = entity_registry.async_get(source_entity_id)
# Add another config entry to the source device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id
)
assert await hass.config_entries.async_setup(
generic_hygrostat_config_entry.entry_id
)
@@ -266,28 +259,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
hass, generic_hygrostat_entity_entry.entity_id
)
# Remove the source entity's config entry from the device, this removes the
# source entity
# Remove the source entity
with patch(
"homeassistant.components.generic_hygrostat.async_unload_entry",
wraps=generic_hygrostat.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id
)
entity_registry.async_remove(source_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
# Check that the helper entity is linked to the expected source device
switch_entity_entry = entity_registry.async_get("switch.test_unique")
generic_hygrostat_entity_entry = entity_registry.async_get(
"humidifier.my_generic_hygrostat"
)
assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id
# Check if the generic_hygrostat config entry is not in the device
# Check that the source device is not removed and the generic_hygrostat config
# entry is not in the device
source_device = device_registry.async_get(source_device.id)
assert source_device is not None
assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries
# Check that the generic_hygrostat config entry is not removed
@@ -541,7 +532,7 @@ async def test_migration_1_1(
switch_device: dr.DeviceEntry,
switch_entity_entry: er.RegistryEntry,
) -> None:
"""Test migration from v1.1 removes generic_hygrostat config entry from device."""
"""Test migration from v1.1 keeps the helper entity linked to the source device."""
generic_hygrostat_config_entry = MockConfigEntry(
data={},
@@ -560,21 +551,12 @@ async def test_migration_1_1(
)
generic_hygrostat_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id
)
# Check preconditions
switch_device = device_registry.async_get(switch_device.id)
assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries
await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id)
await hass.async_block_till_done()
assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# Check that the helper config entry is not on the source device and the helper
# entity is linked to the source device
switch_device = device_registry.async_get(switch_device.id)
assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries
@@ -247,13 +247,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
"""Test config entry is removed when the source entity is removed."""
source_entity_entry = entity_registry.async_get(source_entity_id)
# Add another config entry to the source device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id
)
assert await hass.config_entries.async_setup(
generic_thermostat_config_entry.entry_id
)
@@ -271,28 +264,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
hass, generic_thermostat_entity_entry.entity_id
)
# Remove the source entity's config entry from the device, this removes the
# source entity
# Remove the source entity
with patch(
"homeassistant.components.generic_thermostat.async_unload_entry",
wraps=generic_thermostat.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id
)
entity_registry.async_remove(source_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
# Check that the helper entity is linked to the expected source device
switch_entity_entry = entity_registry.async_get("switch.test_unique")
generic_thermostat_entity_entry = entity_registry.async_get(
"climate.my_generic_thermostat"
)
assert generic_thermostat_entity_entry.device_id == expected_helper_device_id
# Check if the generic_thermostat config entry is not in the device
# Check that the source device is not removed and the generic_thermostat config
# entry is not in the device
source_device = device_registry.async_get(source_device.id)
assert source_device is not None
assert generic_thermostat_config_entry.entry_id not in source_device.config_entries
# Check that the generic_thermostat config entry is not removed
@@ -554,7 +545,7 @@ async def test_migration_1_1(
switch_device: dr.DeviceEntry,
switch_entity_entry: er.RegistryEntry,
) -> None:
"""Test migration from v1.1 removes generic_thermostat config entry from device."""
"""Test migration from v1.1 keeps the helper entity linked to the source device."""
generic_thermostat_config_entry = MockConfigEntry(
data={},
@@ -573,21 +564,12 @@ async def test_migration_1_1(
)
generic_thermostat_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id
)
# Check preconditions
switch_device = device_registry.async_get(switch_device.id)
assert generic_thermostat_config_entry.entry_id in switch_device.config_entries
await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id)
await hass.async_block_till_done()
assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# Check that the helper config entry is not on the source device and the helper
# entity is linked to the source device
switch_device = device_registry.async_get(switch_device.id)
assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries
@@ -755,7 +755,7 @@ async def test_migration_from_v1_with_same_keys(
(
{"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None},
[],
{"mock_entry_id": {None, "mock_id_1"}},
{"mock_entry_id": {"mock_id_1"}},
),
# Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1:
# Wrong device registry, TTS subentry created
@@ -770,7 +770,7 @@ async def test_migration_from_v1_with_same_keys(
unique_id=None,
)
],
{"mock_entry_id": {None, "mock_id_1"}},
{"mock_entry_id": {"mock_id_1"}},
),
# Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2
# or later: Correct device registry, TTS subentry created
@@ -259,6 +259,7 @@
dict({
'device': dict({
'area_id': None,
'config_subentry_id': None,
'configuration_url': None,
'connections': list([
]),
+7 -26
View File
@@ -173,18 +173,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
history_stats_config_entry: MockConfigEntry,
sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test config entry is removed when source entity is removed."""
# Add another config entry to the sensor device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test config entry is removed when the source entity is removed."""
assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id)
await hass.async_block_till_done()
@@ -196,15 +188,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id)
# Remove the source sensor's config entry from the device, this removes the
# source sensor
# Remove the source sensor
with patch(
"homeassistant.components.history_stats.async_unload_entry",
wraps=history_stats.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
)
entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_called_once()
@@ -212,8 +201,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
# Check that the helper entity is removed
assert not entity_registry.async_get("sensor.my_history_stats")
# Check that the history_stats config entry is not in the device
# Check that the source device is not removed
sensor_device = device_registry.async_get(sensor_device.id)
assert sensor_device is not None
assert history_stats_config_entry.entry_id not in sensor_device.config_entries
# Check that the history_stats config entry is removed
@@ -383,7 +373,7 @@ async def test_migration_1_1(
sensor_entity_entry: er.RegistryEntry,
sensor_device: dr.DeviceEntry,
) -> None:
"""Test migration from v1.1 removes history_stats config entry from device."""
"""Test migration from v1.1 keeps the history_stats entity linked to the source device."""
history_stats_config_entry = MockConfigEntry(
data={},
@@ -402,21 +392,12 @@ async def test_migration_1_1(
)
history_stats_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id
)
# Check preconditions
sensor_device = device_registry.async_get(sensor_device.id)
assert history_stats_config_entry.entry_id in sensor_device.config_entries
await hass.config_entries.async_setup(history_stats_config_entry.entry_id)
await hass.async_block_till_done()
assert history_stats_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# Check that the helper config entry is not on the source device and the helper
# entity is linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert history_stats_config_entry.entry_id not in sensor_device.config_entries
+4 -1
View File
@@ -196,7 +196,10 @@ async def test_remove_stale_device(
assert len(device_entries) == 2
assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries)
assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries)
assert any(
# Identifiers are unique per config entry, so Honeywell and OtherDomain have
# separate devices for 7654321; Honeywell's devices do not carry the OtherDomain
# identifier
assert not any(
("OtherDomain", 7654321) in device.identifiers for device in device_entries
)
assert len(device_entries_other) == 1
+9 -26
View File
@@ -266,18 +266,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
integration_config_entry: MockConfigEntry,
sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test config entry is removed when source entity is removed."""
# Add another config entry to the sensor device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test the source entity is removed but the source device is not removed."""
assert await hass.config_entries.async_setup(integration_config_entry.entry_id)
await hass.async_block_till_done()
@@ -289,15 +281,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, integration_entity_entry.entity_id)
# Remove the source sensor's config entry from the device, this removes the
# source sensor
# Remove the source entity, this does not remove the source device
with patch(
"homeassistant.components.integration.async_unload_entry",
wraps=integration.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
)
entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
@@ -306,6 +295,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
integration_entity_entry = entity_registry.async_get("sensor.my_integration")
assert integration_entity_entry.device_id is None
# Check that the source device is not removed
assert device_registry.async_get(sensor_device.id) is not None
# Check that the integration config entry is not in the device
sensor_device = device_registry.async_get(sensor_device.id)
assert integration_config_entry.entry_id not in sensor_device.config_entries
@@ -471,7 +463,7 @@ async def test_migration_1_1(
sensor_entity_entry: er.RegistryEntry,
sensor_device: dr.DeviceEntry,
) -> None:
"""Test migration from v1.1 removes integration config entry from device."""
"""Test migration from v1.1 keeps the helper entity linked to the source device."""
integration_config_entry = MockConfigEntry(
data={},
@@ -491,22 +483,13 @@ async def test_migration_1_1(
)
integration_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=integration_config_entry.entry_id
)
# Check preconditions
sensor_device = device_registry.async_get(sensor_device.id)
assert integration_config_entry.entry_id in sensor_device.config_entries
await hass.config_entries.async_setup(integration_config_entry.entry_id)
await hass.async_block_till_done()
assert integration_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# entity is linked to the source device
# Check that the helper config entry is not in the device and the helper entity
# is linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert integration_config_entry.entry_id not in sensor_device.config_entries
integration_entity_entry = entity_registry.async_get("sensor.my_integration")
+11 -28
View File
@@ -274,16 +274,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
expected_helper_device_id: str | None,
expected_events: list[str],
) -> None:
"""Test config entry removed when the source entity is removed."""
"""Test the source entity is removed but the source device is not removed."""
source_entity_entry = entity_registry.async_get(source_entity_id)
# Add another config entry to the source device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id
)
assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id)
await hass.async_block_till_done()
@@ -297,15 +290,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id)
# Remove the source entity's config entry from the device, this removes the
# source entity
# Remove the source entity, this does not remove the source device
with patch(
"homeassistant.components.mold_indicator.async_unload_entry",
wraps=mold_indicator.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id
)
entity_registry.async_remove(source_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
@@ -314,6 +304,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator")
assert mold_indicator_entity_entry.device_id == expected_helper_device_id
# Check that the source device is not removed
assert device_registry.async_get(source_device.id) is not None
# Check if the mold_indicator config entry is not in the device
source_device = device_registry.async_get(source_device.id)
assert mold_indicator_config_entry.entry_id not in source_device.config_entries
@@ -533,7 +526,7 @@ async def test_migration_1_1(
indoor_temperature_entity_entry: er.RegistryEntry,
outdoor_temperature_entity_entry: er.RegistryEntry,
) -> None:
"""Test migration from v1.1 removes mold_indicator config entry from device."""
"""Test migration from v1.1 keeps the helper entity linked to the source device."""
mold_indicator_config_entry = MockConfigEntry(
data={},
@@ -551,25 +544,15 @@ async def test_migration_1_1(
)
mold_indicator_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
indoor_humidity_device.id,
add_config_entry_id=mold_indicator_config_entry.entry_id,
)
# Check preconditions
switch_device = device_registry.async_get(indoor_humidity_device.id)
assert mold_indicator_config_entry.entry_id in switch_device.config_entries
await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id)
await hass.async_block_till_done()
assert mold_indicator_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# entity is linked to the source device
switch_device = device_registry.async_get(switch_device.id)
assert mold_indicator_config_entry.entry_id not in switch_device.config_entries
# Check that the helper config entry is not in the device and the helper entity
# is linked to the source device
source_device = device_registry.async_get(indoor_humidity_device.id)
assert mold_indicator_config_entry.entry_id not in source_device.config_entries
mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator")
assert (
mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id
+50 -17
View File
@@ -70,6 +70,21 @@ from tests.typing import (
WebSocketGenerator,
)
def _get_device_for_config_entry(
device_registry: dr.DeviceRegistry,
config_entry_id: str,
*,
identifiers: set[tuple[str, str]] | None = None,
connections: set[tuple[str, str]] | None = None,
) -> dr.DeviceEntry | None:
"""Return the device for a config entry matching identifiers or connections."""
for device in device_registry.devices.get_entries(identifiers, connections):
if device.config_entry_id == config_entry_id:
return device
return None
TEST_SINGLE_CONFIGS = [
(
"homeassistant/device_automation/0AFFD2/bla1/config",
@@ -2047,15 +2062,24 @@ async def test_cleanup_device_multiple_config_entries(
)
await hass.async_block_till_done()
# Verify device and registry entries are created
device_entry = device_registry.async_get_device(
connections={("mac", "12:34:56:AB:CD:EF")}
)
assert device_entry is not None
assert device_entry.config_entries == {
# Verify device and registry entries are created. Identifiers and connections are
# unique per config entry, so MQTT discovery creates a separate device owned by the
# MQTT config entry, sharing the connection with the pre-existing device
mqtt_device_entry = _get_device_for_config_entry(
device_registry,
mqtt_config_entry.entry_id,
config_entry.entry_id,
}
connections={("mac", "12:34:56:AB:CD:EF")},
)
assert mqtt_device_entry is not None
assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id}
assert (
_get_device_for_config_entry(
device_registry,
config_entry.entry_id,
connections={("mac", "12:34:56:AB:CD:EF")},
)
is not None
)
entity_entry = entity_registry.async_get("sensor.mqtt_sensor")
assert entity_entry is not None
@@ -2065,7 +2089,7 @@ async def test_cleanup_device_multiple_config_entries(
# Remove MQTT from the device
mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0]
response = await ws_client.remove_device(
device_entry.id, mqtt_config_entry.entry_id
mqtt_device_entry.id, mqtt_config_entry.entry_id
)
assert response["success"]
@@ -2165,15 +2189,24 @@ async def test_cleanup_device_multiple_config_entries_mqtt(
)
await hass.async_block_till_done()
# Verify device and registry entries are created
device_entry = device_registry.async_get_device(
connections={("mac", "12:34:56:AB:CD:EF")}
)
assert device_entry is not None
assert device_entry.config_entries == {
# Verify device and registry entries are created. Identifiers and connections are
# unique per config entry, so MQTT discovery creates a separate device owned by the
# MQTT config entry, sharing the connection with the pre-existing device
mqtt_device_entry = _get_device_for_config_entry(
device_registry,
mqtt_config_entry.entry_id,
config_entry.entry_id,
}
connections={("mac", "12:34:56:AB:CD:EF")},
)
assert mqtt_device_entry is not None
assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id}
assert (
_get_device_for_config_entry(
device_registry,
config_entry.entry_id,
connections={("mac", "12:34:56:AB:CD:EF")},
)
is not None
)
entity_entry = entity_registry.async_get("sensor.mqtt_sensor")
assert entity_entry is not None
+45 -10
View File
@@ -46,6 +46,20 @@ DEFAULT_TAG_SCAN_JSON = (
)
def _get_device_for_config_entry(
device_registry: dr.DeviceRegistry,
config_entry_id: str,
*,
identifiers: set[tuple[str, str]] | None = None,
connections: set[tuple[str, str]] | None = None,
) -> dr.DeviceEntry | None:
"""Return the device for a config entry matching identifiers or connections."""
for device in device_registry.devices.get_entries(identifiers, connections):
if device.config_entry_id == config_entry_id:
return device
return None
@pytest.mark.no_fail_on_log_exception
async def test_discover_bad_tag(
hass: HomeAssistant,
@@ -570,24 +584,45 @@ async def test_cleanup_tag(
async_fire_mqtt_message(hass, "homeassistant/tag/bla2/config", data2)
await hass.async_block_till_done()
# Verify device registry entries are created
device_entry1 = device_registry.async_get_device(
identifiers={("mqtt", "helloworld")}
# Verify device registry entries are created. Identifiers are unique per config
# entry, so the test config entry and MQTT get separate "helloworld" devices
device_entry1 = _get_device_for_config_entry(
device_registry,
config_entry.entry_id,
identifiers={("mqtt", "helloworld")},
)
assert device_entry1 is not None
assert device_entry1.config_entries == {config_entry.entry_id, mqtt_entry.entry_id}
assert device_entry1.config_entries == {config_entry.entry_id}
mqtt_device_entry1 = _get_device_for_config_entry(
device_registry,
mqtt_entry.entry_id,
identifiers={("mqtt", "helloworld")},
)
assert mqtt_device_entry1 is not None
assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id}
device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")})
assert device_entry2 is not None
# Remove other config entry from the device
# Removing the test config entry deletes its device; the MQTT device is untouched
# and MQTT does not clear its discovery topic
device_registry.async_update_device(
device_entry1.id, remove_config_entry_id=config_entry.entry_id
)
device_entry1 = device_registry.async_get_device(
identifiers={("mqtt", "helloworld")}
assert (
_get_device_for_config_entry(
device_registry,
config_entry.entry_id,
identifiers={("mqtt", "helloworld")},
)
is None
)
assert device_entry1 is not None
assert device_entry1.config_entries == {mqtt_entry.entry_id}
mqtt_device_entry1 = _get_device_for_config_entry(
device_registry,
mqtt_entry.entry_id,
identifiers={("mqtt", "helloworld")},
)
assert mqtt_device_entry1 is not None
assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id}
device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")})
assert device_entry2 is not None
mqtt_mock.async_publish.assert_not_called()
@@ -595,7 +630,7 @@ async def test_cleanup_tag(
# Remove MQTT from the device
mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0]
response = await ws_client.remove_device(
device_entry1.id, mqtt_config_entry.entry_id
mqtt_device_entry1.id, mqtt_config_entry.entry_id
)
assert response["success"]
await hass.async_block_till_done()
+1 -1
View File
@@ -732,7 +732,7 @@ async def test_migration_from_v2_1(
device_1 = device_registry.async_update_device(
device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None
)
assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}}
assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}}
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
@@ -1278,7 +1278,7 @@ async def test_migration_from_v2_1(
device_1 = device_registry.async_update_device(
device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None
)
assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}}
assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}}
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
-25
View File
@@ -200,31 +200,6 @@ async def test_service_set_kvs_value(
mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value")
async def test_service_get_kvs_value_config_entry_not_found(
hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry
) -> None:
"""Test device with no config entries."""
entry = await init_integration(hass, 2)
device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0]
# Remove all config entries from device
device_registry.devices[device.id].config_entries.clear()
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_GET_KVS_VALUE,
{ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"},
blocking=True,
return_response=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "config_entry_not_found"
assert exc_info.value.translation_placeholders == {"device_id": device.id}
async def test_service_get_kvs_value_device_not_initialized(
hass: HomeAssistant,
mock_rpc_device: Mock,
@@ -28,7 +28,7 @@
'model_id': None,
'name': None,
'name_by_user': None,
'primary_config_entry': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
+9 -26
View File
@@ -158,18 +158,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
statistics_config_entry: MockConfigEntry,
sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test the statistics config entry is removed when the source entity is removed."""
# Add another config entry to the sensor device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test the source entity is removed but the source device is not removed."""
assert await hass.config_entries.async_setup(statistics_config_entry.entry_id)
await hass.async_block_till_done()
@@ -181,15 +173,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id)
# Remove the source sensor's config entry from the device, this removes the
# source sensor
# Remove the source entity, this does not remove the source device
with patch(
"homeassistant.components.statistics.async_unload_entry",
wraps=statistics.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
)
entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_called_once()
@@ -197,6 +186,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
# Check that the helper entity is removed
assert not entity_registry.async_get("sensor.my_statistics")
# Check that the source device is not removed
assert device_registry.async_get(sensor_device.id) is not None
# Check that the statistics config entry is not in the device
sensor_device = device_registry.async_get(sensor_device.id)
assert statistics_config_entry.entry_id not in sensor_device.config_entries
@@ -362,7 +354,7 @@ async def test_migration_1_1(
sensor_entity_entry: er.RegistryEntry,
sensor_device: dr.DeviceEntry,
) -> None:
"""Test migration from v1.1 removes statistics config entry from device."""
"""Test migration from v1.1 keeps the helper entity linked to the source device."""
statistics_config_entry = MockConfigEntry(
data={},
@@ -382,22 +374,13 @@ async def test_migration_1_1(
)
statistics_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id
)
# Check preconditions
sensor_device = device_registry.async_get(sensor_device.id)
assert statistics_config_entry.entry_id in sensor_device.config_entries
await hass.config_entries.async_setup(statistics_config_entry.entry_id)
await hass.async_block_till_done()
assert statistics_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# entity is linked to the source device
# Check that the helper config entry is not in the device and the helper entity
# is linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert statistics_config_entry.entry_id not in sensor_device.config_entries
statistics_entity_entry = entity_registry.async_get("sensor.my_statistics")
+5 -27
View File
@@ -208,12 +208,6 @@ async def test_device_registry_config_entry_1(
device_id=device_entry.id,
original_name="ABC",
)
# Add another config entry to the same device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
device_entry.id, add_config_entry_id=other_config_entry.entry_id
)
switch_as_x_config_entry = MockConfigEntry(
data={},
@@ -246,15 +240,12 @@ async def test_device_registry_config_entry_1(
async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event)
# Remove the wrapped switch's config entry from the device, this removes the
# wrapped switch
# Remove the wrapped switch, this removes the switch_as_x config entry
with patch(
"homeassistant.components.switch_as_x.async_unload_entry",
wraps=switch_as_x.async_unload_entry,
) as mock_setup_entry:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=switch_config_entry.entry_id
)
entity_registry.async_remove(switch_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_setup_entry.assert_called_once()
@@ -1134,9 +1125,6 @@ async def test_migrate(
minor_version=1,
)
config_entry.add_to_hass(hass)
device_registry.async_update_device(
device_entry.id, add_config_entry_id=config_entry.entry_id
)
switch_as_x_entity_entry = entity_registry.async_get_or_create(
target_domain,
"switch_as_x",
@@ -1179,19 +1167,9 @@ async def test_migrate(
assert hass.states.get(f"{target_domain}.abc") is not None
assert entity_registry.async_get(f"{target_domain}.abc") is not None
# Entity removed from device to prevent deletion, then added back to device
assert events == [
{
"action": "update",
"changes": {"device_id": device_entry.id},
"entity_id": switch_as_x_entity_entry.entity_id,
},
{
"action": "update",
"changes": {"device_id": None},
"entity_id": switch_as_x_entity_entry.entity_id,
},
]
# The switch_as_x config entry was never added to the device, so migration does
# not change the switch_as_x entity's device link
assert events == []
@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST)
+56 -15
View File
@@ -23,6 +23,20 @@ from tests.common import MockConfigEntry, async_fire_mqtt_message
from tests.typing import MqttMockHAClient, WebSocketGenerator
def _get_device_for_config_entry(
device_registry: dr.DeviceRegistry,
config_entry_id: str,
*,
identifiers: set[tuple[str, str]] | None = None,
connections: set[tuple[str, str]] | None = None,
) -> dr.DeviceEntry | None:
"""Return the device for a config entry matching identifiers or connections."""
for device in device_registry.devices.get_entries(identifiers, connections):
if device.config_entry_id == config_entry_id:
return device
return None
async def test_subscribing_config_topic(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
@@ -324,12 +338,21 @@ async def test_device_remove_multiple_config_entries_1(
)
await hass.async_block_till_done()
# Verify device entry is created
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
# Verify device entry is created. Identifiers and connections are unique per config
# entry, so Tasmota discovery creates a separate device sharing the connection
tasmota_device_entry = _get_device_for_config_entry(
device_registry,
tasmota_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
assert device_entry is not None
assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id}
assert tasmota_device_entry is not None
assert tasmota_device_entry.config_entries == {tasmota_entry.entry_id}
mock_device_entry = _get_device_for_config_entry(
device_registry,
mock_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
assert mock_device_entry is not None
async_fire_mqtt_message(
hass,
@@ -338,9 +361,19 @@ async def test_device_remove_multiple_config_entries_1(
)
await hass.async_block_till_done()
# Verify device entry is not removed
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
# Verify the Tasmota device is removed, but the other config entry's device is not
assert (
_get_device_for_config_entry(
device_registry,
tasmota_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
is None
)
device_entry = _get_device_for_config_entry(
device_registry,
mock_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
assert device_entry is not None
assert device_entry.config_entries == {mock_entry.entry_id}
@@ -378,21 +411,29 @@ async def test_device_remove_multiple_config_entries_2(
)
await hass.async_block_till_done()
# Verify device entry is created
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
# Verify device entry is created. Identifiers and connections are unique per config
# entry, so Tasmota discovery creates a separate device sharing the connection
device_entry = _get_device_for_config_entry(
device_registry,
tasmota_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
assert device_entry is not None
assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id}
assert device_entry.config_entries == {tasmota_entry.entry_id}
assert other_device_entry.id != device_entry.id
# Remove other config entry from the device
# Remove the config entry from the other (non-Tasmota) device sharing the connection
mock_device_entry = _get_device_for_config_entry(
device_registry,
mock_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=mock_entry.entry_id
mock_device_entry.id, remove_config_entry_id=mock_entry.entry_id
)
await hass.async_block_till_done()
# Verify device entry is not removed
# Verify the Tasmota device entry is not removed
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
+22 -47
View File
@@ -74,6 +74,7 @@ async def test_migrate_entry_from_1_1(
}
@pytest.mark.parametrize("collapsed_chat_index", [0, 1])
@pytest.mark.parametrize(
"chats_without_notify_entity",
[
@@ -86,9 +87,10 @@ async def test_migrate_entry_to_per_chat_devices(
mock_external_calls: None,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
collapsed_chat_index: int,
chats_without_notify_entity: tuple[int, ...],
) -> None:
"""Test migrating a shared bot device to per-chat devices."""
"""Test migrating chats sharing one bot device to per-chat devices."""
bot_id = 123456 # test_user id from mock_external_calls
chat_ids = (123456, 654321)
config_entry = MockConfigEntry(
@@ -119,22 +121,13 @@ async def test_migrate_entry_to_per_chat_devices(
config_entry.add_to_hass(hass)
subentry_ids = list(config_entry.subentries)
# Pre-migration state: one shared bot device associated with the config entry (None)
# and every chat subentry, holding the event entity and every chat's notify entity.
# Post-store-migration state: one shared bot device collapsed onto an arbitrary chat
# subentry, holding the event entity and every surviving chat's notify entity.
bot_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id=subentry_ids[collapsed_chat_index],
identifiers={(DOMAIN, str(bot_id))},
)
for subentry_id in subentry_ids:
bot_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id=subentry_id,
identifiers={(DOMAIN, str(bot_id))},
)
assert bot_device.config_entries_subentries == {
config_entry.entry_id: {None, *subentry_ids}
}
event_entity = entity_registry.async_get_or_create(
"event",
DOMAIN,
@@ -161,33 +154,26 @@ async def test_migrate_entry_to_per_chat_devices(
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.minor_version == 3
# Each chat has its own device, owned by that chat's subentry and linked to the bot
# device.
chat_devices = {
chat_id: device_registry.async_get_device(
# Every chat has its own device - owned by that subentry and linked to the bot device -
# even a chat whose notify entity was deleted before the migration ran. A surviving
# notify entity is moved onto its chat's device.
for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True):
chat_device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{bot_id}_{chat_id}")}
)
for chat_id in chat_ids
}
for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True):
chat_device = chat_devices[chat_id]
assert chat_device is not None
assert chat_device.config_entries_subentries == {
config_entry.entry_id: {subentry_id}
}
assert chat_device.config_subentry_id == subentry_id
assert chat_device.via_device_id == bot_device.id
if chat_id in notify_entities:
assert (
entity_registry.async_get(notify_entities[chat_id].entity_id).device_id
== chat_device.id
)
# Every notify entity that survived is moved onto its chat's device
for chat_id, notify_entity in notify_entities.items():
assert (
entity_registry.async_get(notify_entity.entity_id).device_id
== chat_devices[chat_id].id
)
# The bot device ends up associated with only (entry, None), keeping the event entity
# The bot device was handed back to the config entry, keeping the event entity
bot_device = device_registry.async_get(bot_device.id)
assert bot_device is not None
assert bot_device.config_entries_subentries == {config_entry.entry_id: {None}}
assert bot_device.config_subentry_id is None
assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id
@@ -203,34 +189,23 @@ async def test_per_chat_devices(
await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id)
await hass.async_block_till_done()
entry_id = mock_broadcast_config_entry.entry_id
# The bot device belongs to the config entry (no subentry) and holds the event entity
bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")})
assert bot_device is not None
assert bot_device.config_entries_subentries == {entry_id: {None}}
assert bot_device.name == "Mock Title"
assert bot_device.config_subentry_id is None
for chat_id, chat_name in ((123456, "mock chat 1"), (654321, "mock chat 2")):
subentry_id = next(
sid
for sid, subentry in mock_broadcast_config_entry.subentries.items()
if subentry.data[CONF_CHAT_ID] == chat_id
)
for chat_id in (123456, 654321):
chat_device = device_registry.async_get_device(
identifiers={(DOMAIN, f"123456_{chat_id}")}
)
assert chat_device is not None
assert chat_device.config_entries_subentries == {entry_id: {subentry_id}}
assert chat_device.config_subentry_id is not None
assert chat_device.via_device_id == bot_device.id
# The device is named after the chat, and its notify entity takes the device name
assert chat_device.name == chat_name
notify_entity_id = entity_registry.async_get_entity_id(
"notify", DOMAIN, f"123456_{chat_id}"
)
assert notify_entity_id is not None
assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id
assert hass.states.get(notify_entity_id).name == chat_name
async def test_remove_chat_subentry_removes_per_chat_device(
+2 -11
View File
@@ -532,7 +532,7 @@ async def test_migration_1_1(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from v1.1 removes template config entry from device."""
"""Test migration from v1.1 does not add the template config entry to the device."""
device_config_entry = MockConfigEntry()
device_config_entry.add_to_hass(hass)
@@ -557,21 +557,12 @@ async def test_migration_1_1(
)
template_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
device_entry.id, add_config_entry_id=template_config_entry.entry_id
)
# Check preconditions
device_entry = device_registry.async_get(device_entry.id)
assert template_config_entry.entry_id in device_entry.config_entries
await hass.config_entries.async_setup(template_config_entry.entry_id)
await hass.async_block_till_done()
assert template_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# Check that the helper config entry is not in the device and the helper
# entity is linked to the source device
device_entry = device_registry.async_get(device_entry.id)
assert template_config_entry.entry_id not in device_entry.config_entries
+9 -26
View File
@@ -265,18 +265,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
threshold_config_entry: MockConfigEntry,
sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test the threshold config entry is removed when the source entity is removed."""
# Add another config entry to the sensor device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test the source entity is removed but the source device is not removed."""
assert await hass.config_entries.async_setup(threshold_config_entry.entry_id)
await hass.async_block_till_done()
@@ -288,15 +280,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id)
# Remove the source sensor's config entry from the device, this removes the
# source sensor
# Remove the source entity, this does not remove the source device
with patch(
"homeassistant.components.threshold.async_unload_entry",
wraps=threshold.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
)
entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
@@ -305,6 +294,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold")
assert threshold_entity_entry.device_id is None
# Check that the source device is not removed
assert device_registry.async_get(sensor_device.id) is not None
# Check that the threshold config entry is not in the device
sensor_device = device_registry.async_get(sensor_device.id)
assert threshold_config_entry.entry_id not in sensor_device.config_entries
@@ -470,7 +462,7 @@ async def test_migration_1_1(
sensor_entity_entry: er.RegistryEntry,
sensor_device: dr.DeviceEntry,
) -> None:
"""Test migration from v1.1 removes threshold config entry from device."""
"""Test migration from v1.1 keeps the helper entity linked to the source device."""
threshold_config_entry = MockConfigEntry(
data={},
@@ -488,22 +480,13 @@ async def test_migration_1_1(
)
threshold_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id
)
# Check preconditions
sensor_device = device_registry.async_get(sensor_device.id)
assert threshold_config_entry.entry_id in sensor_device.config_entries
await hass.config_entries.async_setup(threshold_config_entry.entry_id)
await hass.async_block_till_done()
assert threshold_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# entity is linked to the source device
# Check that the helper config entry is not in the device and the helper entity
# is linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert threshold_config_entry.entry_id not in sensor_device.config_entries
threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold")
+6 -2
View File
@@ -93,8 +93,12 @@ def target_todo_lists(
label_list_one = label_registry.async_create("label_list_one")
label_list_two = label_registry.async_create("label_list_two")
device_list_one = dr.DeviceEntry(id="device_list_one")
device_list_two = dr.DeviceEntry(id="device_list_two")
device_list_one = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device_list_one"
)
device_list_two = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device_list_two"
)
mock_device_registry(
hass,
{
+9 -26
View File
@@ -190,18 +190,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
trend_config_entry: MockConfigEntry,
sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test the trend config entry is removed when the source entity is removed."""
# Add another config entry to the sensor device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test the source entity is removed but the source device is not removed."""
assert await hass.config_entries.async_setup(trend_config_entry.entry_id)
await hass.async_block_till_done()
@@ -213,15 +205,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
events = track_entity_registry_actions(hass, trend_entity_entry.entity_id)
# Remove the source sensor's config entry from the device, this removes the
# source sensor
# Remove the source entity, this does not remove the source device
with patch(
"homeassistant.components.trend.async_unload_entry",
wraps=trend.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
)
entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_called_once()
@@ -229,6 +218,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
# Check that the helper entity is removed
assert not entity_registry.async_get("binary_sensor.my_trend")
# Check that the source device is not removed
assert device_registry.async_get(sensor_device.id) is not None
# Check that the trend config entry is not in the device
sensor_device = device_registry.async_get(sensor_device.id)
assert trend_config_entry.entry_id not in sensor_device.config_entries
@@ -394,7 +386,7 @@ async def test_migration_1_1(
sensor_entity_entry: er.RegistryEntry,
sensor_device: dr.DeviceEntry,
) -> None:
"""Test migration from v1.1 removes trend config entry from device."""
"""Test migration from v1.1 keeps the helper entity linked to the source device."""
trend_config_entry = MockConfigEntry(
data={},
@@ -410,22 +402,13 @@ async def test_migration_1_1(
)
trend_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=trend_config_entry.entry_id
)
# Check preconditions
sensor_device = device_registry.async_get(sensor_device.id)
assert trend_config_entry.entry_id in sensor_device.config_entries
await hass.config_entries.async_setup(trend_config_entry.entry_id)
await hass.async_block_till_done()
assert trend_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# entity is linked to the source device
# Check that the helper config entry is not in the device and the helper entity
# is linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert trend_config_entry.entry_id not in sensor_device.config_entries
trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend")
+8 -27
View File
@@ -651,19 +651,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
utility_meter_config_entry: MockConfigEntry,
sensor_config_entry: ConfigEntry,
sensor_device: dr.DeviceEntry,
sensor_entity_entry: er.RegistryEntry,
expected_entities: set[str],
) -> None:
"""Test config entry is removed when the source entity is removed."""
# Add another config entry to the sensor device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test the source entity is removed while the source device survives."""
assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id)
await hass.async_block_till_done()
@@ -682,15 +674,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
sensor_device = device_registry.async_get(sensor_device.id)
assert utility_meter_config_entry.entry_id not in sensor_device.config_entries
# Remove the source sensor's config entry from the device, this removes the
# source sensor
# Remove the source sensor
with patch(
"homeassistant.components.utility_meter.async_unload_entry",
wraps=utility_meter.async_unload_entry,
) as mock_unload_entry:
device_registry.async_update_device(
sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id
)
entity_registry.async_remove(sensor_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
mock_unload_entry.assert_not_called()
@@ -703,8 +692,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d
):
assert utility_meter_entity.device_id is None
# Check that the utility_meter config entry is not in the device
# Check that the source device survives and does not contain the utility_meter
# config entry
sensor_device = device_registry.async_get(sensor_device.id)
assert sensor_device is not None
assert utility_meter_config_entry.entry_id not in sensor_device.config_entries
# Check that the utility_meter config entry is not removed
@@ -962,7 +953,7 @@ async def test_migration_2_1(
tariffs: list[str],
expected_entities: set[str],
) -> None:
"""Test migration from v2.1 removes utility_meter config entry from device."""
"""Test migration from v2.1 does not add the utility_meter config entry to the device."""
utility_meter_config_entry = MockConfigEntry(
data={},
@@ -983,25 +974,15 @@ async def test_migration_2_1(
)
utility_meter_config_entry.add_to_hass(hass)
# Add the helper config entry to the device
device_registry.async_update_device(
sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id
)
# Check preconditions
sensor_device = device_registry.async_get(sensor_device.id)
assert utility_meter_config_entry.entry_id in sensor_device.config_entries
await hass.config_entries.async_setup(utility_meter_config_entry.entry_id)
await hass.async_block_till_done()
assert utility_meter_config_entry.state is ConfigEntryState.LOADED
# Check that the helper config entry is removed from the device and the helper
# Check that the helper config entry is not in the device and the helper
# entities are linked to the source device
sensor_device = device_registry.async_get(sensor_device.id)
assert utility_meter_config_entry.entry_id not in sensor_device.config_entries
# Check that the entities are linked to the other device
entities = set()
for (
utility_meter_entity
+4
View File
@@ -201,6 +201,10 @@ async def test_migration_from_v1(
"sensor_entity_id": (
"sensor.not_de_jongweg_utrecht_air_quality_index"
),
# Device 2 was created enabled; the migration moves it onto the
# disabled merged config entry, so the move re-evaluates it as disabled
# by CONFIG_ENTRY (the entity keeps its own disabled_by - propagating a
# move-disable to entities is a separate mechanism)
"device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY,
"entity_disabled_by": None,
"device": 1,
@@ -127,15 +127,30 @@ async def target_entities(
area_registry.async_update(label_area.id, labels={label1.label_id})
device1 = dr.DeviceEntry(id="device1", identifiers={("test", "device1")})
device2 = dr.DeviceEntry(id="device2", identifiers={("test", "device2")})
device1 = dr.DeviceEntry(
config_entry_id=config_entry.entry_id,
id="device1",
identifiers={("test", "device1")},
)
device2 = dr.DeviceEntry(
config_entry_id=config_entry.entry_id,
id="device2",
identifiers={("test", "device2")},
)
area_device = dr.DeviceEntry(
id="area_device", identifiers={("test", "device3")}, area_id=kitchen_area.id
config_entry_id=config_entry.entry_id,
id="area_device",
identifiers={("test", "device3")},
area_id=kitchen_area.id,
)
label2_device = dr.DeviceEntry(
id="label_device", identifiers={("test", "device4")}, labels={label2.label_id}
config_entry_id=config_entry.entry_id,
id="label_device",
identifiers={("test", "device4")},
labels={label2.label_id},
)
diag_only_device = dr.DeviceEntry(
config_entry_id=config_entry.entry_id,
id="diag_only_device",
identifiers={("test", "device5")},
area_id=garage_area.id,
+55
View File
@@ -449,3 +449,58 @@ async def test_device_two_config_entries(
await hass.async_block_till_done()
assert "Platform withings does not generate unique IDs" not in caplog.text
async def test_old_device_removal_only_removes_own_device(
hass: HomeAssistant,
withings: AsyncMock,
polling_config_entry: MockConfigEntry,
second_polling_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
device_registry: dr.DeviceRegistry,
) -> None:
"""Removing an old device only removes the processing entry's own device.
Two config entries can each own a device registry entry for the same shared sub-device.
When the sub-device disappears from one entry, it must remove its own device, not
another entry's device sharing the identifier.
"""
identifiers = {(DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d")}
def _device_for_entry(entry: MockConfigEntry) -> dr.DeviceEntry | None:
return next(
(
device
for device in device_registry.devices.get_entries(
identifiers=identifiers
)
if device.config_entry_id == entry.entry_id
),
None,
)
# The first entry creates the sub-device and owns its device registry entry.
await setup_integration(hass, polling_config_entry, False)
assert _device_for_entry(polling_config_entry) is not None
# Unload it, then set up a second entry: with the first entry unloaded it no longer
# provides the sub-device, so the second entry creates and owns its own device.
await hass.config_entries.async_unload(polling_config_entry.entry_id)
await hass.async_block_till_done()
second_polling_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(second_polling_config_entry.entry_id)
await hass.async_block_till_done()
assert _device_for_entry(polling_config_entry) is not None
assert _device_for_entry(second_polling_config_entry) is not None
# The sub-device disappears from the (still loaded) second entry's data.
withings.get_devices.return_value = []
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Only the second entry's own device was removed; the first entry's remains.
assert _device_for_entry(second_polling_config_entry) is None
assert _device_for_entry(polling_config_entry) is not None
+2 -1
View File
@@ -233,8 +233,9 @@ async def test_migration_merges_duplicate_v1_entries(
wolf_mock.return_value.fetch_system_list.side_effect = RequestError(
"Unable to connect"
)
# Setting up the first entry loads the integration, which sets up and migrates
# every wolflink entry: the first becomes the hub and the second merges into it.
await hass.config_entries.async_setup(first_entry.entry_id)
await second_entry.async_migrate(hass)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
File diff suppressed because it is too large Load Diff
+471 -179
View File
@@ -554,6 +554,39 @@ async def test_entity_registry_loading_waits_for_device_registry(
assert registry.async_get("test.my_entity") is not None
@pytest.mark.parametrize("load_registries", [False])
async def test_entity_load_detaches_from_dropped_device(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""An entity referencing a device that no longer exists is detached on load.
The device migration drops a device with no config entry; an entity that pointed at
it must be detached rather than left on a removed device id.
"""
hass_storage[er.STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"data": {
"entities": [
{
"entity_id": "test.my_entity",
"device_id": "gone-device",
"platform": "test_platform",
"unique_id": "unique-1",
},
]
},
}
dr.async_setup(hass)
await asyncio.gather(er.async_load(hass), dr.async_load(hass))
registry = er.async_get(hass)
entity = registry.async_get("test.my_entity")
assert entity is not None
assert entity.device_id is None
def test_get_available_entity_id_considers_registered_entities(
entity_registry: er.EntityRegistry,
) -> None:
@@ -1813,6 +1846,12 @@ async def test_migration_1_21(
"area_id": None,
"config_entries": ["mock_entry"],
"config_entries_subentries": {"mock_entry": [None]},
"config_entry_id": "mock_entry",
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
@@ -2777,66 +2816,59 @@ async def test_remove_config_entry_from_device_removes_entities(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that we remove entities tied to a device when config entry is removed."""
"""Test that we remove entities tied to a device when its config entry is removed."""
config_entry_1 = MockConfigEntry(domain="hue")
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry(domain="device_tracker")
config_entry_2.add_to_hass(hass)
# Create device with two config entries
device_registry.async_get_or_create(
# Same connections on different config entries are separate devices
device_entry_1 = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
device_entry = device_registry.async_get_or_create(
device_entry_2 = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert device_entry.config_entries == {
config_entry_1.entry_id,
config_entry_2.entry_id,
}
assert device_entry_1.id != device_entry_2.id
# Create one entity for each config entry
# Create one entity for each device
entry_1 = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry_1,
device_id=device_entry.id,
device_id=device_entry_1.id,
)
entry_2 = entity_registry.async_get_or_create(
"sensor",
"device_tracker",
"6789",
config_entry=config_entry_2,
device_id=device_entry.id,
device_id=device_entry_2.id,
)
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
# Remove the first config entry from the device, the entity associated with it
# should be removed
# Removing the first config entry removes its device and the tied entity
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry_1.entry_id
device_entry_1.id, remove_config_entry_id=config_entry_1.entry_id
)
await hass.async_block_till_done()
assert device_registry.async_get(device_entry.id)
assert not device_registry.async_get(device_entry_1.id)
assert not entity_registry.async_is_registered(entry_1.entity_id)
assert device_registry.async_get(device_entry_2.id)
assert entity_registry.async_is_registered(entry_2.entity_id)
# Remove the second config entry from the device, the entity associated with it
# (and the device itself) should be removed
# Removing the second config entry removes its device and entity too
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry_2.entry_id
device_entry_2.id, remove_config_entry_id=config_entry_2.entry_id
)
await hass.async_block_till_done()
assert not device_registry.async_get(device_entry.id)
assert not entity_registry.async_is_registered(entry_1.entity_id)
assert not device_registry.async_get(device_entry_2.id)
assert not entity_registry.async_is_registered(entry_2.entity_id)
@@ -2845,72 +2877,148 @@ async def test_remove_config_entry_from_device_removes_entities_2(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test we don't remove entities w/o config entry when device is modified."""
"""Test we don't remove entities not tied to the removed config entry."""
config_entry_1 = MockConfigEntry(domain="hue")
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry(domain="device_tracker")
config_entry_2 = MockConfigEntry(domain="some_helper")
config_entry_2.add_to_hass(hass)
config_entry_3 = MockConfigEntry(domain="some_helper")
config_entry_3.add_to_hass(hass)
# Create device with two config entries
device_registry.async_get_or_create(
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert device_entry.config_entries == {
config_entry_1.entry_id,
config_entry_2.entry_id,
}
# Create an entity without config entry
# An entity without a config entry, tied to the device
entry_1 = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
device_id=device_entry.id,
)
# Create an entity with a config entry not in the device
# An entity with a different config entry, tied to the device
entry_2 = entity_registry.async_get_or_create(
"light",
"some_helper",
"5678",
config_entry=config_entry_3,
config_entry=config_entry_2,
device_id=device_entry.id,
)
assert entry_1.entity_id != entry_2.entity_id
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
# Remove the first config entry from the device
# Removing the device's config entry removes the device
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry_1.entry_id
)
await hass.async_block_till_done()
assert device_registry.async_get(device_entry.id)
# Entities which are not tied to the removed config entry should not be removed
assert not device_registry.async_get(device_entry.id)
# Entities not tied to the removed config entry are kept, but detached
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert entity_registry.async_get(entry_1.entity_id).device_id is None
assert entity_registry.async_get(entry_2.entity_id).device_id is None
# Remove the second config entry from the device (this removes the device)
async def test_move_device_config_entry_removes_old_entry_entities(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Moving a device to another config entry removes the old entry's entities."""
entry_a = MockConfigEntry(domain="hue")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="tado")
entry_b.add_to_hass(hass)
entry_c = MockConfigEntry(domain="some_helper")
entry_c.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("hue", "1")}
)
# An entity owned by the departing entry A, and a helper entity of a third entry C
entry_a_entity = entity_registry.async_get_or_create(
"light", "hue", "a", config_entry=entry_a, device_id=device_entry.id
)
entry_c_entity = entity_registry.async_get_or_create(
"sensor", "some_helper", "c", config_entry=entry_c, device_id=device_entry.id
)
# Move the device from entry A to entry B (an update, not a removal)
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry_2.entry_id
device_entry.id, new_config_entry_id=entry_b.entry_id
)
await hass.async_block_till_done()
assert not device_registry.async_get(device_entry.id)
# Entities which are not tied to a config entry in the device should not be removed
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
# Check the device link is set to None
assert entity_registry.async_get(entry_1.entity_id).device_id is None
assert entity_registry.async_get(entry_2.entity_id).device_id is None
# A no longer owns the device, so A's entity is removed; C's helper is untouched
assert not entity_registry.async_is_registered(entry_a_entity.entity_id)
assert entity_registry.async_is_registered(entry_c_entity.entity_id)
@pytest.mark.parametrize("old_subentry_id", [None, "sub-1"])
async def test_move_device_config_subentry_removes_old_subentry_entities(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
old_subentry_id: str | None,
) -> None:
"""Moving a device to another subentry removes the old subentry's entities.
Includes a departing subentry of None (the main entry): the change is detected by the
old config_subentry_id being present in the event, not by its truthiness.
"""
config_entry = MockConfigEntry(
domain="hue",
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="sub-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="sub-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
],
)
config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id=old_subentry_id,
identifiers={("hue", "1")},
)
# Entity on the departing subentry, and one on the destination subentry sub-2
old_entity = entity_registry.async_get_or_create(
"light",
"hue",
"old",
config_entry=config_entry,
config_subentry_id=old_subentry_id,
device_id=device_entry.id,
)
sub2_entity = entity_registry.async_get_or_create(
"light",
"hue",
"2",
config_entry=config_entry,
config_subentry_id="sub-2",
device_id=device_entry.id,
)
# Move the device to subentry sub-2 (an update, not a removal)
device_registry.async_update_device(device_entry.id, new_config_subentry_id="sub-2")
await hass.async_block_till_done()
# The departing subentry's entity is removed; sub-2's entity is kept
assert not entity_registry.async_is_registered(old_entity.entity_id)
assert entity_registry.async_is_registered(sub2_entity.entity_id)
async def test_remove_config_subentry_from_device_removes_entities(
@@ -2918,7 +3026,7 @@ async def test_remove_config_subentry_from_device_removes_entities(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that we remove entities tied to a device when config subentry is removed."""
"""Test that we remove entities tied to a device when its config subentry is removed."""
config_entry_1 = MockConfigEntry(
domain="hue",
subentries_data=[
@@ -2940,27 +3048,15 @@ async def test_remove_config_subentry_from_device_removes_entities(
)
config_entry_1.add_to_hass(hass)
# Create device with three config subentries
device_registry.async_get_or_create(
# A device belongs to a single config subentry
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
config_subentry_id="mock-subentry-id-1",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
config_subentry_id="mock-subentry-id-2",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert device_entry.config_entries == {config_entry_1.entry_id}
assert device_entry.config_entries_subentries == {
config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"},
}
assert device_entry.config_subentry_id == "mock-subentry-id-1"
# Create one entity entry for each config entry or subentry
# Entity tied to the device's subentry
entry_1 = entity_registry.async_get_or_create(
"light",
"hue",
@@ -2969,7 +3065,7 @@ async def test_remove_config_subentry_from_device_removes_entities(
config_subentry_id="mock-subentry-id-1",
device_id=device_entry.id,
)
# Entity tied to a different subentry of the same config entry
entry_2 = entity_registry.async_get_or_create(
"light",
"hue",
@@ -2978,22 +3074,11 @@ async def test_remove_config_subentry_from_device_removes_entities(
config_subentry_id="mock-subentry-id-2",
device_id=device_entry.id,
)
entry_3 = entity_registry.async_get_or_create(
"sensor",
"device_tracker",
"6789",
config_entry=config_entry_1,
config_subentry_id=None,
device_id=device_entry.id,
)
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert entity_registry.async_is_registered(entry_3.entity_id)
# Remove the first config subentry from the device, the entity associated with it
# should be removed
# Removing the device's config subentry deletes the device; the entity tied to that
# subentry is removed, the entity tied to another subentry is detached
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=config_entry_1.entry_id,
@@ -3001,55 +3086,18 @@ async def test_remove_config_subentry_from_device_removes_entities(
)
await hass.async_block_till_done()
assert device_registry.async_get(device_entry.id)
assert not entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert entity_registry.async_is_registered(entry_3.entity_id)
# Remove the second config subentry from the device, the entity associated with it
# should be removed
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=config_entry_1.entry_id,
remove_config_subentry_id=None,
)
await hass.async_block_till_done()
assert device_registry.async_get(device_entry.id)
assert not entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert not entity_registry.async_is_registered(entry_3.entity_id)
# Remove the third config subentry from the device, the entity associated with it
# (and the device itself) should be removed
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=config_entry_1.entry_id,
remove_config_subentry_id="mock-subentry-id-2",
)
await hass.async_block_till_done()
assert not device_registry.async_get(device_entry.id)
assert not entity_registry.async_is_registered(entry_1.entity_id)
assert not entity_registry.async_is_registered(entry_2.entity_id)
assert not entity_registry.async_is_registered(entry_3.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert entity_registry.async_get(entry_2.entity_id).device_id is None
@pytest.mark.parametrize(
("subentries_in_device", "subentry_in_entity"),
[
(["mock-subentry-id-1", "mock-subentry-id-2"], None),
([None, "mock-subentry-id-2"], "mock-subentry-id-1"),
],
)
async def test_remove_config_subentry_from_device_removes_entities_2(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
subentries_in_device: list[str | None],
subentry_in_entity: str | None,
) -> None:
"""Test we don't remove entities w/o config entry when device is modified."""
"""Test we don't remove entities not tied to the removed config subentry."""
config_entry_1 = MockConfigEntry(
domain="hue",
subentries_data=[
@@ -3067,95 +3115,49 @@ async def test_remove_config_subentry_from_device_removes_entities_2(
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-3",
subentry_type="test",
title="Mock title",
unique_id="test",
),
],
)
config_entry_1.add_to_hass(hass)
# Create device with two config subentries
device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
config_subentry_id=subentries_in_device[0],
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
config_subentry_id=subentries_in_device[1],
config_subentry_id="mock-subentry-id-1",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert device_entry.config_entries == {config_entry_1.entry_id}
assert device_entry.config_entries_subentries == {
config_entry_1.entry_id: set(subentries_in_device),
}
# Create an entity without config entry or subentry
# An entity without a config entry
entry_1 = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
device_id=device_entry.id,
)
# Create an entity for same config entry but subentry not in device
# An entity tied to a different subentry of the same config entry
entry_2 = entity_registry.async_get_or_create(
"light",
"some_helper",
"5678",
config_entry=config_entry_1,
config_subentry_id=subentry_in_entity,
device_id=device_entry.id,
)
# Create an entity for same config entry but subentry not in device
entry_3 = entity_registry.async_get_or_create(
"light",
"some_helper",
"hue",
"abcd",
config_entry=config_entry_1,
config_subentry_id="mock-subentry-id-3",
config_subentry_id="mock-subentry-id-2",
device_id=device_entry.id,
)
assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert entity_registry.async_is_registered(entry_3.entity_id)
# Remove the first config subentry from the device
# Removing the device's config subentry deletes the device; entities not tied to
# that subentry are kept but detached
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=config_entry_1.entry_id,
remove_config_subentry_id=subentries_in_device[0],
)
await hass.async_block_till_done()
assert device_registry.async_get(device_entry.id)
# Entities with a config subentry not in the device are not removed
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert entity_registry.async_is_registered(entry_3.entity_id)
# Remove the second config subentry from the device, this removes the device
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=config_entry_1.entry_id,
remove_config_subentry_id=subentries_in_device[1],
remove_config_subentry_id="mock-subentry-id-1",
)
await hass.async_block_till_done()
assert not device_registry.async_get(device_entry.id)
# Entities with a config subentry not in the device are not removed
assert entity_registry.async_is_registered(entry_1.entity_id)
assert entity_registry.async_is_registered(entry_2.entity_id)
assert entity_registry.async_is_registered(entry_3.entity_id)
# Check the device link is set to None
assert entity_registry.async_get(entry_1.entity_id).device_id is None
assert entity_registry.async_get(entry_2.entity_id).device_id is None
assert entity_registry.async_get(entry_3.entity_id).device_id is None
async def test_update_device_race(
@@ -3642,9 +3644,9 @@ async def test_resolve_entity_ids(entity_registry: er.EntityRegistry) -> None:
er.async_validate_entity_ids(entity_registry, ["unknown_uuid"])
def test_entity_registry_items() -> None:
async def test_entity_registry_items(hass: HomeAssistant) -> None:
"""Test the EntityRegistryItems container."""
entities = er.EntityRegistryItems()
entities = er.EntityRegistryItems(hass)
assert entities.get_entity_id(("a", "b", "c")) is None
assert entities.get_entry("abc") is None
@@ -5406,3 +5408,293 @@ async def test_subentry(
config_subentry_id="mock-subentry-id-2-1",
)
assert entry.config_subentry_id == "mock-subentry-id-2-1"
COMPOSITE_ID = "composite0000000000000000000000"
def _composite_device_storage(
entry_a: MockConfigEntry, entry_b: MockConfigEntry
) -> dict[str, Any]:
"""Return a v1.10 device registry store with one composite device."""
return {
"version": 1,
"minor_version": 10,
"data": {
"devices": [
{
"area_id": "area_1",
"config_entries": [entry_a.entry_id, entry_b.entry_id],
"config_entries_subentries": {
entry_a.entry_id: [None],
entry_b.entry_id: [None],
},
"configuration_url": None,
"connections": [["mac", "12:34:56:ab:cd:ef"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": COMPOSITE_ID,
"identifiers": [["domain_a", "1"], ["domain_b", "1"]],
"labels": ["lab"],
"manufacturer": "man",
"model": "mod",
"name": "composite",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": "custom name",
"primary_config_entry": entry_a.entry_id,
"serial_number": "SERIAL",
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_repoints_entities(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Entities are moved to the split device matching their config entry."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b)
hass_storage[er.STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"data": {
"entities": [
{
"entity_id": "sensor.a",
"platform": "domain_a",
"unique_id": "a",
"config_entry_id": entry_a.entry_id,
"device_id": COMPOSITE_ID,
},
{
"entity_id": "sensor.b",
"platform": "domain_b",
"unique_id": "b",
"config_entry_id": entry_b.entry_id,
"device_id": COMPOSITE_ID,
},
]
},
}
dr.async_setup(hass)
await dr.async_load(hass)
await er.async_load(hass)
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
by_entry = {
d.config_entry_id: d.id
for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID)
}
assert entity_registry.async_get("sensor.a").device_id == by_entry[entry_a.entry_id]
assert entity_registry.async_get("sensor.b").device_id == by_entry[entry_b.entry_id]
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_repoints_entities_fallbacks(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""An entity not exactly matching a split falls back by config entry, then first split."""
entry_a = MockConfigEntry(
domain="domain_a",
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-sub",
subentry_type="test",
title="t",
unique_id="u",
)
],
)
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
# The split for entry_a is on the "mock-sub" subentry
device_store = _composite_device_storage(entry_a, entry_b)
device_store["data"]["devices"][0]["config_entries_subentries"] = {
entry_a.entry_id: ["mock-sub"],
entry_b.entry_id: [None],
}
hass_storage[dr.STORAGE_KEY] = device_store
hass_storage[er.STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"data": {
"entities": [
{
# config entry matches a split, but the subentry does not
"entity_id": "sensor.sub",
"platform": "domain_a",
"unique_id": "sub",
"config_entry_id": entry_a.entry_id,
"config_subentry_id": None,
"device_id": COMPOSITE_ID,
},
{
# no split matches the config entry (it has none)
"entity_id": "sensor.none",
"platform": "domain_a",
"unique_id": "none",
"config_entry_id": None,
"device_id": COMPOSITE_ID,
},
]
},
}
dr.async_setup(hass)
await dr.async_load(hass)
await er.async_load(hass)
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID)
by_entry = {d.config_entry_id: d.id for d in splits}
# Subentry mismatch falls back to the split owning the entity's config entry
assert (
entity_registry.async_get("sensor.sub").device_id == by_entry[entry_a.entry_id]
)
# No matching config entry falls back to the first split
assert entity_registry.async_get("sensor.none").device_id in {d.id for d in splits}
@pytest.mark.parametrize("load_registries", [False])
async def test_async_entries_for_device_legacy_composite_id(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A legacy composite device id resolves to its split devices' entities."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b)
hass_storage[er.STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"data": {
"entities": [
{
"entity_id": "sensor.a",
"platform": "domain_a",
"unique_id": "a",
"config_entry_id": entry_a.entry_id,
"device_id": COMPOSITE_ID,
},
{
"entity_id": "sensor.b",
"platform": "domain_b",
"unique_id": "b",
"config_entry_id": entry_b.entry_id,
"device_id": COMPOSITE_ID,
},
]
},
}
dr.async_setup(hass)
await dr.async_load(hass)
await er.async_load(hass)
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
# The composite id is no longer a live device; its entities were repointed to splits
assert COMPOSITE_ID not in device_registry.devices
# get_entries_for_device_id resolves the composite id to the split entities
assert {
entry.entity_id
for entry in entity_registry.entities.get_entries_for_device_id(COMPOSITE_ID)
} == {"sensor.a", "sensor.b"}
# The public helper resolves the composite id via the device registry
assert {
entry.entity_id
for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID)
} == {"sensor.a", "sensor.b"}
# Disabled entities are only included when requested, across the split devices
entity_registry.async_update_entity(
"sensor.b", disabled_by=er.RegistryEntryDisabler.USER
)
assert {
entry.entity_id
for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID)
} == {"sensor.a"}
assert {
entry.entity_id
for entry in er.async_entries_for_device(
entity_registry, COMPOSITE_ID, include_disabled_entities=True
)
} == {"sensor.a", "sensor.b"}
# A live split device id returns just its own entity
splits = {
device.config_entry_id: device.id
for device in device_registry.async_get_devices_for_composite_device_id(
COMPOSITE_ID
)
}
assert {
entry.entity_id
for entry in er.async_entries_for_device(
entity_registry, splits[entry_a.entry_id]
)
} == {"sensor.a"}
async def test_async_entries_for_device_composite_id(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""A pre-migration composite id resolves to the underlying devices' entities.
Backwards compatibility for unmodified integrations: before the single-config-entry
rewrite a shared identifier resolved to one multi-config-entry device, so
async_entries_for_device(composite_id) returned all of that device's entities. After
the split, the composite's virtual id must resolve to the same union so a legacy
reference keeps working.
"""
entry_1 = MockConfigEntry(domain="itg1")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="itg2")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("itg1", "1")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")}
)
entity_1 = entity_registry.async_get_or_create(
"sensor", "itg1", "u1", config_entry=entry_1, device_id=device_1.id
)
entity_2 = entity_registry.async_get_or_create(
"sensor", "itg2", "u2", config_entry=entry_2, device_id=device_2.id
)
old_id = "composite00000000000000000000ab"
# Simulate a migration split: both devices carry the pre-migration composite id
device_registry.devices[device_1.id] = attr.evolve(
device_1, composite_device_id=old_id
)
device_registry.devices[device_2.id] = attr.evolve(
device_2, composite_device_id=old_id
)
assert old_id not in device_registry.devices
assert {
entry.entity_id
for entry in er.async_entries_for_device(entity_registry, old_id)
} == {entity_1.entity_id, entity_2.entity_id}
+37 -88
View File
@@ -230,33 +230,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
set_source_entity_id_or_uuid: Mock,
) -> None:
"""Test the helper config entry is removed when the source entity is removed."""
# Add the helper config entry to the source device
device_registry.async_update_device(
source_device.id, add_config_entry_id=helper_config_entry.entry_id
)
# Add another config entry to the source device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
source_device.id, add_config_entry_id=other_config_entry.entry_id
)
assert await hass.config_entries.async_setup(helper_config_entry.entry_id)
await hass.async_block_till_done()
# Check preconditions
# Check preconditions - the helper entity is linked to the source device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_entity_entry.device_id
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
events = track_entity_registry_actions(hass, helper_entity_entry.entity_id)
# Remove the source entitys's config entry from the device, this removes the
# source entity
device_registry.async_update_device(
source_device.id, remove_config_entry_id=source_config_entry.entry_id
)
# Remove the source entity
entity_registry.async_remove(source_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
@@ -267,10 +251,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
async_remove_entry.assert_not_called()
set_source_entity_id_or_uuid.assert_not_called()
# Check that the helper config entry is not removed from the device
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
# Check that the helper config entry is not removed
assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids()
@@ -294,34 +274,18 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h
set_source_entity_id_or_uuid: Mock,
source_entity_removed: AsyncMock,
) -> None:
"""Test the helper config entry is removed when the source entity is removed."""
# Add the helper config entry to the source device
device_registry.async_update_device(
source_device.id, add_config_entry_id=helper_config_entry.entry_id
)
# Add another config entry to the source device
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
device_registry.async_update_device(
source_device.id, add_config_entry_id=other_config_entry.entry_id
)
"""Test the source_entity_removed handler is called when the source entity is removed."""
assert await hass.config_entries.async_setup(helper_config_entry.entry_id)
await hass.async_block_till_done()
# Check preconditions
# Check preconditions - the helper entity is linked to the source device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_entity_entry.device_id
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
events = track_entity_registry_actions(hass, helper_entity_entry.entity_id)
# Remove the source entitys's config entry from the device, this removes the
# source entity
device_registry.async_update_device(
source_device.id, remove_config_entry_id=source_config_entry.entry_id
)
# Remove the source entity
entity_registry.async_remove(source_entity_entry.entity_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
@@ -331,9 +295,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h
async_remove_entry.assert_not_called()
set_source_entity_id_or_uuid.assert_not_called()
# Check that the helper config entry is not removed from the device
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
# Check that the custom handler took over: the helper entity is left linked to the
# source device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_device.id
# Check that the helper config entry is not removed
assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids()
@@ -357,21 +322,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev
set_source_entity_id_or_uuid: Mock,
) -> None:
"""Test the source entity removed from the source device."""
# Add the helper config entry to the source device
device_registry.async_update_device(
source_device.id, add_config_entry_id=helper_config_entry.entry_id
)
assert await hass.config_entries.async_setup(helper_config_entry.entry_id)
await hass.async_block_till_done()
# Check preconditions
# Check preconditions - the helper entity is linked to the source device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_entity_entry.device_id
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
events = track_entity_registry_actions(hass, helper_entity_entry.entity_id)
# Remove the source entity from the device
@@ -381,9 +338,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev
async_unload_entry.assert_called_once()
set_source_entity_id_or_uuid.assert_not_called()
# Check that the helper config entry is removed from the device
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id not in source_device.config_entries
# Check that the helper entity is not linked to the source device anymore
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id is None
# Check that the helper config entry is not removed
assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids()
@@ -408,11 +365,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi
set_source_entity_id_or_uuid: Mock,
) -> None:
"""Test the source entity is moved to another device."""
# Add the helper config entry to the source device
device_registry.async_update_device(
source_device.id, add_config_entry_id=helper_config_entry.entry_id
)
# Create another device to move the source entity to
source_device_2 = device_registry.async_get_or_create(
config_entry_id=source_config_entry.entry_id,
@@ -422,15 +374,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi
assert await hass.config_entries.async_setup(helper_config_entry.entry_id)
await hass.async_block_till_done()
# Check preconditions
# Check preconditions - the helper entity is linked to the source device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_entity_entry.device_id
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
source_device_2 = device_registry.async_get(source_device_2.id)
assert helper_config_entry.entry_id not in source_device_2.config_entries
events = track_entity_registry_actions(hass, helper_entity_entry.entity_id)
# Move the source entity to another device
@@ -442,11 +389,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi
async_unload_entry.assert_called_once()
set_source_entity_id_or_uuid.assert_not_called()
# Check that the helper config entry is moved to the other device
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id not in source_device.config_entries
source_device_2 = device_registry.async_get(source_device_2.id)
assert helper_config_entry.entry_id in source_device_2.config_entries
# Check that the helper entity is relinked to the other device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_device_2.id
# Check that the helper config entry is not removed
assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids()
@@ -475,21 +420,13 @@ async def test_async_handle_source_entity_new_entity_id(
set_source_entity_id_calls: int,
) -> None:
"""Test the source entity's entity ID is changed."""
# Add the helper config entry to the source device
device_registry.async_update_device(
source_device.id, add_config_entry_id=helper_config_entry.entry_id
)
assert await hass.config_entries.async_setup(helper_config_entry.entry_id)
await hass.async_block_till_done()
# Check preconditions
# Check preconditions - the helper entity is linked to the source device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_entity_entry.device_id
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
events = track_entity_registry_actions(hass, helper_entity_entry.entity_id)
# Change the source entity's entity ID
@@ -501,9 +438,9 @@ async def test_async_handle_source_entity_new_entity_id(
assert len(async_unload_entry.mock_calls) == unload_calls
assert len(set_source_entity_id_or_uuid.mock_calls) == set_source_entity_id_calls
# Check that the helper config is still in the device
source_device = device_registry.async_get(source_device.id)
assert helper_config_entry.entry_id in source_device.config_entries
# Check that the helper entity is still linked to the source device
helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id)
assert helper_entity_entry.device_id == source_device.id
# Check that the helper config entry is not removed
assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids()
@@ -520,13 +457,25 @@ async def test_async_remove_helper_config_entry_from_source_device(
entity_registry: er.EntityRegistry,
helper_config_entry: MockConfigEntry,
helper_entity_entry: er.RegistryEntry,
source_config_entry: ConfigEntry,
source_device: dr.DeviceEntry,
) -> None:
"""Test removing the helper config entry from the source device."""
# Add the helper config entry to the source device
# In the single-owner model the migration helper only acts when the helper config
# entry owns the source device. Move the source device to the helper config entry
# and record a pending move back to the source config entry, so removing the helper
# config entry hands the device back to the source config entry instead of deleting
# it.
device_registry.async_update_device(
source_device.id, add_config_entry_id=helper_config_entry.entry_id
source_device.id,
add_config_entry_id=helper_config_entry.entry_id,
remove_config_entry_id=source_config_entry.entry_id,
)
device_registry.async_update_device(
source_device.id, add_config_entry_id=source_config_entry.entry_id
)
source_device = device_registry.async_get(source_device.id)
assert source_device.config_entries == {helper_config_entry.entry_id}
# Create a helper entity entry, not connected to the source device
extra_helper_entity_entry = entity_registry.async_get_or_create(
+28 -9
View File
@@ -163,10 +163,18 @@ def floor_area_mock(hass: HomeAssistant) -> None:
},
)
device_in_area = dr.DeviceEntry(area_id="test-area")
device_no_area = dr.DeviceEntry(id="device-no-area-id")
device_diff_area = dr.DeviceEntry(area_id="diff-area")
device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a")
device_in_area = dr.DeviceEntry(
config_entry_id="mock-config-entry", area_id="test-area"
)
device_no_area = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device-no-area-id"
)
device_diff_area = dr.DeviceEntry(
config_entry_id="mock-config-entry", area_id="diff-area"
)
device_area_a = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a"
)
mock_device_registry(
hass,
@@ -330,13 +338,21 @@ def label_mock(hass: HomeAssistant) -> None:
},
)
device_has_label1 = dr.DeviceEntry(labels={"label1"})
device_has_label2 = dr.DeviceEntry(labels={"label2"})
device_has_label1 = dr.DeviceEntry(
config_entry_id="mock-config-entry", labels={"label1"}
)
device_has_label2 = dr.DeviceEntry(
config_entry_id="mock-config-entry", labels={"label2"}
)
device_has_labels = dr.DeviceEntry(
labels={"label1", "label2"}, area_id=area_with_labels.id
config_entry_id="mock-config-entry",
labels={"label1", "label2"},
area_id=area_with_labels.id,
)
device_no_labels = dr.DeviceEntry(
id="device-no-labels", area_id=area_without_labels.id
config_entry_id="mock-config-entry",
id="device-no-labels",
area_id=area_without_labels.id,
)
mock_device_registry(
@@ -2491,7 +2507,10 @@ async def test_async_extract_entities_warn_referenced(
async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None:
"""Test we can find devices that have no entities."""
device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"})
device_no_entities = dr.DeviceEntry(
config_entry_id="abc",
id="device-no-entities",
)
call = ServiceCall(
hass,
+115 -6
View File
@@ -2,6 +2,7 @@
import asyncio
from collections.abc import Mapping
from typing import Any
import pytest
@@ -109,13 +110,30 @@ def registries_mock(hass: HomeAssistant) -> None:
},
)
device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area")
device_no_area = dr.DeviceEntry(id="device-no-area-id")
device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area")
device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a")
device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"})
device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"})
device_in_area = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device-test-area", area_id="test-area"
)
device_no_area = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device-no-area-id"
)
device_diff_area = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device-diff-area", area_id="diff-area"
)
device_area_a = dr.DeviceEntry(
config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a"
)
device_has_label1 = dr.DeviceEntry(
config_entry_id="mock-config-entry",
id="device-has-label1-id",
labels={"label1"},
)
device_has_label2 = dr.DeviceEntry(
config_entry_id="mock-config-entry",
id="device-has-label2-id",
labels={"label2"},
)
device_has_labels = dr.DeviceEntry(
config_entry_id="mock-config-entry",
id="device-has-labels-id",
labels={"label1", "label2"},
area_id=area_with_labels.id,
@@ -988,3 +1006,94 @@ async def test_async_track_target_selector_no_on_entities_update(
assert len(events) == 1
unsub()
COMPOSITE_ID = "composite0000000000000000000000"
@pytest.mark.parametrize("load_registries", [False])
async def test_target_trickle_down_to_splits(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Targeting the legacy id reaches the split devices' entities."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 10,
"data": {
"devices": [
{
"area_id": "area_1",
"config_entries": [entry_a.entry_id, entry_b.entry_id],
"config_entries_subentries": {
entry_a.entry_id: [None],
entry_b.entry_id: [None],
},
"configuration_url": None,
"connections": [["mac", "12:34:56:ab:cd:ef"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": COMPOSITE_ID,
"identifiers": [["domain_a", "1"], ["domain_b", "1"]],
"labels": ["lab"],
"manufacturer": "man",
"model": "mod",
"name": "composite",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": "custom name",
"primary_config_entry": entry_a.entry_id,
"serial_number": "SERIAL",
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
hass_storage[er.STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"data": {
"entities": [
{
"entity_id": "sensor.a",
"platform": "domain_a",
"unique_id": "a",
"config_entry_id": entry_a.entry_id,
"device_id": COMPOSITE_ID,
},
{
"entity_id": "sensor.b",
"platform": "domain_b",
"unique_id": "b",
"config_entry_id": entry_b.entry_id,
"device_id": COMPOSITE_ID,
},
]
},
}
dr.async_setup(hass)
await dr.async_load(hass)
await er.async_load(hass)
device_registry = dr.async_get(hass)
selected = target.async_extract_referenced_entity_ids(
hass, target.TargetSelection({"device_id": COMPOSITE_ID})
)
assert COMPOSITE_ID not in selected.missing_devices
splits = {
d.id
for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID)
}
# The composite id resolves to its splits only; it is not itself referenced (it is not
# a real device), so a device-id consumer does not act on the same device twice.
assert selected.referenced_devices == splits
assert COMPOSITE_ID not in selected.referenced_devices
assert selected.indirectly_referenced == {"sensor.a", "sensor.b"}
+32 -11
View File
@@ -36,6 +36,17 @@ ANY = _ANY()
__all__ = ["HomeAssistantSnapshotExtension"]
# DeviceEntry attributes that are internal bookkeeping and should not appear in snapshots.
# Underscore attributes (_cache, _suggested_area and the transient _pending_move /
# _composite_subentries) are excluded separately. The composite-device migration
# attributes below can be removed in HA Core 2027.8.
_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = (
"composite_device_id",
"composite_primary_config_entry",
"has_composite_identifiers",
"split_at",
)
class AreaRegistryEntrySnapshot(dict):
"""Tiny wrapper to represent an area registry entry in snapshots."""
@@ -150,21 +161,31 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer):
cls, data: dr.DeviceEntry
) -> SerializableData:
"""Prepare a Home Assistant device registry entry for serialization."""
# Exclude internal attributes (caches, transient move state, and the
# composite-device migration bookkeeping) from the snapshot
serialized = DeviceRegistryEntrySnapshot(
attrs.asdict(data)
| {
"config_entries": ANY,
"config_entries_subentries": ANY,
"id": ANY,
}
attr.asdict(
data,
retain_collection_types=True,
filter=lambda attribute, _: (
not attribute.name.startswith("_")
and attribute.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES
),
)
| {"id": ANY}
)
if serialized["via_device_id"] is not None:
serialized["via_device_id"] = ANY
if serialized["primary_config_entry"] is not None:
serialized["primary_config_entry"] = ANY
serialized.pop("_cache")
# This can be removed when suggested_area is removed from DeviceEntry
serialized.pop("_suggested_area")
# Remove single config entry and subentry ids to not break snapshots
serialized.pop("config_entry_id")
serialized.pop("config_subentry_id")
# Set removed composite device attributes to ANY to not break snapshots
serialized["config_entries"] = ANY
serialized["config_entries_subentries"] = ANY
serialized["primary_config_entry"] = ANY
return cls._remove_created_and_modified_at(serialized)
@classmethod
+51
View File
@@ -6260,6 +6260,57 @@ async def test_loading_old_data(
assert entry.pref_disable_new_entities is True
async def test_async_initialize_sets_event_with_empty_store(
hass: HomeAssistant,
) -> None:
"""The initialized event is set when there is no stored data to load.
The device registry waits on this event during its own load.
"""
manager = config_entries.ConfigEntries(hass, {})
assert not manager._initialized.is_set()
with patch.object(manager._store, "async_load", return_value=None):
await manager.async_initialize()
assert manager._initialized.is_set()
await manager.async_wait_initialized()
assert manager.async_entries() == []
async def test_async_initialize_sets_event_with_existing_store(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""The initialized event is set when loading an existing store.
The device registry waits on this event during its own load.
"""
hass_storage[config_entries.STORAGE_KEY] = {
"version": 1,
"data": {
"entries": [
{
"version": 5,
"domain": "my_domain",
"entry_id": "mock-id",
"data": {"my": "data"},
"source": "user",
"title": "Mock title",
"system_options": {"disable_new_entities": True},
}
]
},
}
manager = config_entries.ConfigEntries(hass, {})
assert not manager._initialized.is_set()
await manager.async_initialize()
assert manager._initialized.is_set()
await manager.async_wait_initialized()
assert len(manager.async_entries()) == 1
async def test_deprecated_disabled_by_str_ctor() -> None:
"""Test deprecated str disabled_by constructor enumizes and logs a warning."""
with pytest.raises(