mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Restrict device registry entries to a single config entry and subentry (#175785)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}], [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3413
-1705
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user