Files
core/tests/helpers/test_device_registry.py
T

9143 lines
338 KiB
Python

"""Tests for the Device Registry."""
from collections.abc import Callable, Iterable
from contextlib import AbstractContextManager, nullcontext
from datetime import datetime, timedelta
from functools import partial
import json
import pathlib
import time
from typing import Any
from unittest.mock import ANY, AsyncMock, patch
import attr
from freezegun.api import FrozenDateTimeFactory
import pytest
from yarl import URL
from homeassistant import config_entries
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, HomeAssistant, ReleaseChannel
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util.dt import utcnow
from tests.common import (
MockConfigEntry,
MockModule,
async_capture_events,
async_fire_time_changed,
flush_store,
mock_config_flow,
mock_device_registry,
mock_integration,
mock_platform,
)
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
def _mock_deleted_device(
device_id: str,
config_entry_id: str,
identifiers: set[tuple[str, str]],
) -> dr.DeletedDeviceEntry:
"""Create a deleted device entry for seeding stored registry states."""
return dr.DeletedDeviceEntry(
area_id=None,
config_entry_id=config_entry_id,
config_subentry_id=None,
connections=set(),
created_at=utcnow(),
disabled_by=None,
id=device_id,
identifiers=identifiers,
labels=set(),
modified_at=utcnow(),
name_by_user=None,
orphaned_timestamp=None,
domain="test",
)
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Create a mock config entry and add it to hass."""
entry = MockConfigEntry(title=None)
entry.add_to_hass(hass)
return entry
@pytest.fixture
def mock_config_entry_with_subentries(hass: HomeAssistant) -> MockConfigEntry:
"""Create a mock config entry and add it to hass."""
entry = MockConfigEntry(
title=None,
subentries_data=(
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
),
)
entry.add_to_hass(hass)
return entry
async def test_get_or_create_returns_same_entry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
area_registry: ar.AreaRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Make sure we do not duplicate entries."""
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
sw_version="sw-version",
name="name",
manufacturer="manufacturer",
model="model",
suggested_area="Game Room",
)
entry2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:66:77:88")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
suggested_area="Game Room",
)
entry3 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
game_room_area = area_registry.async_get_area_by_name("Game Room")
assert game_room_area is not None
assert len(area_registry.areas) == 1
assert len(device_registry.devices) == 1
assert entry.area_id == game_room_area.id
assert entry.id == entry2.id
assert entry.id == entry3.id
assert entry.identifiers == {("bridgeid", "0123")}
assert entry2.area_id == game_room_area.id
assert entry3.manufacturer == "manufacturer"
assert entry3.model == "model"
assert entry3.name == "name"
assert entry3.sw_version == "sw-version"
assert entry3.area_id == game_room_area.id
await hass.async_block_till_done()
# Only 2 update events. The third entry did not generate any changes.
assert len(update_events) == 2
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
assert update_events[1].data == {
"action": "update",
"device_id": entry.id,
"changes": {"connections": {("mac", "12:34:56:ab:cd:ef")}},
}
async def test_requirement_for_identifier_or_connection(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Make sure we do require some descriptor of device."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers=set(),
manufacturer="manufacturer",
model="model",
)
entry2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections=set(),
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
assert len(device_registry.devices) == 2
assert entry
assert entry2
with pytest.raises(HomeAssistantError):
device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections=set(),
identifiers=set(),
manufacturer="manufacturer",
model="model",
)
@pytest.mark.parametrize("load_registries", [False])
async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None:
"""Test async_get raises when the registry has not been set up."""
with pytest.raises(RuntimeError, match="Device registry not set up"):
dr.async_get(hass)
dr.async_setup(hass)
assert isinstance(dr.async_get(hass), dr.DeviceRegistry)
async def test_async_load_twice_raises(hass: HomeAssistant) -> None:
"""Test loading the device registry twice raises."""
registry = dr.async_get(hass)
with pytest.raises(RuntimeError, match="Device registry is already loaded"):
await registry.async_load()
async def test_multiple_config_entries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test registering a device for multiple config entries with same identifiers."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
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")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
entry2 = 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")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
entry3 = 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")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
# Identifiers and connections are unique per config entry: the two config entries
# get separate devices, while re-registering for the first entry reuses its device
assert len(device_registry.devices) == 2
assert entry.id != entry2.id
assert entry.id == entry3.id
assert entry.config_entry_id == config_entry_1.entry_id
assert entry2.config_entry_id == config_entry_2.entry_id
async def test_multiple_config_subentries(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test re-registering a device under different subentries of one config entry."""
config_entry = MockConfigEntry(
subentries_data=(
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
)
)
config_entry.add_to_hass(hass)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-1",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
# Re-registering under the same subentry is idempotent
entry2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-1",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
assert entry2.id == entry.id
assert entry2.config_subentry_id == "mock-subentry-id-1"
# The idempotent re-registration must not emit the deprecation warning
assert "A device belongs to one subentry" not in caplog.text
# A device belongs to a single subentry; re-registering the same identifiers under
# another subentry of the same config entry is deprecated. For now it warns and moves
# the existing device (rather than duplicating); from HA Core 2027.8 it will raise.
entry3 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-2",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
assert "A device belongs to one subentry" in caplog.text
# Still one device, now moved to the new subentry
assert entry3.id == entry.id
assert len(device_registry.devices) == 1
assert (
device_registry.async_get(entry.id).config_subentry_id == "mock-subentry-id-2"
)
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_loading_from_storage(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test loading stored devices on start."""
created_at = "2024-01-01T00:00:00+00:00"
modified_at = "2024-02-01T00:00:00+00:00"
hass_storage[dr.STORAGE_KEY] = {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"data": {
"devices": [
{
"area_id": "12345A",
"config_entries": [mock_config_entry.entry_id],
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": "https://example.com/config",
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": created_at,
"disabled_by": dr.DeviceEntryDisabler.USER,
"entry_type": dr.DeviceEntryType.SERVICE,
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": {"label1", "label2"},
"manufacturer": "manufacturer",
"model": "model",
"model_id": "model_id",
"modified_at": modified_at,
"name_by_user": "Test Friendly Name",
"name": "name",
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": "serial_no",
"sw_version": "version",
"via_device_id": None,
}
],
"deleted_devices": [
{
"area_id": "12345A",
"config_entries": [mock_config_entry.entry_id],
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"has_composite_identifiers": False,
"connections": [["Zigbee", "23.45.67.89.01"]],
"created_at": created_at,
"disabled_by": dr.DeviceEntryDisabler.USER,
"disabled_by_undefined": False,
"id": "bcdefghijklmn",
"identifiers": [["serial", "3456ABCDEF12"]],
"labels": {"label1", "label2"},
"modified_at": modified_at,
"name_by_user": "Test Friendly Name",
"orphaned_timestamp": None,
"domain": None,
}
],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
assert len(registry.devices) == 1
assert len(registry.deleted_devices) == 1
assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry(
area_id="12345A",
config_entry_id=mock_config_entry.entry_id,
config_subentry_id=None,
connections={("Zigbee", "23.45.67.89.01")},
created_at=datetime.fromisoformat(created_at),
disabled_by=dr.DeviceEntryDisabler.USER,
id="bcdefghijklmn",
identifiers={("serial", "3456ABCDEF12")},
labels={"label1", "label2"},
modified_at=datetime.fromisoformat(modified_at),
name_by_user="Test Friendly Name",
orphaned_timestamp=None,
)
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
manufacturer="manufacturer",
model="model",
)
assert entry == dr.DeviceEntry(
area_id="12345A",
config_entry_id=mock_config_entry.entry_id,
config_subentry_id=None,
configuration_url="https://example.com/config",
connections={("Zigbee", "01.23.45.67.89")},
created_at=datetime.fromisoformat(created_at),
disabled_by=dr.DeviceEntryDisabler.USER,
entry_type=dr.DeviceEntryType.SERVICE,
hw_version="hw_version",
id="abcdefghijklm",
identifiers={("serial", "123456ABCDEF")},
labels={"label1", "label2"},
manufacturer="manufacturer",
model="model",
model_id="model_id",
modified_at=datetime.fromisoformat(modified_at),
name_by_user="Test Friendly Name",
name="name",
serial_number="serial_no",
sw_version="version",
)
assert isinstance(entry.config_entries, set)
assert isinstance(entry.connections, set)
assert isinstance(entry.identifiers, set)
# Restore a device, id should be reused from the deleted device entry
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "23.45.67.89.01")},
identifiers={("serial", "3456ABCDEF12")},
manufacturer="manufacturer",
model="model",
)
assert entry == dr.DeviceEntry(
area_id="12345A",
config_entry_id=mock_config_entry.entry_id,
config_subentry_id=None,
connections={("Zigbee", "23.45.67.89.01")},
created_at=datetime.fromisoformat(created_at),
disabled_by=dr.DeviceEntryDisabler.USER,
id="bcdefghijklmn",
identifiers={("serial", "3456ABCDEF12")},
labels={"label1", "label2"},
manufacturer="manufacturer",
model="model",
modified_at=utcnow(),
name_by_user="Test Friendly Name",
)
assert entry.id == "bcdefghijklmn"
assert isinstance(entry.config_entries, set)
assert isinstance(entry.connections, set)
assert isinstance(entry.identifiers, set)
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_1(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.1."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"data": {
"devices": [
{
"config_entries": [mock_config_entry.entry_id],
"connections": [["Zigbee", "01.23.45.67.89"]],
"entry_type": "service",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"sw_version": "version",
},
# Invalid entry type
{
"config_entries": ["234567"],
"connections": [],
"entry_type": "INVALID_VALUE",
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"manufacturer": None,
"model": None,
"name": None,
"sw_version": None,
},
],
"deleted_devices": [
{
"config_entries": ["123456"],
"connections": [],
"entry_type": "service",
"id": "deletedid",
"identifiers": [["serial", "123456ABCDFF"]],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"sw_version": "version",
}
],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
deleted_entry = registry.deleted_devices["deletedid"]
assert deleted_entry.disabled_by is UNDEFINED
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": None,
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": [],
"manufacturer": "manufacturer",
"model": "model",
"model_id": None,
"modified_at": utcnow().isoformat(),
"name": "name",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": "234567",
"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",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"primary_config_entry": "234567",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [
{
"area_id": None,
"config_entry_id": "123456",
"config_subentry_id": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"disabled_by_undefined": True,
"id": "deletedid",
"identifiers": [["serial", "123456ABCDFF"]],
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"orphaned_timestamp": None,
"domain": None,
}
],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_2(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.2."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 2,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"disabled_by": None,
"entry_type": "service",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"manufacturer": "manufacturer",
"model": "model",
"modified_at": utcnow().isoformat(),
"name": "name",
"name_by_user": None,
"sw_version": "version",
"via_device_id": None,
},
{
"area_id": None,
"config_entries": ["234567"],
"configuration_url": None,
"connections": [],
"disabled_by": None,
"entry_type": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"manufacturer": None,
"model": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": None,
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": [],
"manufacturer": "manufacturer",
"model": "model",
"model_id": None,
"modified_at": utcnow().isoformat(),
"name": "name",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": "234567",
"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",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"primary_config_entry": "234567",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_fom_1_3(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.3."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 3,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"name_by_user": None,
"sw_version": "version",
"via_device_id": None,
},
{
"area_id": None,
"config_entries": ["234567"],
"configuration_url": None,
"connections": [],
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"manufacturer": None,
"model": None,
"name_by_user": None,
"name": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": [],
"manufacturer": "manufacturer",
"model": "model",
"model_id": None,
"modified_at": utcnow().isoformat(),
"name": "name",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": "234567",
"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",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"name_by_user": None,
"primary_config_entry": "234567",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_4(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.4."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 4,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"name_by_user": None,
"serial_number": None,
"sw_version": "version",
"via_device_id": None,
},
{
"area_id": None,
"config_entries": ["234567"],
"configuration_url": None,
"connections": [],
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"manufacturer": None,
"model": None,
"name_by_user": None,
"name": None,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": [],
"manufacturer": "manufacturer",
"model": "model",
"model_id": None,
"modified_at": utcnow().isoformat(),
"name": "name",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": "234567",
"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",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"primary_config_entry": "234567",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_5(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.5."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 5,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"name_by_user": None,
"serial_number": None,
"sw_version": "version",
"via_device_id": None,
},
{
"area_id": None,
"config_entries": ["234567"],
"configuration_url": None,
"connections": [],
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": ["blah"],
"manufacturer": None,
"model": None,
"name_by_user": None,
"name": None,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"model_id": None,
"modified_at": utcnow().isoformat(),
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": "234567",
"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",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": ["blah"],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"primary_config_entry": "234567",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_6(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.6."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 6,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entries": ["234567"],
"configuration_url": None,
"connections": [],
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": ["blah"],
"manufacturer": None,
"model": None,
"name_by_user": None,
"primary_config_entry": "234567",
"name": None,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": "234567",
"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",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": ["blah"],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"primary_config_entry": "234567",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_7(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.7."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 7,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"model_id": None,
"name": "name",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entries": ["234567"],
"configuration_url": None,
"connections": [],
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": ["blah"],
"manufacturer": None,
"model": None,
"model_id": None,
"name_by_user": None,
"primary_config_entry": "234567",
"name": None,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("Zigbee", "01.23.45.67.89")},
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": [["Zigbee", "01.23.45.67.89"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": "234567",
"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",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "invalid-entry-type",
"identifiers": [["serial", "mock-id-invalid-entry"]],
"labels": ["blah"],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"primary_config_entry": "234567",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_10(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.10."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 10,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
"configuration_url": None,
"connections": [["mac", "123456ABCDEF"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
],
"deleted_devices": [
{
"area_id": None,
"config_entries": ["234567"],
"config_entries_subentries": {"234567": [None]},
"connections": [["mac", "123456ABCDAB"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"disabled_by_undefined": False,
"id": "abcdefghijklm2",
"identifiers": [["serial", "123456ABCDAB"]],
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"orphaned_timestamp": "1970-01-01T00:00:00+00:00",
},
],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
deleted_entry = registry.deleted_devices.get_entry(
connections=set(),
identifiers={("serial", "123456ABCDAB")},
)
assert deleted_entry.id == "abcdefghijklm2"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"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": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
],
"deleted_devices": [
{
"area_id": None,
"config_entry_id": "234567",
"config_subentry_id": None,
"domain": None,
"connections": [["mac", "12:34:56:ab:cd:ab"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"disabled_by_undefined": False,
"id": "abcdefghijklm2",
"identifiers": [["serial", "123456ABCDAB"]],
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"orphaned_timestamp": "1970-01-01T00:00:00+00:00",
},
],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_11(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.11."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 10,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
"configuration_url": None,
"connections": [["mac", "123456ABCDEF"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
],
"deleted_devices": [
{
"area_id": None,
"config_entries": ["234567"],
"config_entries_subentries": {"234567": [None]},
"connections": [["mac", "123456ABCDAB"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"id": "abcdefghijklm2",
"identifiers": [["serial", "123456ABCDAB"]],
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"orphaned_timestamp": "1970-01-01T00:00:00+00:00",
},
],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
deleted_entry = registry.deleted_devices.get_entry(
connections=set(),
identifiers={("serial", "123456ABCDAB")},
)
assert deleted_entry.id == "abcdefghijklm2"
# Update to trigger a store
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={("serial", "123456ABCDEF")},
sw_version="new_version",
)
assert entry.id == "abcdefghijklm"
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[dr.STORAGE_KEY] == {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"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": "service",
"hw_version": "hw_version",
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": ["blah"],
"manufacturer": "manufacturer",
"model": "model",
"name": "name",
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": "new_version",
"via_device_id": None,
},
],
"deleted_devices": [
{
"area_id": None,
"config_entry_id": "234567",
"config_subentry_id": None,
"domain": None,
"connections": [["mac", "12:34:56:ab:cd:ab"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"disabled_by_undefined": False,
"id": "abcdefghijklm2",
"identifiers": [["serial", "123456ABCDAB"]],
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"orphaned_timestamp": "1970-01-01T00:00:00+00:00",
},
],
},
}
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_from_1_12(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from version 1.12.
Version 3.1 restricts a device to a single config entry and subentry: a device
belonging to several config entries is split into one device per config entry (each
keeping a copy of the identifiers/connections and a legacy reference to the composite
id), while a device in several subentries of one config entry is collapsed onto a
single subentry (preferring a real subentry over the main entry). A device already
tied to a single config entry and subentry keeps its id.
"""
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
config_entry_3 = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
config_entry_3.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
# Composite device belonging to two config entries -> split in two
{
"area_id": "area_1",
"config_entries": [
mock_config_entry.entry_id,
config_entry_2.entry_id,
],
"config_entries_subentries": {
mock_config_entry.entry_id: [None],
config_entry_2.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": "composite0000000000000000000000",
"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": mock_config_entry.entry_id,
"serial_number": "SERIAL",
"sw_version": None,
"via_device_id": None,
},
# Composite device spanning several subentries of one config entry ->
# split into one device per subentry (including the no-subentry one)
{
"area_id": None,
"config_entries": [config_entry_3.entry_id],
"config_entries_subentries": {
config_entry_3.entry_id: [
None,
"mock-subentry-id-1",
"mock-subentry-id-2",
]
},
"configuration_url": None,
"connections": [["mac", "34:56:78:cd:ef:12"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "subentries00000000000000000000",
"identifiers": [["domain_c", "1"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": config_entry_3.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
# Single (config entry, subentry) device -> keeps its id, no legacy ref
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "singleentry00000000000000000000",
"identifiers": [["domain_a", "2"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# The single (config entry, subentry) device keeps its id and has no legacy reference
single = registry.async_get("singleentry00000000000000000000")
assert single is not None
assert single.config_entry_id == mock_config_entry.entry_id
assert single.config_subentry_id is None
assert single.composite_device_id is None
assert single.has_composite_identifiers is False
# The composite spanning two config entries is split into one device per config entry
assert "composite0000000000000000000000" not in registry.devices
entry_splits = registry.async_get_devices_for_composite_device_id(
"composite0000000000000000000000"
)
assert len(entry_splits) == 2
assert {(d.config_entry_id, d.config_subentry_id) for d in entry_splits} == {
(mock_config_entry.entry_id, None),
(config_entry_2.entry_id, None),
}
for device in entry_splits:
assert device.id != "composite0000000000000000000000"
# Each split copies the identity and customizations of the composite ...
assert device.identifiers == {("domain_a", "1"), ("domain_b", "1")}
assert device.connections == {("mac", "12:34:56:ab:cd:ef")}
assert device.area_id == "area_1"
assert device.name_by_user == "custom name"
assert device.labels == {"lab"}
assert device.serial_number == "SERIAL"
# ... and records its composite_device_id, keeping the copied identifiers
assert device.composite_device_id == "composite0000000000000000000000"
assert device.composite_primary_config_entry == mock_config_entry.entry_id
assert device.split_at is not None
assert device.has_composite_identifiers is True
# A device spanning several subentries of ONE config entry is an invalid state (only
# a buggy 2025.7 subentry migration produced it); it is collapsed to a single device
# on one subentry - preferring a real subentry over the main entry (None) - rather
# than split into duplicate devices sharing the same identifiers/connections. It
# keeps its id and gains no composite bookkeeping.
assert "subentries00000000000000000000" in registry.devices
assert (
registry.async_get_devices_for_composite_device_id(
"subentries00000000000000000000"
)
== []
)
collapsed = _get_device_for_config_entry(
registry, config_entry_3.entry_id, identifiers={("domain_c", "1")}
)
assert collapsed is not None
assert collapsed.id == "subentries00000000000000000000"
assert collapsed.config_entry_id == config_entry_3.entry_id
assert collapsed.config_subentry_id == "mock-subentry-id-1"
assert collapsed.identifiers == {("domain_c", "1")}
assert collapsed.connections == {("mac", "34:56:78:cd:ef:12")}
assert collapsed.composite_device_id is None
assert collapsed.has_composite_identifiers is False
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_backs_up_store_file(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_tmp_config_dir: str,
) -> None:
"""The store file is copied to a timestamped backup before the version 3 migration."""
hass.config.config_dir = hass_tmp_config_dir
storage_dir = pathlib.Path(hass_tmp_config_dir) / ".storage"
storage_dir.mkdir(parents=True, exist_ok=True)
old_store = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {"devices": [], "deleted_devices": []},
}
(storage_dir / dr.STORAGE_KEY).write_text(json.dumps(old_store))
hass_storage[dr.STORAGE_KEY] = old_store
dr.async_setup(hass)
await dr.async_load(hass)
# Exactly one timestamped copy of the pre-migration file was made
backups = list(storage_dir.glob(f"{dr.STORAGE_KEY}.*.migration_backup"))
assert len(backups) == 1
assert json.loads(backups[0].read_text()) == old_store
# The middle segment is a YYYYMMDD_HHMMSS timestamp (strptime raises if malformed)
datetime.strptime(backups[0].name.split(".")[-2], "%Y%m%d_%H%M%S")
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_detaches_via_device_of_dropped_parent(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A child of an ownerless parent dropped by the migration has its link detached.
The migration drops an active device with no config entry; normally
async_remove_device would clear via_device_id links to it, so the migration must too.
"""
entry = MockConfigEntry()
entry.add_to_hass(hass)
def _device(**overrides: Any) -> dict[str, Any]:
device = {
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "device0000000000000000000000000",
"identifiers": [["test", "1"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": entry.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
return device | overrides
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
# Ownerless parent (no config entries) -> dropped by the migration
_device(
id="orphan0000000000000000000000000",
config_entries=[],
config_entries_subentries={},
identifiers=[["test", "orphan"]],
primary_config_entry=None,
),
# Child linked to the orphan via via_device_id
_device(
id="child00000000000000000000000000",
identifiers=[["test", "child"]],
via_device_id="orphan0000000000000000000000000",
),
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# The ownerless parent was dropped; the child survives with its link detached
assert registry.async_get("orphan0000000000000000000000000") is None
child = registry.async_get("child00000000000000000000000000")
assert child is not None
assert child.via_device_id is None
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_collapses_multi_subentry_device(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A device wrongly assigned to several subentries of one config entry collapses.
Only a buggy 2025.7 subentry migration produced this state. The migration must
collapse it to a single device (preferring a real subentry over the main entry,
None), NOT split it into duplicate devices sharing the same identifiers/connections.
"""
entry = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="sub-1",
subentry_type="test",
title="Sub 1",
unique_id="s1",
),
]
)
entry.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None, "sub-1"]},
"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": "buggydevice00000000000000000",
"identifiers": [["test", "device-1"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": entry.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Collapsed to a single device (no duplicate), on the real subentry, keeping its id
assert len(registry.devices) == 1
device = registry.async_get("buggydevice00000000000000000")
assert device is not None
assert device.config_entry_id == entry.entry_id
assert device.config_subentry_id == "sub-1"
assert device.config_entries_subentries == {entry.entry_id: {"sub-1"}}
# It is not split and stays findable by identifier and connection (not shadowed)
assert (
registry.async_get_devices_for_composite_device_id(
"buggydevice00000000000000000"
)
== []
)
assert device.composite_device_id is None
assert device.has_composite_identifiers is False
assert (
_get_device_for_config_entry(
registry, entry.entry_id, identifiers={("test", "device-1")}
)
is device
)
assert (
_get_device_for_config_entry(
registry, entry.entry_id, connections={("mac", "12:34:56:ab:cd:ef")}
)
is device
)
async def test_async_get_or_create_warns_on_subentry_reassignment(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Re-registering under a different subentry warns and moves the device.
Identifiers and connections are unique per config entry (not per subentry), so a
second async_get_or_create with the same identifier/connection but a different
subentry of the same config entry can neither create a duplicate nor keep two
devices - it moves the existing device to the new subentry. This implicit move is
deprecated (logs a warning now, will raise in HA Core 2027.8).
"""
entry = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="sub-1",
subentry_type="test",
title="1",
unique_id="s1",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="sub-2",
subentry_type="test",
title="2",
unique_id="s2",
),
]
)
entry.add_to_hass(hass)
# Same identifier, different subentry -> warns and moves the existing device
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
config_subentry_id="sub-1",
identifiers={("test", "1")},
)
assert device.config_subentry_id == "sub-1"
moved = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
config_subentry_id="sub-2",
identifiers={("test", "1")},
)
assert "A device belongs to one subentry" in caplog.text
assert moved.id == device.id
assert device_registry.async_get(device.id).config_subentry_id == "sub-2"
assert len(device_registry.devices) == 1
# Same connection, different subentry -> also moves
device_2 = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
config_subentry_id="sub-1",
connections={("mac", "12:34:56:ab:cd:ef")},
)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
config_subentry_id="sub-2",
connections={("mac", "12:34:56:ab:cd:ef")},
)
assert device_registry.async_get(device_2.id).config_subentry_id == "sub-2"
assert len(device_registry.devices) == 2
async def test_async_get_device_returns_first_match_for_ambiguous_lookup(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Independent devices sharing an identifier resolve to the first match.
They are not splits of one pre-migration composite (no shared composite_device_id),
so there is nothing to merge and the lookup returns one of the real devices rather
than a composite.
"""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "shared")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "shared")}
)
assert device_1.id != device_2.id
match = device_registry.async_get_device(identifiers={("test", "shared")})
# A real registry device (the first match), not a synthesized composite
assert match is device_1
assert match.id in device_registry.devices
assert match.config_entries == {entry_1.entry_id}
async def test_async_get_device_prefers_calling_integration(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An ambiguous lookup prefers a device owned by the calling integration."""
entry_a = MockConfigEntry(domain="itg_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="itg_b")
entry_b.add_to_hass(hass)
mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")
# itg_a's device is indexed first (created first)
device_a = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, connections={mac}
)
device_b = device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id, connections={mac}
)
assert device_a.id != device_b.id
# Each integration resolves to its own device, regardless of index order
with patch.object(dr, "_current_integration_domain", return_value="itg_b"):
assert device_registry.async_get_device(connections={mac}) is device_b
with patch.object(dr, "_current_integration_domain", return_value="itg_a"):
assert device_registry.async_get_device(connections={mac}) is device_a
# A caller owning neither, or no integration frame, falls back to the first match
with patch.object(dr, "_current_integration_domain", return_value="other"):
assert device_registry.async_get_device(connections={mac}) is device_a
with patch.object(dr, "_current_integration_domain", return_value=None):
assert device_registry.async_get_device(connections={mac}) is device_a
async def test_async_get_device_prefers_matching_domain(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A lookup prefers the device whose config entry domain matches the identifier.
Right after the migration split, and until identifiers are pruned, every split still
carries the composite's full identifier set, so a lookup matches all splits; the
domain match resolves it to the correct single device without a composite.
"""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
device_a = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")}
)
# entry_b's device also carries domain_a's identifier (unpruned split state)
device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id,
identifiers={("domain_a", "1"), ("domain_b", "2")},
)
assert device_registry.async_get_device(identifiers={("domain_a", "1")}) is device_a
@pytest.mark.parametrize(
("method", "create_kwargs", "key", "miss_key"),
[
pytest.param(
"async_get_device_by_identifier",
{"identifiers": {("test", "shared")}},
("test", "shared"),
("test", "other"),
id="identifier",
),
pytest.param(
"async_get_device_by_connection",
{"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}},
(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"),
(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ff"),
id="connection",
),
],
)
async def test_async_get_device_scoped_to_config_entry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
method: str,
create_kwargs: dict[str, set[tuple[str, str]]],
key: tuple[str, str],
miss_key: tuple[str, str],
) -> None:
"""A single-key lookup scoped to a config entry resolves a shared key uniquely."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, **create_kwargs
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, **create_kwargs
)
assert device_1.id != device_2.id
lookup = getattr(device_registry, method)
assert lookup(key, entry_1.entry_id) is device_1
assert lookup(key, entry_2.entry_id) is device_2
assert lookup(miss_key, entry_1.entry_id) is None
assert lookup(key, "unknown_entry_id") is None
async def test_async_get_device_by_connection_normalizes(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""MAC connection values are normalized before the scoped lookup."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert (
device_registry.async_get_device_by_connection(
(dr.CONNECTION_NETWORK_MAC, "12-34-56-ab-cd-ef"), entry.entry_id
)
is device
)
async def test_async_get_device_id_by_identifier(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""The id lookup returns the device id, and raises when there is no match."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "1")}
)
assert (
dr.async_get_device_id_by_identifier(
hass, ("test", "1"), config_entry_id=entry.entry_id
)
== device.id
)
# A missing device is treated as an error: an unknown identifier or the
# wrong config entry both raise rather than silently returning None.
with pytest.raises(ValueError, match="no device with identifier"):
dr.async_get_device_id_by_identifier(
hass, ("test", "missing"), config_entry_id=entry.entry_id
)
with pytest.raises(ValueError, match="no device with identifier"):
dr.async_get_device_id_by_identifier(
hass, ("test", "1"), config_entry_id="unknown_entry_id"
)
@pytest.mark.parametrize(
("create_kwargs", "lookup_kwargs", "miss_kwargs"),
[
pytest.param(
{"identifiers": {("test", "shared")}},
{"identifiers": {("test", "shared")}},
{"identifiers": {("test", "other")}},
id="identifier",
),
pytest.param(
{"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}},
{"connections": {(dr.CONNECTION_NETWORK_MAC, "12-34-56-AB-CD-EF")}},
{"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ff")}},
id="connection",
),
],
)
async def test_async_get_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
create_kwargs: dict[str, set[tuple[str, str]]],
lookup_kwargs: dict[str, set[tuple[str, str]]],
miss_kwargs: dict[str, set[tuple[str, str]]],
) -> None:
"""A plural lookup returns all devices sharing the key across config entries."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, **create_kwargs
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, **create_kwargs
)
assert device_1.id != device_2.id
assert {
device.id for device in device_registry.async_get_devices(**lookup_kwargs)
} == {
device_1.id,
device_2.id,
}
assert device_registry.async_get_devices(**miss_kwargs) == []
assert [
device.id
for device in device_registry.async_get_devices(
**lookup_kwargs, config_entry_id=entry_1.entry_id
)
] == [device_1.id]
assert (
device_registry.async_get_devices(**lookup_kwargs, config_entry_id="unknown")
== []
)
async def test_async_get_devices_multiple_keys(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A plural lookup with several keys returns the union of matches, deduplicated."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")
device_1 = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "1")},
connections={mac},
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "2")}
)
devices = device_registry.async_get_devices(
identifiers={("test", "1"), ("test", "2")}, connections={mac}
)
assert {device.id for device in devices} == {device_1.id, device_2.id}
assert len(devices) == 2
async def test_async_remove_device_fans_out_to_migration_composite(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""async_remove_device on a pre-migration composite id removes its splits."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "2")}
)
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
)
device_registry.async_remove_device(old_id)
assert device_1.id not in device_registry.devices
assert device_2.id not in device_registry.devices
async def test_async_update_device_fans_out_to_migration_composite(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""async_update_device on a pre-migration composite id fans out to its splits."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "2")}
)
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
)
device_registry.async_update_device(old_id, name_by_user="merged")
assert device_registry.async_get(device_1.id).name_by_user == "merged"
assert device_registry.async_get(device_2.id).name_by_user == "merged"
async def test_get_entry_by_connection_without_config_entry_scope(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""The container resolves by connection when no config entry scope is given."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, connections={connection}
)
assert device_registry.devices.get_entry(connections={connection}) is device
async def test_update_unknown_device_id_raises(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Updating an id that is neither a real device nor a composite raises."""
with pytest.raises(KeyError):
device_registry.async_update_device("unknown0000000000000000000000ab", name="x")
async def test_cleanup_removes_device_referencing_missing_config_entry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Cleanup drops a device still referencing a config entry that no longer exists."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "1")}
)
# An entity keeps the device out of the plain-orphan sweep so the defensive
# missing-config-entry path is reached
entity_registry.async_get_or_create("sensor", "test", "unique", device_id=device.id)
# The device's config entry is no longer known to hass
with patch.object(hass.config_entries, "async_entry_ids", return_value=[]):
dr.async_cleanup(hass, device_registry, entity_registry)
assert device.id not in device_registry.devices
async def test_clear_config_entry_removes_device_with_pending_move(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Clearing a config entry removes its device, ignoring a pending move.
add_config_entry_id records a transient pending move; tearing down the owning config
entry must remove the device rather than complete that move to the other entry.
"""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id)
device_registry.async_clear_config_entry(entry_1.entry_id)
assert device.id not in device_registry.devices
assert device_registry.async_get_device(identifiers={("test", "1")}) is None
async def test_clear_config_entry_clears_pending_move_targeting_it(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Clearing a config entry drops a pending move that targets it.
A device owned by another entry can hold a transient pending move to the entry being
removed; clearing it stops a later completion from moving the device onto the removed
entry instead of deleting it.
"""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
# Start a deferred move to entry_2 (add_config_entry_id without the paired remove yet)
device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id)
# entry_2 is torn down before the move completes
device_registry.async_clear_config_entry(entry_2.entry_id)
# Completing the move by removing the owner must delete the device, not move it onto
# the removed entry_2
result = device_registry.async_update_device(
device.id, remove_config_entry_id=entry_1.entry_id
)
assert result is None
assert device.id not in device_registry.devices
async def test_move_to_config_entry_clears_target_entry_deleted_device(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Moving a device into a config entry clears a matching deleted device it holds.
A retained-identity move adds no new identifiers/connections, so the deleted device the
target entry kept for the same identity must still be removed - otherwise the active
device and the deleted device share the target entry's per-identity slot.
"""
entry_a = MockConfigEntry()
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry()
entry_b.add_to_hass(hass)
device_a = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("test", "shared")}
)
device_b = device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id, identifiers={("test", "shared")}
)
assert device_a.id != device_b.id
# Leave a deleted device owned by entry_b with the shared identity
device_registry.async_remove_device(device_b.id)
assert device_b.id in device_registry.deleted_devices
# Move device_a into entry_b, retaining its identity
device_registry.async_update_device(
device_a.id, new_config_entry_id=entry_b.entry_id
)
assert device_registry.async_get(device_a.id).config_entry_id == entry_b.entry_id
# The deleted device entry_b held for the same identity is cleared, not left immortal
assert device_b.id not in device_registry.deleted_devices
async def test_get_or_create_via_device_and_via_device_id_raises_cleanly(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Passing both via_device and via_device_id raises without inserting a device."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
with pytest.raises(HomeAssistantError, match="not allowed"):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "1")},
via_device=("test", "via"),
via_device_id="via-device-id",
)
assert device_registry.async_get_device(identifiers={("test", "1")}) is None
assert len(device_registry.devices) == 0
async def test_get_or_create_invalid_subentry_raises_cleanly(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An unknown config_subentry_id raises without inserting a device."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
with pytest.raises(HomeAssistantError, match="has no subentry"):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
config_subentry_id="does-not-exist",
identifiers={("test", "1")},
)
assert device_registry.async_get_device(identifiers={("test", "1")}) is None
assert len(device_registry.devices) == 0
async def test_add_current_config_entry_is_noop(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Adding the device's current owner records no pending move.
So a later removal of that sole owner deletes the device instead of moving it to
itself.
"""
entry = MockConfigEntry()
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "1")}
)
device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id)
result = device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
assert result is None
assert device.id not in device_registry.devices
@pytest.mark.parametrize(
"clear_domain",
["light", None],
ids=["explicit-domain", "auto-resolved-domain"],
)
async def test_reregister_restores_orphan(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
clear_domain: str | None,
) -> None:
"""Re-adding an integration restores its orphan.
async_clear_config_entry records the config entry's domain - passed in by the core
removal flow, or resolved from the still-present entry when omitted - and a later
async_get_or_create under the same domain restores that orphan (id, labels, name)
rather than create a fresh device.
"""
entry = MockConfigEntry(domain="light")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("light", "1")}, name="Original"
)
device_registry.async_update_device(
device.id, name_by_user="Custom", labels={"label1"}
)
# Removing the config entry orphans the deleted device (config_entry_id=None)
device_registry.async_clear_config_entry(entry.entry_id, clear_domain)
orphan = device_registry.deleted_devices[device.id]
assert orphan.config_entry_id is None
assert orphan.domain == "light"
# Re-add the integration under a new config entry and re-register the device
new_entry = MockConfigEntry(domain="light")
new_entry.add_to_hass(hass)
restored = device_registry.async_get_or_create(
config_entry_id=new_entry.entry_id, identifiers={("light", "1")}
)
assert restored.id == device.id
assert restored.config_entry_id == new_entry.entry_id
assert restored.name_by_user == "Custom"
assert restored.labels == {"label1"}
async def test_orphan_not_restored_for_other_domain(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An orphan recorded for one integration is not restored by another.
Identifiers and connections are no longer unique across integrations, so a chance
collision must not restore another integration's orphaned device onto this one.
"""
entry = MockConfigEntry(domain="light")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("light", "1")}
)
device_registry.async_clear_config_entry(entry.entry_id, entry.domain)
assert device_registry.deleted_devices[device.id].domain == "light"
# A different integration registering a device with the same identifiers gets a fresh
# device, and the orphan is left intact for its own integration to restore later
other_entry = MockConfigEntry(domain="switch")
other_entry.add_to_hass(hass)
fresh = device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id, identifiers={("light", "1")}
)
assert fresh.id != device.id
assert device.id in device_registry.deleted_devices
async def test_orphaning_replaces_colliding_same_domain_orphan(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Orphaning a device drops a stale same-domain orphan it collides with.
Two devices from the same integration sharing a connection both orphan under
config_entry_id=None and would collide in the lookup index; the newest orphan replaces
the stale one so a re-add restores it deterministically.
"""
connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}
entry_1 = MockConfigEntry(domain="hue")
entry_1.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
connections=connections,
identifiers={("hue", "1")},
)
entry_2 = MockConfigEntry(domain="hue")
entry_2.add_to_hass(hass)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id,
connections=connections,
identifiers={("hue", "2")},
)
device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain)
assert device_1.id in device_registry.deleted_devices
device_registry.async_clear_config_entry(entry_2.entry_id, entry_2.domain)
# The newer orphan replaces the stale one it collides with on the shared connection
assert device_1.id not in device_registry.deleted_devices
assert device_2.id in device_registry.deleted_devices
# Re-adding under the same domain restores the surviving orphan
entry_3 = MockConfigEntry(domain="hue")
entry_3.add_to_hass(hass)
restored = device_registry.async_get_or_create(
config_entry_id=entry_3.entry_id,
connections=connections,
identifiers={("hue", "2")},
)
assert restored.id == device_2.id
async def test_orphaned_domain_survives_store_round_trip(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An orphan's recorded domain is written to and read back from storage."""
entry = MockConfigEntry(domain="hue")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("hue", "1")}
)
device_registry.async_clear_config_entry(entry.entry_id, entry.domain)
registry2 = dr.DeviceRegistry(hass)
await flush_store(device_registry._store)
await registry2.async_load()
assert registry2.deleted_devices[device.id].domain == "hue"
async def test_orphan_keeps_domain_when_config_entry_removed(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An orphan keeps its domain when its config entry is removed via the normal flow.
config_entries deletes the entry from the registry before calling
async_clear_config_entry, so async_remove_device can no longer look up the domain and
records None; the domain passed to async_clear_config_entry is what preserves it on
the orphan. Without it the orphan would have domain=None and, with the domain-less
restore fallback gone, could never be restored.
"""
entry = MockConfigEntry(domain="hue")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("hue", "1")}
)
await hass.config_entries.async_remove(entry.entry_id)
orphan = device_registry.deleted_devices[device.id]
assert orphan.config_entry_id is None
assert orphan.domain == "hue"
async def test_cross_domain_orphans_do_not_shadow(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Orphans from different integrations sharing an identifier stay independently found.
Both orphans would otherwise collide in the config_entry_id=None index; keying orphans
by their recorded domain keeps each restorable by its own integration.
"""
shared = {("test", "shared")}
entry_a = MockConfigEntry(domain="hue")
entry_a.add_to_hass(hass)
device_a = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers=shared
)
entry_b = MockConfigEntry(domain="mqtt")
entry_b.add_to_hass(hass)
device_b = device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id, identifiers=shared
)
device_registry.async_clear_config_entry(entry_a.entry_id, entry_a.domain)
device_registry.async_clear_config_entry(entry_b.entry_id, entry_b.domain)
# Re-adding under each domain restores that domain's own orphan, not the other's
entry_c = MockConfigEntry(domain="mqtt")
entry_c.add_to_hass(hass)
restored_b = device_registry.async_get_or_create(
config_entry_id=entry_c.entry_id, identifiers=shared
)
assert restored_b.id == device_b.id
entry_d = MockConfigEntry(domain="hue")
entry_d.add_to_hass(hass)
restored_a = device_registry.async_get_or_create(
config_entry_id=entry_d.entry_id, identifiers=shared
)
assert restored_a.id == device_a.id
async def test_domainless_orphan_not_restored(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A domain-less orphan is not restored; re-registering creates a fresh device.
The migration carries orphans over without a domain, which can't be resolved once the
config entry is gone. Orphans are matched only on their recorded domain, so a
domain-less one is left for the periodic purge and re-registering makes a new device.
"""
entry_1 = MockConfigEntry(domain="hue")
entry_1.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "shared")}
)
# Simulate an orphan whose domain can no longer be resolved (the migration carries
# orphans over without one)
with patch.object(hass.config_entries, "async_get_entry", return_value=None):
device_registry.async_clear_config_entry(entry_1.entry_id)
assert device_registry.deleted_devices[device_1.id].domain is None
# Re-registering the shared identifier does not restore the domain-less orphan
entry_2 = MockConfigEntry(domain="hue")
entry_2.add_to_hass(hass)
fresh = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "shared")}
)
assert fresh.id != device_1.id
# The un-restored orphan lingers until the periodic purge
assert device_1.id in device_registry.deleted_devices
async def test_clear_config_subentry_removes_device_with_pending_move(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Clearing a config subentry removes its device, ignoring a pending move.
add_config_entry_id records a transient pending move; tearing down the owning
subentry must remove the device rather than complete that move.
"""
entry_1 = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
config_subentry_id="mock-subentry-id-1",
identifiers={("test", "1")},
)
device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id)
device_registry.async_clear_config_subentry(entry_1.entry_id, "mock-subentry-id-1")
assert device.id not in device_registry.devices
assert device_registry.async_get_device(identifiers={("test", "1")}) is None
async def test_clear_config_subentry_clears_pending_move_targeting_it(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Clearing a config subentry drops a pending move that targets it.
A device owned by another entry can hold a transient pending move to the subentry being
removed; clearing it stops a later completion from validating against the removed
subentry (moving the device onto it, or raising) instead of deleting it.
"""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
entry_2.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
# Start a deferred move into entry_2's subentry
device_registry.async_update_device(
device.id,
add_config_entry_id=entry_2.entry_id,
add_config_subentry_id="mock-subentry-id-1",
)
# The target subentry is torn down before the move completes
device_registry.async_clear_config_subentry(entry_2.entry_id, "mock-subentry-id-1")
# Completing the move by removing the owner must delete the device, not move it onto
# the removed subentry
result = device_registry.async_update_device(
device.id, remove_config_entry_id=entry_1.entry_id
)
assert result is None
assert device.id not in device_registry.devices
async def test_async_is_composite_device_id(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test asking the registry if a device id is a pre-migration composite id."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "2")}
)
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 device_registry.async_is_composite_device_id(old_id) is True
assert device_registry.async_is_composite_device_id(device_1.id) is False
assert device_registry.async_is_composite_device_id(device_2.id) is False
assert device_registry.async_is_composite_device_id("unknown_id") is None
@pytest.mark.parametrize("load_registries", [False])
async def test_async_get_device_composite_reuses_pre_migration_id(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A composite over migration splits reuses the pre-migration device id.
Backwards compatibility for unmodified integrations: before the rewrite a shared
connection resolved to one device with a stable id that stored references
(automations, an entity device_id, a fired event device_id) use. The composite over
that device's splits reuses the same id, so those references keep resolving; a
transient id is minted only for a runtime ambiguity between independent devices.
"""
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": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"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", "aa:bb:cc:dd:ee:ff"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "composite00000000000000000000",
"identifiers": [["domain_a", "1"], ["domain_b", "2"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": entry_a.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# A connections-only lookup matches both splits -> composite reuses the old id
composite = registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")}
)
assert composite is not None
assert composite.id == "composite00000000000000000000"
assert composite.id not in registry.devices
# It is the same composite async_get resolves for the old id
assert registry.async_get("composite00000000000000000000").id == composite.id
# An identifier lookup still domain-resolves to the single owning split (real id)
resolved = registry.async_get_device(identifiers={("domain_a", "1")})
assert resolved.id in registry.devices
assert resolved.config_entry_id == entry_a.entry_id
@pytest.mark.parametrize(
"update_kwargs",
[
pytest.param({"new_identifiers": {("test", "new")}}, id="new_identifiers"),
pytest.param(
{"new_connections": {("mac", "12:34:56:ab:cd:ef")}}, id="new_connections"
),
pytest.param(
{"merge_identifiers": {("test", "extra")}}, id="merge_identifiers"
),
pytest.param(
{"merge_connections": {("mac", "12:34:56:ab:cd:ef")}},
id="merge_connections",
),
],
)
async def test_async_update_device_composite_drops_identity_args(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
update_kwargs: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Identity-rewriting args are ambiguous on a composite: dropped with a warning."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "2")}
)
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
)
# No raise; the arg is ignored with a report-issue warning, devices untouched
device_registry.async_update_device(old_id, **update_kwargs)
assert "async_entries_for_config_entry" in caplog.text
assert "report this issue" in caplog.text
assert device_registry.async_get(device_1.id).identifiers == {("test", "1")}
assert device_registry.async_get(device_1.id).connections == set()
assert device_registry.async_get(device_2.id).identifiers == {("test", "2")}
assert device_registry.async_get(device_2.id).connections == set()
async def test_async_update_device_composite_drops_only_disallowed_args(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A composite update applies the allowed args and drops the disallowed ones."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "2")}
)
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
)
device_registry.async_update_device(
old_id,
new_identifiers={("test", "renamed")}, # disallowed -> dropped
name_by_user="Custom name", # allowed -> applied to every underlying device
)
assert "new_identifiers" in caplog.text
# Allowed arg applied to both underlying devices
assert device_registry.async_get(device_1.id).name_by_user == "Custom name"
assert device_registry.async_get(device_2.id).name_by_user == "Custom name"
# Disallowed arg dropped: identities untouched
assert device_registry.async_get(device_1.id).identifiers == {("test", "1")}
assert device_registry.async_get(device_2.id).identifiers == {("test", "2")}
async def test_async_update_device_composite_drops_move_args(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""new_config_entry_id / new_config_subentry_id are dropped on the composite path.
A forwarded move can't be caught by the identifier/connection checks - the splits have
distinct identities and would move without colliding - so assert each split keeps its
original (config entry, subentry).
"""
entry_1 = MockConfigEntry(
domain="test",
subentries_data=[
config_entries.ConfigSubentryData(
data={}, subentry_type="test", title="Sub", unique_id=None
)
],
)
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
subentry_id = next(iter(entry_1.subentries))
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "2")}
)
old_id = "composite00000000000000000000ab"
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
)
# Targets are valid, so a forwarded move would land silently - only the owner
# assertions below catch it.
device_registry.async_update_device(old_id, new_config_entry_id=entry_2.entry_id)
device_registry.async_update_device(old_id, new_config_subentry_id=subentry_id)
assert device_registry.async_get(device_1.id).config_entry_id == entry_1.entry_id
assert device_registry.async_get(device_1.id).config_subentry_id is None
assert device_registry.async_get(device_2.id).config_entry_id == entry_2.entry_id
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_drops_device_without_config_entries(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""A device with no config entry / subentry pairs is dropped during migration."""
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
# Orphan device with no config entries -> dropped
{
"area_id": None,
"config_entries": [],
"config_entries_subentries": {},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "orphan00000000000000000000000",
"identifiers": [["domain_a", "orphan"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": None,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
# Normal single-config-entry device -> kept
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "keptdevice0000000000000000000",
"identifiers": [["domain_a", "kept"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# The orphan device was dropped, the normal device kept
assert registry.async_get("orphan00000000000000000000000") is None
assert "orphan00000000000000000000000" not in registry.devices
kept = registry.async_get("keptdevice0000000000000000000")
assert kept is not None
assert kept.config_entry_id == mock_config_entry.entry_id
assert len(registry.devices) == 1
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.usefixtures("freezer")
async def test_migration_splits_deleted_device_with_multiple_config_entries(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A deleted device belonging to several config entries is split, one per entry.
Each split keeps the identity and customizations so every config entry can still
restore its share when a matching device is re-registered.
"""
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": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [],
"deleted_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],
},
"connections": [["mac", "12:34:56:ab:cd:ef"]],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"disabled_by_undefined": False,
"id": "deletedcomposite0000000000000",
"identifiers": [["domain_a", "1"]],
"labels": ["lab"],
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": "custom name",
"orphaned_timestamp": None,
}
],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# Split into one deleted device per config entry, each keeping identity/customizations
assert len(registry.deleted_devices) == 2
assert "deletedcomposite0000000000000" not in registry.deleted_devices
by_entry = {d.config_entry_id: d for d in registry.deleted_devices.values()}
assert set(by_entry) == {entry_a.entry_id, entry_b.entry_id}
for deleted in by_entry.values():
assert deleted.identifiers == {("domain_a", "1")}
assert deleted.connections == {("mac", "12:34:56:ab:cd:ef")}
assert deleted.name_by_user == "custom name"
assert deleted.area_id == "area_1"
# Each config entry can restore its share, with the customizations preserved
restored_a = registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")}
)
assert restored_a.config_entry_id == entry_a.entry_id
assert restored_a.name_by_user == "custom name"
restored_b = registry.async_get_or_create(
config_entry_id=entry_b.entry_id, identifiers={("domain_a", "1")}
)
assert restored_b.config_entry_id == entry_b.entry_id
assert restored_b.name_by_user == "custom name"
assert restored_a.id != restored_b.id
async def test_removing_config_entries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test clearing a config entry removes the devices that belong to it."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
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")},
identifiers={("bridgeid", "0123")},
)
entry2 = 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")},
identifiers={("bridgeid", "0123")},
)
entry3 = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")},
identifiers={("bridgeid", "4567")},
)
# Same identifiers on different config entries are separate devices
assert len(device_registry.devices) == 3
assert entry.id != entry2.id
assert entry.id != entry3.id
device_registry.async_clear_config_entry(config_entry_1.entry_id)
# Clearing config_entry_1 removes its two devices, leaving config_entry_2's
assert len(device_registry.devices) == 1
assert device_registry.async_get(entry.id) is None
assert device_registry.async_get(entry3.id) is None
assert device_registry.async_get(entry2.id) is not None
async def test_deleted_device_removing_config_entries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test clearing a config entry orphans its deleted devices."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
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")},
identifiers={("bridgeid", "0123")},
)
entry2 = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")},
identifiers={("bridgeid", "4567")},
)
device_registry.async_remove_device(entry.id)
device_registry.async_remove_device(entry2.id)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 2
device_registry.async_clear_config_entry(config_entry_1.entry_id)
# Deleted devices are kept but orphaned (config entry cleared) so they can be purged
assert len(device_registry.deleted_devices) == 2
assert device_registry.deleted_devices[entry.id].config_entry_id is None
assert (
device_registry.deleted_devices[entry2.id].config_entry_id
== config_entry_2.entry_id
)
device_registry.async_clear_config_entry(config_entry_2.entry_id)
assert len(device_registry.deleted_devices) == 2
assert device_registry.deleted_devices[entry2.id].config_entry_id is None
async def test_removing_config_subentries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test clearing a config subentry removes the devices that belong to it."""
config_entry = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
config_entry.add_to_hass(hass)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-1",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
entry2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-2",
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")},
identifiers={("bridgeid", "4567")},
)
assert len(device_registry.devices) == 2
assert entry.config_subentry_id == "mock-subentry-id-1"
assert entry2.config_subentry_id == "mock-subentry-id-2"
device_registry.async_clear_config_subentry(
config_entry.entry_id, "mock-subentry-id-1"
)
# Only the device on the cleared subentry is removed
assert len(device_registry.devices) == 1
assert device_registry.async_get(entry.id) is None
assert device_registry.async_get(entry2.id) is not None
async def test_deleted_device_removing_config_subentries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test clearing a config subentry orphans its deleted devices."""
config_entry = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
config_entry.add_to_hass(hass)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-1",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
entry2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-2",
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")},
identifiers={("bridgeid", "4567")},
)
device_registry.async_remove_device(entry.id)
device_registry.async_remove_device(entry2.id)
assert len(device_registry.deleted_devices) == 2
device_registry.async_clear_config_subentry(
config_entry.entry_id, "mock-subentry-id-1"
)
# Only the deleted device on the cleared subentry is orphaned
assert len(device_registry.deleted_devices) == 2
assert device_registry.deleted_devices[entry.id].config_entry_id is None
assert (
device_registry.deleted_devices[entry2.id].config_entry_id
== config_entry.entry_id
)
async def test_removing_area_id(
device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry
) -> None:
"""Make sure we can clear area id."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
entry_w_area = device_registry.async_update_device(entry.id, area_id="12345A")
device_registry.async_clear_area_id("12345A")
entry_wo_area = device_registry.async_get_device(identifiers={("bridgeid", "0123")})
assert not entry_wo_area.area_id
assert entry_w_area != entry_wo_area
async def test_removing_area_id_deleted_device(
device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry
) -> None:
"""Make sure we can clear area id."""
entry1 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
entry2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")},
identifiers={("bridgeid", "1234")},
manufacturer="manufacturer",
model="model",
)
entry1_w_area = device_registry.async_update_device(entry1.id, area_id="12345A")
entry2_w_area = device_registry.async_update_device(entry2.id, area_id="12345B")
device_registry.async_remove_device(entry1.id)
device_registry.async_remove_device(entry2.id)
device_registry.async_clear_area_id("12345A")
entry1_restored = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
entry2_restored = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")},
identifiers={("bridgeid", "1234")},
)
assert not entry1_restored.area_id
assert entry2_restored.area_id == "12345B"
assert entry1_w_area != entry1_restored
assert entry2_w_area != entry2_restored
async def test_specifying_via_device_create(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test specifying a via_device and removal of the hub device."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
via = 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")},
identifiers={("hue", "0123")},
manufacturer="manufacturer",
model="via",
)
light = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections=set(),
identifiers={("hue", "456")},
manufacturer="manufacturer",
model="light",
via_device=("hue", "0123"),
)
assert light.via_device_id == via.id
device_registry.async_remove_device(via.id)
light = device_registry.async_get_device(identifiers={("hue", "456")})
assert light.via_device_id is None
# A device with a non existing via_device reference should create
light_via_nonexisting_parent_device = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections=set(),
identifiers={("hue", "789")},
manufacturer="manufacturer",
model="light",
via_device=("hue", "non_existing_123"),
)
assert {
"calls `device_registry.async_get_or_create` "
"referencing a non existing `via_device` "
'("hue","non_existing_123")' in caplog.text
}
assert light_via_nonexisting_parent_device is not None
assert light_via_nonexisting_parent_device.via_device_id is None
nonexisting_parent_device = device_registry.async_get_device(
identifiers={("hue", "non_existing_123")}
)
assert nonexisting_parent_device is None
async def test_specifying_via_device_update(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test specifying a via_device and updating."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
light = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections=set(),
identifiers={("hue", "456")},
manufacturer="manufacturer",
model="light",
name="Light",
via_device=("hue", "0123"),
)
assert light.via_device_id is None
via = 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")},
identifiers={("hue", "0123")},
manufacturer="manufacturer",
model="via",
)
light = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections=set(),
identifiers={("hue", "456")},
manufacturer="manufacturer",
model="light",
via_device=("hue", "0123"),
)
assert light.via_device_id == via.id
assert light.name == "Light"
# Try updating with a non existing via device
light = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections=set(),
identifiers={("hue", "456")},
manufacturer="manufacturer",
model="light",
name="New light",
via_device=("hue", "non_existing_abc"),
)
assert {
"calls `device_registry.async_get_or_create` "
"referencing a non existing `via_device` "
'("hue","non_existing_123")' in caplog.text
}
# Assert the name was updated correctly
assert light.via_device_id == via.id
assert light.name == "New light"
async def test_get_or_create_via_device_and_via_device_id_not_allowed(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Passing both via_device and via_device_id is not allowed."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
via = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={("hue", "via")}
)
with pytest.raises(
HomeAssistantError,
match="Passing both `via_device` and `via_device_id` is not allowed",
):
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "device")},
via_device=("hue", "via"),
via_device_id=via.id,
)
# Passing only via_device_id is allowed
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "device")},
via_device_id=via.id,
)
assert device.via_device_id == via.id
# Passing only the deprecated via_device is still allowed (resolved to via_device_id)
device_2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "device_2")},
via_device=("hue", "via"),
)
assert device_2.via_device_id == via.id
async def test_get_or_create_via_device_none(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""`via_device=None` means "no via device"; combining it with via_device_id raises."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
via = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={("hue", "via")}
)
# `via_device=None` alongside a via_device_id is contradictory and rejected
with pytest.raises(
HomeAssistantError,
match="Passing both `via_device` and `via_device_id` is not allowed",
):
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "device")},
via_device=None,
via_device_id=via.id,
)
# `via_device=None` on its own means no via device
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "device")},
via_device=None,
)
assert device.via_device_id is None
# ... and it clears an existing via device on re-registration
linked = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "linked")},
via_device_id=via.id,
)
assert linked.via_device_id == via.id
relinked = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "linked")},
via_device=None,
)
assert relinked.id == linked.id
assert relinked.via_device_id is None
async def test_get_or_create_unknown_via_device_id_raises_cleanly(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An unknown via_device_id raises without inserting a device."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
removed = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={("hue", "removed")}
)
device_registry.async_remove_device(removed.id)
with pytest.raises(dr.DeviceInfoError, match="is not a registered device id"):
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "device")},
via_device_id="unknown-device-id",
)
# The id of a removed device is stale and rejected the same way
with pytest.raises(dr.DeviceInfoError, match="is not a registered device id"):
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("hue", "device")},
via_device_id=removed.id,
)
assert device_registry.async_get_device(identifiers={("hue", "device")}) is None
assert len(device_registry.devices) == 0
async def test_update_device_unknown_via_device_id_raises(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An unknown via_device_id raises on update, leaving the device unchanged."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={("hue", "device")}
)
with pytest.raises(
HomeAssistantError, match="unknown via device unknown-device-id"
):
device_registry.async_update_device(
device.id, via_device_id="unknown-device-id"
)
assert device_registry.async_get(device.id).via_device_id is None
async def test_update_device_unknown_via_device_id_raises_before_removal(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An unknown via_device_id raises before a removal in the same call."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={("hue", "device")}
)
with pytest.raises(
HomeAssistantError, match="unknown via device unknown-device-id"
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=config_entry.entry_id,
via_device_id="unknown-device-id",
)
# The device was not removed
assert device_registry.async_get(device.id) == device
async def test_get_or_create_composite_via_device_id_resolved(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A composite via_device_id resolves to a split: same entry, same domain, any."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="matter")
entry_2.add_to_hass(hass)
entry_3 = MockConfigEntry(domain="matter")
entry_3.add_to_hass(hass)
entry_4 = MockConfigEntry(domain="other")
entry_4.add_to_hass(hass)
split_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "hub")}
)
split_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "hub")}
)
old_id = "composite00000000000000000000ab"
# Simulate a migration split: both devices carry the pre-migration composite id
device_registry.devices[split_1.id] = attr.evolve(
split_1, composite_device_id=old_id
)
device_registry.devices[split_2.id] = attr.evolve(
split_2, composite_device_id=old_id
)
# A child in a config entry owning a split resolves to that split
child = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id,
identifiers={("matter", "child")},
via_device_id=old_id,
)
assert child.via_device_id == split_2.id
assert "passes the id of a pre-migration composite device" in caplog.text
# A child in another config entry of a split's domain resolves to that split
domain_child = device_registry.async_get_or_create(
config_entry_id=entry_3.entry_id,
identifiers={("matter", "child")},
via_device_id=old_id,
)
assert domain_child.via_device_id == split_2.id
# A child sharing neither config entry nor domain falls back to any split
other_child = device_registry.async_get_or_create(
config_entry_id=entry_4.entry_id,
identifiers={("other", "child")},
via_device_id=old_id,
)
assert other_child.via_device_id == split_1.id
async def test_update_device_composite_via_device_id_resolved(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A composite via_device_id resolves to a split on update."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
split_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "hub")}
)
split_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "hub")}
)
old_id = "composite00000000000000000000ab"
# Simulate a migration split: both devices carry the pre-migration composite id
device_registry.devices[split_1.id] = attr.evolve(
split_1, composite_device_id=old_id
)
device_registry.devices[split_2.id] = attr.evolve(
split_2, composite_device_id=old_id
)
child = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "child")}
)
updated = device_registry.async_update_device(child.id, via_device_id=old_id)
assert updated.via_device_id == split_2.id
assert "passes the id of a pre-migration composite device" in caplog.text
async def test_via_device_prefers_same_config_entry(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""The deprecated via_device resolves to the via device in the same config entry."""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
# Two via devices share an identifier, one per config entry
via_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("hue", "via")}
)
via_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("hue", "via")}
)
assert via_1.id != via_2.id
device = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id,
identifiers={("hue", "device")},
via_device=("hue", "via"),
)
assert device.via_device_id == via_2.id
async def test_via_device_falls_back_to_other_config_entry(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""The deprecated via_device falls back to a via device in another config entry."""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
# The via device only exists in entry_1
via_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("hue", "via")}
)
device = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id,
identifiers={("hue", "device")},
via_device=("hue", "via"),
)
assert device.via_device_id == via_1.id
async def test_via_device_prefers_same_domain(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""The deprecated via_device prefers a via device from the same integration.
When no via device exists in the registering config entry, one from another config
entry of the same domain is preferred over an arbitrary other-domain match.
"""
entry = MockConfigEntry(domain="hue")
entry.add_to_hass(hass)
other_domain_entry = MockConfigEntry(domain="deconz")
other_domain_entry.add_to_hass(hass)
same_domain_entry = MockConfigEntry(domain="hue")
same_domain_entry.add_to_hass(hass)
# No via device in `entry`; the other-domain candidate is indexed first
device_registry.async_get_or_create(
config_entry_id=other_domain_entry.entry_id, identifiers={("hue", "via")}
)
via_same_domain = device_registry.async_get_or_create(
config_entry_id=same_domain_entry.entry_id, identifiers={("hue", "via")}
)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("hue", "device")},
via_device=("hue", "via"),
)
assert device.via_device_id == via_same_domain.id
async def test_loading_saving_data(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test that we load/save data correctly."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
config_entry_3 = MockConfigEntry()
config_entry_3.add_to_hass(hass)
config_entry_4 = MockConfigEntry()
config_entry_4.add_to_hass(hass)
config_entry_5 = MockConfigEntry()
config_entry_5.add_to_hass(hass)
orig_via = 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")},
identifiers={("hue", "0123")},
manufacturer="manufacturer",
model="via",
name="Original Name",
sw_version="Orig SW 1",
entry_type=None,
)
orig_light = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections=set(),
identifiers={("hue", "456")},
manufacturer="manufacturer",
model="light",
via_device=("hue", "0123"),
disabled_by=dr.DeviceEntryDisabler.USER,
)
orig_light2 = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
connections=set(),
identifiers={("hue", "789")},
manufacturer="manufacturer",
model="light",
via_device=("hue", "0123"),
)
device_registry.async_remove_device(orig_light2.id)
orig_light3 = device_registry.async_get_or_create(
config_entry_id=config_entry_3.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")},
identifiers={("hue", "abc")},
manufacturer="manufacturer",
model="light",
)
device_registry.async_get_or_create(
config_entry_id=config_entry_4.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")},
identifiers={("abc", "123")},
manufacturer="manufacturer",
model="light",
)
device_registry.async_remove_device(orig_light3.id)
orig_light4 = device_registry.async_get_or_create(
config_entry_id=config_entry_3.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")},
identifiers={("hue", "abc")},
manufacturer="manufacturer",
model="light",
entry_type=dr.DeviceEntryType.SERVICE,
)
assert orig_light4.id == orig_light3.id
orig_kitchen_light = device_registry.async_get_or_create(
config_entry_id=config_entry_5.entry_id,
connections=set(),
identifiers={("hue", "999")},
manufacturer="manufacturer",
model="light",
via_device=("hue", "0123"),
disabled_by=dr.DeviceEntryDisabler.USER,
suggested_area="Kitchen",
)
# config_entry_4's device shares a connection with orig_light3 but belongs to a
# different config entry, so it is a separate device (identifiers/connections are
# unique per config entry)
assert len(device_registry.devices) == 5
assert len(device_registry.deleted_devices) == 1
orig_via = device_registry.async_update_device(
orig_via.id,
area_id="mock-area-id",
name_by_user="mock-name-by-user",
labels={"mock-label1", "mock-label2"},
)
# Now load written data in new registry
registry2 = dr.DeviceRegistry(hass)
await flush_store(device_registry._store)
await registry2.async_load()
# Ensure same order
assert list(device_registry.devices) == list(registry2.devices)
assert list(device_registry.deleted_devices) == list(registry2.deleted_devices)
new_via = registry2.async_get_device(identifiers={("hue", "0123")})
new_light = registry2.async_get_device(identifiers={("hue", "456")})
new_light4 = registry2.async_get_device(identifiers={("hue", "abc")})
assert orig_via == new_via
assert orig_light == new_light
assert orig_light4 == new_light4
# Ensure enums converted
for old, new in (
(orig_via, new_via),
(orig_light, new_light),
(orig_light4, new_light4),
):
assert old.disabled_by is new.disabled_by
assert old.entry_type is new.entry_type
# Ensure a save/load cycle does not keep suggested area
new_kitchen_light = registry2.async_get_device(identifiers={("hue", "999")})
assert orig_kitchen_light.area_id == "kitchen"
orig_kitchen_light_without_suggested_area = device_registry.async_update_device(
orig_kitchen_light.id, suggested_area=None
)
assert orig_kitchen_light_without_suggested_area.area_id == "kitchen"
assert orig_kitchen_light_without_suggested_area == new_kitchen_light
async def test_no_unnecessary_changes(
device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry
) -> None:
"""Make sure we do not consider devices changes."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={("ethernet", "12:34:56:78:90:AB:CD:EF")},
identifiers={("hue", "456"), ("bla", "123")},
)
with patch(
"homeassistant.helpers.device_registry.DeviceRegistry.async_schedule_save"
) as mock_save:
entry2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id, identifiers={("hue", "456")}
)
assert entry.id == entry2.id
assert len(mock_save.mock_calls) == 0
async def test_format_mac(
device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry
) -> None:
"""Make sure we normalize mac addresses."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
for mac in ("123456ABCDEF", "123456abcdef", "12:34:56:ab:cd:ef", "1234.56ab.cdef"):
test_entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
assert test_entry.id == entry.id, mac
assert test_entry.connections == {
(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")
}
# This should not raise
for invalid in (
"invalid_mac",
"123456ABCDEFG", # 1 extra char
"12:34:56:ab:cdef", # not enough :
"12:34:56:ab:cd:e:f", # too many :
"1234.56abcdef", # not enough .
"123.456.abc.def", # too many .
):
invalid_mac_entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, invalid)},
)
assert list(invalid_mac_entry.connections)[0][1] == invalid
async def test_update(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Verify that we can update some attributes of a device."""
created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00")
freezer.move_to(created_at)
via_device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={("hue", "via")},
)
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("hue", "456"), ("bla", "123")},
)
new_connections = {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}
new_identifiers = {("hue", "654"), ("bla", "321")}
assert not entry.area_id
assert not entry.labels
assert not entry.name_by_user
assert entry.created_at == created_at
assert entry.modified_at == created_at
modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00")
freezer.move_to(modified_at)
with patch.object(device_registry, "async_schedule_save") as mock_save:
updated_entry = device_registry.async_update_device(
entry.id,
area_id="12345A",
configuration_url="https://example.com/config",
disabled_by=dr.DeviceEntryDisabler.USER,
entry_type=dr.DeviceEntryType.SERVICE,
hw_version="hw_version",
labels={"label1", "label2"},
manufacturer="Test Producer",
model="Test Model",
model_id="Test Model Name",
name_by_user="Test Friendly Name",
name="name",
new_connections=new_connections,
new_identifiers=new_identifiers,
serial_number="serial_no",
suggested_area="suggested_area",
sw_version="version",
via_device_id=via_device.id,
)
assert mock_save.call_count == 1
assert updated_entry != entry
assert updated_entry == dr.DeviceEntry(
area_id="12345A",
config_entry_id=mock_config_entry.entry_id,
config_subentry_id=None,
configuration_url="https://example.com/config",
connections={("mac", "65:43:21:fe:dc:ba")},
created_at=created_at,
disabled_by=dr.DeviceEntryDisabler.USER,
entry_type=dr.DeviceEntryType.SERVICE,
hw_version="hw_version",
id=entry.id,
identifiers={("bla", "321"), ("hue", "654")},
labels={"label1", "label2"},
manufacturer="Test Producer",
model="Test Model",
model_id="Test Model Name",
modified_at=modified_at,
name_by_user="Test Friendly Name",
name="name",
serial_number="serial_no",
suggested_area="suggested_area",
sw_version="version",
via_device_id=via_device.id,
)
assert device_registry.async_get_device(identifiers={("hue", "456")}) is None
assert device_registry.async_get_device(identifiers={("bla", "123")}) is None
assert (
device_registry.async_get_device(identifiers={("hue", "654")}) == updated_entry
)
assert (
device_registry.async_get_device(identifiers={("bla", "321")}) == updated_entry
)
assert (
device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}
)
is None
)
assert (
device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}
)
== updated_entry
)
assert device_registry.async_get(updated_entry.id) is not None
await hass.async_block_till_done()
assert len(update_events) == 2
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
assert update_events[1].data == {
"action": "update",
"device_id": entry.id,
"changes": {
"area_id": None,
"connections": {("mac", "12:34:56:ab:cd:ef")},
"configuration_url": None,
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"identifiers": {("bla", "123"), ("hue", "456")},
"labels": set(),
"manufacturer": None,
"model": None,
"model_id": None,
"name": None,
"name_by_user": None,
"serial_number": None,
"suggested_area": None,
"sw_version": None,
"via_device_id": None,
},
}
with pytest.raises(HomeAssistantError):
device_registry.async_update_device(
entry.id,
merge_connections=new_connections,
new_connections=new_connections,
)
with pytest.raises(HomeAssistantError):
device_registry.async_update_device(
entry.id,
merge_identifiers=new_identifiers,
new_identifiers=new_identifiers,
)
@pytest.mark.parametrize(
("initial_connections", "new_connections", "updated_connections"),
[
( # No connection -> single connection
None,
{(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
{(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
),
( # No connection -> double connection
None,
{
(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"),
(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"),
},
{
(dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"),
(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"),
},
),
( # single connection -> no connection
{(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")},
set(),
set(),
),
( # single connection -> single connection
{(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")},
{(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
{(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
),
( # single connection -> double connection
{(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")},
{
(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"),
(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"),
},
{
(dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"),
(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"),
},
),
( # Double connection -> None
{
(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"),
(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"),
},
set(),
set(),
),
( # Double connection -> single connection
{
(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"),
(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"),
},
{(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")},
{(dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba")},
),
],
)
async def test_update_connection(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
initial_connections: set[tuple[str, str]] | None,
new_connections: set[tuple[str, str]] | None,
updated_connections: set[tuple[str, str]] | None,
) -> None:
"""Verify that we can update some attributes of a device."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections=initial_connections,
identifiers={("hue", "456"), ("bla", "123")},
)
with patch.object(device_registry, "async_schedule_save") as mock_save:
updated_entry = device_registry.async_update_device(
entry.id,
new_connections=new_connections,
)
assert mock_save.call_count == 1
assert updated_entry != entry
assert updated_entry.connections == updated_connections
assert (
device_registry.async_get_device(identifiers={("bla", "123")}) == updated_entry
)
async def test_update_remove_config_entries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test removing a device's config entry deletes the device."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
assert entry.config_entry_id == config_entry.entry_id
# Removing the owning config entry with no pending move deletes the device
updated = device_registry.async_update_device(
entry.id, remove_config_entry_id=config_entry.entry_id
)
assert updated is None
assert device_registry.async_get(entry.id) is None
assert len(device_registry.devices) == 0
async def test_update_remove_config_subentries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test removing a device's config subentry deletes the device."""
config_entry = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
config_entry.add_to_hass(hass)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-1",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
assert entry.config_subentry_id == "mock-subentry-id-1"
# Removing the owning config entry/subentry with no pending move deletes the device
updated = device_registry.async_update_device(
entry.id,
remove_config_entry_id=config_entry.entry_id,
remove_config_subentry_id="mock-subentry-id-1",
)
assert updated is None
assert device_registry.async_get(entry.id) is None
assert len(device_registry.devices) == 0
@pytest.mark.parametrize(
("initial_area", "device_area_id", "number_of_areas"),
[
(None, None, 0),
("Living Room", "living_room", 1),
],
)
async def test_update_suggested_area(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
area_registry: ar.AreaRegistry,
mock_config_entry: MockConfigEntry,
initial_area: str | None,
device_area_id: str | None,
number_of_areas: int,
) -> None:
"""Verify that we can update the suggested area of a device.
Updating the suggested area of a device should not create a new area, nor should
it change the area_id of the device.
"""
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bla", "123")},
suggested_area=initial_area,
)
assert entry.area_id == device_area_id
suggested_area = "Pool"
with patch.object(device_registry, "async_schedule_save") as mock_save:
updated_entry = device_registry.async_update_device(
entry.id, suggested_area=suggested_area
)
# Check the device registry was not saved
assert mock_save.call_count == 0
assert updated_entry != entry
assert updated_entry.area_id == device_area_id
# Check we did not create an area
pool_area = area_registry.async_get_area_by_name(suggested_area)
assert pool_area is None
assert updated_entry.area_id == device_area_id
assert len(area_registry.areas) == number_of_areas
await hass.async_block_till_done()
assert len(update_events) == 1
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
# Do not save or fire the event if the suggested
# area does not result in a change of area
# but still update the actual entry
with patch.object(device_registry, "async_schedule_save") as mock_save_2:
updated_entry = device_registry.async_update_device(
entry.id, suggested_area="Other"
)
assert len(update_events) == 1
assert mock_save_2.call_count == 0
assert updated_entry != entry
assert updated_entry.area_id == device_area_id
@pytest.mark.parametrize(
("config_entry_disabled_by", "device_disabled_by"),
[
(None, None),
(
config_entries.ConfigEntryDisabler.USER,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
),
(None, dr.DeviceEntryDisabler.INTEGRATION),
(None, dr.DeviceEntryDisabler.USER),
],
)
@pytest.mark.usefixtures("freezer")
async def test_update_add_config_entry_disabled_by(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry_disabled_by: config_entries.ConfigEntryDisabler | None,
device_disabled_by: dr.DeviceEntryDisabler | None,
) -> None:
"""Check how the disabled_by flag is treated when adding a config entry.
A device is now owned by a single config entry: add_config_entry_id only records a
transient pending move (completed by a subsequent remove of the current owner), so on
its own it leaves the device - including its disabled_by flag - unchanged.
"""
config_entry_1 = MockConfigEntry(title=None, disabled_by=config_entry_disabled_by)
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry(title=None)
config_entry_2.add_to_hass(hass)
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
config_subentry_id=None,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
disabled_by=device_disabled_by,
)
assert entry.disabled_by == device_disabled_by
entry2 = device_registry.async_update_device(
entry.id, add_config_entry_id=config_entry_2.entry_id
)
# The device is unchanged: still owned by config_entry_1, same disabled_by
assert entry2.config_entry_id == config_entry_1.entry_id
assert entry2.config_subentry_id is None
assert entry2.disabled_by == device_disabled_by
await hass.async_block_till_done()
# The pending move is never stored, so no update event is fired
assert len(update_events) == 1
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
@pytest.mark.parametrize(
("config_entry_disabled_by", "device_disabled_by", "expected_disabled_by"),
[
# An enabled device moved onto a disabled entry is disabled by CONFIG_ENTRY
(None, None, dr.DeviceEntryDisabler.CONFIG_ENTRY),
# An existing CONFIG_ENTRY / INTEGRATION / USER disable is preserved
(
config_entries.ConfigEntryDisabler.USER,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
),
(None, dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION),
(None, dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER),
],
)
@pytest.mark.usefixtures("freezer")
async def test_update_remove_config_entry_disabled_by(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry_disabled_by: config_entries.ConfigEntryDisabler | None,
device_disabled_by: dr.DeviceEntryDisabler | None,
expected_disabled_by: dr.DeviceEntryDisabler | None,
) -> None:
"""Check how the disabled_by flag is treated when removing a config entry.
add_config_entry_id followed by remove_config_entry_id of the current owner moves the
device to the added config entry. The move re-evaluates disabled_by against the new
owning entry (like restoring a deleted device): an enabled device moved onto a
disabled entry becomes CONFIG_ENTRY-disabled, while a USER/INTEGRATION disable - or an
existing CONFIG_ENTRY disable - is kept.
"""
config_entry_1 = MockConfigEntry(title=None, disabled_by=config_entry_disabled_by)
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry(
title=None, disabled_by=config_entries.ConfigEntryDisabler.USER
)
config_entry_2.add_to_hass(hass)
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
config_subentry_id=None,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
disabled_by=device_disabled_by,
)
assert entry.disabled_by == device_disabled_by
# add records a pending move, remove of the current owner performs it
device_registry.async_update_device(
entry.id, add_config_entry_id=config_entry_2.entry_id
)
entry3 = device_registry.async_update_device(
entry.id, remove_config_entry_id=config_entry_1.entry_id
)
# The device moved to config_entry_2, disabled_by reflecting the new entry
assert entry3 is not None
assert entry3.config_entry_id == config_entry_2.entry_id
assert entry3.config_subentry_id is None
assert entry3.disabled_by == expected_disabled_by
await hass.async_block_till_done()
# create + the move update (the add on its own does not fire an event)
assert len(update_events) == 2
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
expected_changes: dict[str, Any] = {"config_entry_id": config_entry_1.entry_id}
if expected_disabled_by != device_disabled_by:
expected_changes["disabled_by"] = device_disabled_by
assert update_events[1].data == {
"action": "update",
"device_id": entry.id,
"changes": expected_changes,
}
async def test_move_to_enabled_config_entry_clears_config_entry_disable(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Moving a device to an enabled config entry clears a CONFIG_ENTRY disable.
The reverse of moving onto a disabled entry; a USER disable is preserved.
"""
disabled_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
)
disabled_entry.add_to_hass(hass)
enabled_entry = MockConfigEntry()
enabled_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "1")},
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
)
device_registry.async_update_device(
device.id, add_config_entry_id=enabled_entry.entry_id
)
moved = device_registry.async_update_device(
device.id, remove_config_entry_id=disabled_entry.entry_id
)
assert moved is not None
assert moved.config_entry_id == enabled_entry.entry_id
assert moved.disabled_by is None
user_device = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "2")},
disabled_by=dr.DeviceEntryDisabler.USER,
)
device_registry.async_update_device(
user_device.id, add_config_entry_id=enabled_entry.entry_id
)
moved_user = device_registry.async_update_device(
user_device.id, remove_config_entry_id=disabled_entry.entry_id
)
assert moved_user is not None
assert moved_user.disabled_by is dr.DeviceEntryDisabler.USER
async def test_move_with_conflicting_disabled_by_ignored(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An explicit disabled_by contradicting the target entry's disabled state.
The conflicting disabled_by is ignored, the disabled state is updated to
reflect the new config entry's disabled state, and a deprecation warning is
logged. This will raise in HA Core 2027.8.
"""
disabled_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
)
disabled_entry.add_to_hass(hass)
enabled_entry = MockConfigEntry()
enabled_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id, identifiers={("test", "1")}
)
moved = device_registry.async_update_device(
device.id,
disabled_by=None,
new_config_entry_id=disabled_entry.entry_id,
)
assert moved is not None
assert moved.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
assert (
"Detected code that sets disabled_by to None when moving a device to the "
f"disabled config entry {disabled_entry.entry_id}. This will stop working "
"in Home Assistant 2027.8, please report this issue"
) in caplog.text
disabled_device = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "2")},
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
)
moved_disabled = device_registry.async_update_device(
disabled_device.id,
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
new_config_entry_id=enabled_entry.entry_id,
)
assert moved_disabled is not None
assert moved_disabled.disabled_by is None
assert (
"Detected code that sets disabled_by to DeviceEntryDisabler.CONFIG_ENTRY "
f"when moving a device to the enabled config entry {enabled_entry.entry_id}. "
"This will stop working in Home Assistant 2027.8, please report this issue"
) in caplog.text
# The same validation applies when the move is performed by removing the
# owning config entry after a pending move was recorded.
pending_device = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id, identifiers={("test", "3")}
)
device_registry.async_update_device(
pending_device.id, add_config_entry_id=disabled_entry.entry_id
)
caplog.clear()
moved_pending = device_registry.async_update_device(
pending_device.id,
disabled_by=None,
remove_config_entry_id=enabled_entry.entry_id,
)
assert moved_pending is not None
assert moved_pending.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
assert (
"Detected code that sets disabled_by to None when moving a device to the "
f"disabled config entry {disabled_entry.entry_id}. This will stop working "
"in Home Assistant 2027.8, please report this issue"
) in caplog.text
async def test_update_conflicting_disabled_by_ignored(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An explicit disabled_by contradicting the owning entry's disabled state.
Without a move, the conflicting disabled_by is ignored and a deprecation
warning is logged. This will raise in HA Core 2027.8.
"""
disabled_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
)
disabled_entry.add_to_hass(hass)
enabled_entry = MockConfigEntry()
enabled_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "1")},
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
)
updated = device_registry.async_update_device(device.id, disabled_by=None)
assert updated is not None
assert updated.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
assert (
"Detected code that sets disabled_by to None on a device belonging to the "
f"disabled config entry {disabled_entry.entry_id}. This will stop working "
"in Home Assistant 2027.8, please report this issue"
) in caplog.text
enabled_device = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id, identifiers={("test", "2")}
)
updated_enabled = device_registry.async_update_device(
enabled_device.id, disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY
)
assert updated_enabled is not None
assert updated_enabled.disabled_by is None
assert (
"Detected code that sets disabled_by to DeviceEntryDisabler.CONFIG_ENTRY on "
f"a device belonging to the enabled config entry {enabled_entry.entry_id}. "
"This will stop working in Home Assistant 2027.8, please report this issue"
) in caplog.text
async def test_create_with_conflicting_disabled_by_ignored(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An explicit disabled_by contradicting the owning entry's disabled state.
When creating a device, the conflicting disabled_by is ignored, the disabled
state is updated to reflect the owning config entry's disabled state, and a
deprecation warning is logged. This will raise in HA Core 2027.8.
"""
disabled_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
)
disabled_entry.add_to_hass(hass)
enabled_entry = MockConfigEntry()
enabled_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "1")},
disabled_by=None,
)
assert device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
assert (
"Detected code that sets disabled_by to None when creating a device "
f"attached to the disabled config entry {disabled_entry.entry_id}. This "
"will stop working in Home Assistant 2027.8, please report this issue"
) in caplog.text
enabled_device = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id,
identifiers={("test", "2")},
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
)
assert enabled_device.disabled_by is None
assert (
"Detected code that sets disabled_by to DeviceEntryDisabler.CONFIG_ENTRY "
"when creating a device attached to the enabled config entry "
f"{enabled_entry.entry_id}. This will stop working in Home Assistant "
"2027.8, please report this issue"
) in caplog.text
async def test_create_reflects_config_entry_disabled_state(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A new device without an explicit disabled_by reflects the owning entry's state."""
disabled_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
)
disabled_entry.add_to_hass(hass)
enabled_entry = MockConfigEntry()
enabled_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id, identifiers={("test", "1")}
)
assert device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
enabled_device = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id, identifiers={("test", "2")}
)
assert enabled_device.disabled_by is None
# Restoring a deleted device from a legacy store without a recorded
# disabled_by is reconciled the same way
device_registry.async_remove_device(device.id)
deleted_entry = device_registry.deleted_devices[device.id]
device_registry.deleted_devices[device.id] = attr.evolve(
deleted_entry, disabled_by=UNDEFINED
)
restored = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id, identifiers={("test", "1")}
)
assert restored.id == device.id
assert restored.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
assert "Detected code that" not in caplog.text
async def test_update_explicit_disabled_by(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An explicit disabled_by consistent with the owning entry's state is applied."""
disabled_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
)
disabled_entry.add_to_hass(hass)
enabled_entry = MockConfigEntry()
enabled_entry.add_to_hass(hass)
# A USER disable is accepted on a device of a disabled entry, and the device
# can be flagged CONFIG_ENTRY again
device = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "1")},
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
)
updated = device_registry.async_update_device(
device.id, disabled_by=dr.DeviceEntryDisabler.USER
)
assert updated is not None
assert updated.disabled_by is dr.DeviceEntryDisabler.USER
updated = device_registry.async_update_device(
device.id, disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY
)
assert updated is not None
assert updated.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
# A USER disable and enabling are accepted on a device of an enabled entry
enabled_device = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id, identifiers={("test", "2")}
)
updated_enabled = device_registry.async_update_device(
enabled_device.id, disabled_by=dr.DeviceEntryDisabler.USER
)
assert updated_enabled is not None
assert updated_enabled.disabled_by is dr.DeviceEntryDisabler.USER
updated_enabled = device_registry.async_update_device(
enabled_device.id, disabled_by=None
)
assert updated_enabled is not None
assert updated_enabled.disabled_by is None
assert "Detected code that" not in caplog.text
async def test_move_with_explicit_disabled_by(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An explicit disabled_by consistent with the target entry's state is applied."""
disabled_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
)
disabled_entry.add_to_hass(hass)
enabled_entry = MockConfigEntry()
enabled_entry.add_to_hass(hass)
# A USER disable is accepted on a move to a disabled entry
device = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id, identifiers={("test", "1")}
)
moved = device_registry.async_update_device(
device.id,
disabled_by=dr.DeviceEntryDisabler.USER,
new_config_entry_id=disabled_entry.entry_id,
)
assert moved is not None
assert moved.disabled_by is dr.DeviceEntryDisabler.USER
# A USER disable is accepted on a move to an enabled entry (the
# CONFIG_ENTRY to USER rewrite used by migrations)
device_2 = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "2")},
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
)
moved_2 = device_registry.async_update_device(
device_2.id,
disabled_by=dr.DeviceEntryDisabler.USER,
new_config_entry_id=enabled_entry.entry_id,
)
assert moved_2 is not None
assert moved_2.disabled_by is dr.DeviceEntryDisabler.USER
# A CONFIG_ENTRY disable is accepted on a move to a disabled entry
device_3 = device_registry.async_get_or_create(
config_entry_id=enabled_entry.entry_id,
identifiers={("test", "3")},
disabled_by=dr.DeviceEntryDisabler.USER,
)
moved_3 = device_registry.async_update_device(
device_3.id,
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
new_config_entry_id=disabled_entry.entry_id,
)
assert moved_3 is not None
assert moved_3.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
# No disable is accepted on a move to an enabled entry
device_4 = device_registry.async_get_or_create(
config_entry_id=disabled_entry.entry_id,
identifiers={("test", "4")},
disabled_by=dr.DeviceEntryDisabler.USER,
)
moved_4 = device_registry.async_update_device(
device_4.id,
disabled_by=None,
new_config_entry_id=enabled_entry.entry_id,
)
assert moved_4 is not None
assert moved_4.disabled_by is None
async def test_move_to_config_entry_with_colliding_identity_raises(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Moving a device onto a config entry that already has its identity raises.
Identifiers and connections are unique per config entry, so a move must validate the
device's retained identity against the target entry instead of silently overwriting
the existing device's index slot.
"""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
device_a = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "shared")}
)
device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "shared")}
)
with pytest.raises(dr.DeviceIdentifierCollisionError):
device_registry.async_update_device(
device_a.id, new_config_entry_id=entry_2.entry_id
)
mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")
device_c = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, connections={mac}
)
device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, connections={mac}
)
with pytest.raises(dr.DeviceConnectionCollisionError):
device_registry.async_update_device(
device_c.id, new_config_entry_id=entry_2.entry_id
)
async def test_add_identifier_keeps_other_config_entry_deleted_device(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Adding an identifier does not delete a matching deleted device of another entry.
Deleted devices are per config entry now, so a device in entry A merging an
identifier must not wipe entry B's deleted-device metadata (its restore data).
"""
entry_a = MockConfigEntry()
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry()
entry_b.add_to_hass(hass)
device_a = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("test", "a")}
)
device_b = device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id, identifiers={("test", "shared")}
)
device_registry.async_update_device(device_b.id, name_by_user="Custom B")
device_b_id = device_b.id
device_registry.async_remove_device(device_b.id)
# entry A's device merges the identifier entry B's deleted device also has
device_registry.async_update_device(
device_a.id, merge_identifiers={("test", "shared")}
)
# entry B's deleted device survives, so re-registering restores its id and metadata
restored_b = device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id, identifiers={("test", "shared")}
)
assert restored_b.id == device_b_id
assert restored_b.name_by_user == "Custom B"
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_remaps_via_device_id_to_split(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A child's via_device_id is remapped to a live parent split.
To the split in the child's own config entry when the parent spanned it, then to a
split owned by the child's domain, otherwise to one of the parent's splits - never
left dangling on the removed composite id.
"""
entry_a = MockConfigEntry(domain="dom_a")
entry_a.add_to_hass(hass)
entry_a2 = MockConfigEntry(domain="dom_a")
entry_a2.add_to_hass(hass)
entry_b = MockConfigEntry(domain="dom_b")
entry_b.add_to_hass(hass)
entry_c = MockConfigEntry(domain="dom_c")
entry_c.add_to_hass(hass)
def _device(id_: str, entries: list[str], identifiers, via: str | None) -> dict:
return {
"area_id": None,
"config_entries": entries,
"config_entries_subentries": {entry: [None] for entry in entries},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": id_,
"identifiers": identifiers,
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": entries[0],
"serial_number": None,
"sw_version": None,
"via_device_id": via,
}
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
_device(
"parent000000000000000000000000",
[entry_a.entry_id, entry_b.entry_id],
[["dom_a", "p"], ["dom_b", "p"]],
None,
),
_device(
"child0000000000000000000000000",
[entry_a.entry_id],
[["dom_a", "c"]],
"parent000000000000000000000000",
),
# child in another config entry of dom_a, which the parent does not span
_device(
"childa200000000000000000000000",
[entry_a2.entry_id],
[["dom_a", "c2"]],
"parent000000000000000000000000",
),
# child in a config entry the parent does not span
_device(
"childc000000000000000000000000",
[entry_c.entry_id],
[["dom_c", "c"]],
"parent000000000000000000000000",
),
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
# The parent splits (fresh split ids, not the old composite id)
parent_a = registry.async_get_device(identifiers={("dom_a", "p")})
parent_b = registry.async_get_device(identifiers={("dom_b", "p")})
assert parent_a is not None
assert parent_b is not None
assert parent_a.config_entry_id == entry_a.entry_id
assert parent_a.id != "parent000000000000000000000000"
# The child in entry_a points at the parent's entry_a split
child = registry.async_get_device(identifiers={("dom_a", "c")})
assert child is not None
assert child.via_device_id == parent_a.id
# The child in entry_a2, which the parent did not span, points at the parent's
# split owned by its domain
child_a2 = registry.async_get_device(identifiers={("dom_a", "c2")})
assert child_a2 is not None
assert child_a2.via_device_id == parent_a.id
# The child in entry_c, which the parent did not span, points at one of the parent's
# splits rather than the removed composite id
child_c = registry.async_get_device(identifiers={("dom_c", "c")})
assert child_c is not None
assert child_c.via_device_id in {parent_a.id, parent_b.id}
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_from_3_1_rewrites_stale_via_device_id(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Stale via_device_id links are remapped or detached when migrating from 3.1."""
entry_a = MockConfigEntry(domain="dom_a")
entry_a.add_to_hass(hass)
entry_a2 = MockConfigEntry(domain="dom_a")
entry_a2.add_to_hass(hass)
entry_b = MockConfigEntry(domain="dom_b")
entry_b.add_to_hass(hass)
entry_c = MockConfigEntry(domain="dom_c")
entry_c.add_to_hass(hass)
composite_id = "composite000000000000000000000"
def _device(
id_: str,
config_entry_id: str,
identifiers: list[list[str]],
via: str | None,
composite: str | None = None,
) -> dict[str, Any]:
return {
"area_id": None,
"config_entry_id": config_entry_id,
"config_subentry_id": None,
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": id_,
"identifiers": identifiers,
"labels": [],
"composite_device_id": composite,
"composite_primary_config_entry": None,
"split_at": None,
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"has_composite_identifiers": composite is not None,
"primary_config_entry": config_entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": via,
}
hass_storage[dr.STORAGE_KEY] = {
"version": 3,
"minor_version": 1,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
_device(
"splita000000000000000000000000",
entry_a.entry_id,
[["dom_a", "p"]],
None,
composite=composite_id,
),
_device(
"splitb000000000000000000000000",
entry_b.entry_id,
[["dom_b", "p"]],
None,
composite=composite_id,
),
# composite link from an entry the parent spans: its split
_device(
"childa000000000000000000000000",
entry_a.entry_id,
[["dom_a", "c"]],
composite_id,
),
# composite link from another entry of a split's domain: that split
_device(
"childa200000000000000000000000",
entry_a2.entry_id,
[["dom_a", "c2"]],
composite_id,
),
# composite link sharing neither entry nor domain: any split
_device(
"childc000000000000000000000000",
entry_c.entry_id,
[["dom_c", "c"]],
composite_id,
),
# link to an unknown device: detached
_device(
"childx000000000000000000000000",
entry_a.entry_id,
[["dom_a", "cx"]],
"unknown0000000000000000000000",
),
# link to a live device: kept
_device(
"childl000000000000000000000000",
entry_a.entry_id,
[["dom_a", "cl"]],
"splitb000000000000000000000000",
),
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
assert (
registry.devices["childa000000000000000000000000"].via_device_id
== "splita000000000000000000000000"
)
assert (
registry.devices["childa200000000000000000000000"].via_device_id
== "splita000000000000000000000000"
)
assert registry.devices["childc000000000000000000000000"].via_device_id in {
"splita000000000000000000000000",
"splitb000000000000000000000000",
}
assert registry.devices["childx000000000000000000000000"].via_device_id is None
assert (
registry.devices["childl000000000000000000000000"].via_device_id
== "splitb000000000000000000000000"
)
@pytest.mark.parametrize("load_registries", [False])
@pytest.mark.parametrize(
("composite_disabled_by", "expected_split_enabled", "expected_split_disabled"),
[
pytest.param(
None, None, dr.DeviceEntryDisabler.CONFIG_ENTRY, id="enabled_composite"
),
pytest.param(
dr.DeviceEntryDisabler.USER,
dr.DeviceEntryDisabler.USER,
dr.DeviceEntryDisabler.USER,
id="user_disabled",
),
pytest.param(
dr.DeviceEntryDisabler.CONFIG_ENTRY,
None,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
id="config_entry_disabled",
),
],
)
async def test_migration_split_disabled_by_follows_config_entry(
hass: HomeAssistant,
hass_storage: dict[str, Any],
composite_disabled_by: dr.DeviceEntryDisabler | None,
expected_split_enabled: dr.DeviceEntryDisabler | None,
expected_split_disabled: dr.DeviceEntryDisabler,
) -> None:
"""A split's disabled_by follows its single owning config entry's disabled state.
A composite spanning an enabled and a disabled config entry copies its disabled_by to
both splits; each split is then reconciled against its own entry - the split owned by
the disabled entry becomes CONFIG_ENTRY disabled (a USER disable is preserved), while
the split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared.
"""
entry_enabled = MockConfigEntry(domain="dom_a")
entry_enabled.add_to_hass(hass)
entry_disabled = MockConfigEntry(
domain="dom_b", disabled_by=config_entries.ConfigEntryDisabler.USER
)
entry_disabled.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [
entry_enabled.entry_id,
entry_disabled.entry_id,
],
"config_entries_subentries": {
entry_enabled.entry_id: [None],
entry_disabled.entry_id: [None],
},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": composite_disabled_by,
"entry_type": None,
"hw_version": None,
"id": "composite00000000000000000000",
"identifiers": [["dom_a", "x"], ["dom_b", "x"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": entry_enabled.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
split_enabled = registry.async_get_device(identifiers={("dom_a", "x")})
split_disabled = registry.async_get_device(identifiers={("dom_b", "x")})
assert split_enabled is not None
assert split_disabled is not None
assert split_enabled.config_entry_id == entry_enabled.entry_id
assert split_disabled.config_entry_id == entry_disabled.entry_id
# The split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared
assert split_enabled.disabled_by is expected_split_enabled
# The split owned by the disabled entry follows that entry (USER preserved)
assert split_disabled.disabled_by is expected_split_disabled
@pytest.mark.parametrize("load_registries", [False])
async def test_disabled_by_not_reconciled_without_composite_split(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""disabled_by is reconciled only for split composites, not other migrated devices.
A 1.12 -> 1.13 migration that splits no composite does not touch a device whose stored
disabled_by does not match its config entry.
"""
entry = MockConfigEntry(
domain="dom_a", disabled_by=config_entries.ConfigEntryDisabler.USER
)
entry.add_to_hass(hass)
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": "device000000000000000000000000",
"identifiers": [["dom_a", "x"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": entry.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
device = registry.async_get_device(identifiers={("dom_a", "x")})
assert device is not None
# The reconcile is gated on a composite split, so disabled_by is left as stored
assert device.disabled_by is None
@pytest.mark.parametrize("config_entry_disabled", [False, True])
@pytest.mark.parametrize(
"initial_disabled_by",
[
None,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
dr.DeviceEntryDisabler.INTEGRATION,
dr.DeviceEntryDisabler.USER,
],
)
async def test_migrate_device_disabled_by_matches_runtime(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
initial_disabled_by: dr.DeviceEntryDisabler | None,
config_entry_disabled: bool,
) -> None:
"""The migration dict reconcile matches async_config_entry_disabled_by_changed.
_migrate_device_disabled_by reimplements the runtime helper on stored data, so for
every combination of device disabled_by and config entry state both must agree.
"""
config_entry = MockConfigEntry(
disabled_by=config_entries.ConfigEntryDisabler.USER
if config_entry_disabled
else None
)
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={("test", "1")}
)
# Explicit disabled_by bypasses async_update_device's own reconciliation
device_registry.async_update_device(device.id, disabled_by=initial_disabled_by)
# Runtime helper on the loaded registry
dr.async_config_entry_disabled_by_changed(device_registry, config_entry)
runtime_result = device_registry.async_get(device.id).disabled_by
# Migration helper on the stored representation
stored = {"disabled_by": initial_disabled_by}
dr._migrate_device_disabled_by(stored, config_entry_disabled)
assert stored["disabled_by"] == runtime_result
@pytest.mark.parametrize("load_registries", [False])
async def test_composite_lineage_not_restored_after_remove(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""A migrated split loses its composite lineage once removed.
The deleted device does not carry composite data, so re-registering the split makes a
plain device that no longer resolves from the pre-migration composite id.
"""
entry_a = MockConfigEntry(domain="dom_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="dom_b")
entry_b.add_to_hass(hass)
old_id = "composite00000000000000000000"
hass_storage[dr.STORAGE_KEY] = {
"version": 1,
"minor_version": 12,
"key": dr.STORAGE_KEY,
"data": {
"devices": [
{
"area_id": None,
"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": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": old_id,
"identifiers": [["dom_a", "x"], ["dom_b", "x"]],
"labels": [],
"manufacturer": None,
"model": None,
"name": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"primary_config_entry": entry_a.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
split_a = registry.async_get_device(identifiers={("dom_a", "x")})
assert split_a is not None
assert split_a.composite_device_id == old_id
# Remove the split; the deleted device does not carry the composite lineage
registry.async_remove_device(split_a.id)
# Re-registering reuses the deleted device's id but drops the composite lineage
restored = registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("dom_a", "x")}
)
assert restored.id == split_a.id
assert restored.composite_device_id is None
assert restored not in registry.async_get_devices_for_composite_device_id(old_id)
async def test_cleanup_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test cleanup works."""
config_entry = MockConfigEntry(domain="hue")
config_entry.add_to_hass(hass)
ghost_config_entry = MockConfigEntry()
ghost_config_entry.add_to_hass(hass)
d1 = device_registry.async_get_or_create(
identifiers={("hue", "d1")}, config_entry_id=config_entry.entry_id
)
device_registry.async_get_or_create(
identifiers={("hue", "d2")}, config_entry_id=config_entry.entry_id
)
d3 = device_registry.async_get_or_create(
identifiers={("hue", "d3")}, config_entry_id=config_entry.entry_id
)
device_registry.async_get_or_create(
identifiers={("something", "d4")}, config_entry_id=ghost_config_entry.entry_id
)
# Remove the config entry without triggering the normal cleanup
hass.config_entries._entries.pop(ghost_config_entry.entry_id)
entity_registry.async_get_or_create("light", "hue", "e1", device_id=d1.id)
entity_registry.async_get_or_create("light", "hue", "e2", device_id=d1.id)
entity_registry.async_get_or_create("light", "hue", "e3", device_id=d3.id)
# Manual cleanup should detect the orphaned config entry
dr.async_cleanup(hass, device_registry, entity_registry)
assert device_registry.async_get_device(identifiers={("hue", "d1")}) is not None
assert device_registry.async_get_device(identifiers={("hue", "d2")}) is not None
assert device_registry.async_get_device(identifiers={("hue", "d3")}) is not None
assert device_registry.async_get_device(identifiers={("something", "d4")}) is None
async def test_cleanup_device_registry_removes_expired_orphaned_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test cleanup removes expired orphaned devices."""
config_entry = MockConfigEntry(domain="hue")
config_entry.add_to_hass(hass)
device_registry.async_get_or_create(
identifiers={("hue", "d1")}, config_entry_id=config_entry.entry_id
)
device_registry.async_get_or_create(
identifiers={("hue", "d2")}, config_entry_id=config_entry.entry_id
)
device_registry.async_get_or_create(
identifiers={("hue", "d3")}, config_entry_id=config_entry.entry_id
)
device_registry.async_clear_config_entry(config_entry.entry_id)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 3
dr.async_cleanup(hass, device_registry, entity_registry)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 3
future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1
with patch("time.time", return_value=future_time):
dr.async_cleanup(hass, device_registry, entity_registry)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 0
async def test_cleanup_startup(hass: HomeAssistant) -> None:
"""Test we run a cleanup on startup."""
hass.set_state(CoreState.not_running)
with patch(
"homeassistant.helpers.device_registry.Debouncer.async_call"
) as mock_call:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_call.mock_calls) == 1
@pytest.mark.parametrize("load_registries", [False])
async def test_cleanup_entity_registry_change(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we run a cleanup when entity registry changes.
Don't pre-load the registries as the debouncer will then not be waiting for
EVENT_ENTITY_REGISTRY_UPDATED events.
"""
dr.async_setup(hass)
await dr.async_load(hass)
await er.async_load(hass)
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
entry = dev_reg.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
with patch(
"homeassistant.helpers.device_registry.Debouncer.async_schedule_call"
) as mock_call:
entity = ent_reg.async_get_or_create("light", "hue", "e1")
await hass.async_block_till_done()
assert len(mock_call.mock_calls) == 0
# Normal update does not trigger
ent_reg.async_update_entity(entity.entity_id, name="updated")
await hass.async_block_till_done()
assert len(mock_call.mock_calls) == 0
# Device ID update triggers
ent_reg.async_get_or_create("light", "hue", "e1", device_id=entry.id)
await hass.async_block_till_done()
assert len(mock_call.mock_calls) == 1
# Removal also triggers
ent_reg.async_remove(entity.entity_id)
await hass.async_block_till_done()
assert len(mock_call.mock_calls) == 2
@pytest.mark.parametrize("initial_area", [None, "12345A"])
@pytest.mark.usefixtures("freezer")
async def test_restore_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry_with_subentries: MockConfigEntry,
initial_area: str | None,
) -> None:
"""Make sure device id is stable."""
entry_id = mock_config_entry_with_subentries.entry_id
subentry_id = "mock-subentry-id-1-1"
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
entry = device_registry.async_get_or_create(
config_entry_id=entry_id,
config_subentry_id=subentry_id,
configuration_url="http://config_url_orig.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
entry_type=dr.DeviceEntryType.SERVICE,
hw_version="hw_version_orig",
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer_orig",
model="model_orig",
model_id="model_id_orig",
name="name_orig",
serial_number="serial_no_orig",
suggested_area="suggested_area_orig",
sw_version="version_orig",
via_device="via_device_id_orig",
)
# Apply user customizations
entry = device_registry.async_update_device(
entry.id,
area_id=initial_area,
disabled_by=dr.DeviceEntryDisabler.USER,
labels={"label1", "label2"},
name_by_user="Test Friendly Name",
)
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
device_registry.async_remove_device(entry.id)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 1
# This will create a new device
entry2 = device_registry.async_get_or_create(
config_entry_id=entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")},
identifiers={("bridgeid", "4567")},
manufacturer="manufacturer",
model="model",
)
assert entry2 == dr.DeviceEntry(
area_id=None,
config_entry_id=entry_id,
config_subentry_id=None,
configuration_url=None,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")},
created_at=utcnow(),
disabled_by=None,
entry_type=None,
hw_version=None,
id=ANY,
identifiers={("bridgeid", "4567")},
labels={},
manufacturer="manufacturer",
model="model",
model_id=None,
modified_at=utcnow(),
name_by_user=None,
name=None,
serial_number=None,
sw_version=None,
)
# This will restore the original device, user customizations of
# area_id, disabled_by, labels and name_by_user will be restored
entry3 = device_registry.async_get_or_create(
config_entry_id=entry_id,
config_subentry_id=subentry_id,
configuration_url="http://config_url_new.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
entry_type=None,
hw_version="hw_version_new",
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer_new",
model="model_new",
model_id="model_id_new",
name="name_new",
serial_number="serial_no_new",
suggested_area="suggested_area_new",
sw_version="version_new",
via_device="via_device_id_new",
)
assert entry3 == dr.DeviceEntry(
area_id=initial_area,
config_entry_id=entry_id,
config_subentry_id=subentry_id,
configuration_url="http://config_url_new.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
created_at=utcnow(),
disabled_by=dr.DeviceEntryDisabler.USER,
entry_type=None,
hw_version="hw_version_new",
id=entry.id,
identifiers={("bridgeid", "0123")},
labels={"label1", "label2"},
manufacturer="manufacturer_new",
model="model_new",
model_id="model_id_new",
modified_at=utcnow(),
name_by_user="Test Friendly Name",
name="name_new",
serial_number="serial_no_new",
suggested_area="suggested_area_new",
sw_version="version_new",
)
assert entry.id == entry3.id
assert entry.id != entry2.id
assert len(device_registry.devices) == 2
assert len(device_registry.deleted_devices) == 0
assert isinstance(entry3.config_entries, set)
assert isinstance(entry3.connections, set)
assert isinstance(entry3.identifiers, set)
await hass.async_block_till_done()
assert len(update_events) == 5
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
assert update_events[1].data == {
"action": "update",
"changes": {
"area_id": "suggested_area_orig",
"disabled_by": None,
"labels": set(),
"name_by_user": None,
},
"device_id": entry.id,
}
assert update_events[2].data == {
"action": "remove",
"device_id": entry.id,
"device": entry.dict_repr,
}
assert update_events[3].data == {
"action": "create",
"device_id": entry2.id,
}
assert update_events[4].data == {
"action": "create",
"device_id": entry3.id,
}
@pytest.mark.parametrize(
("stored_connections", "stored_identifiers", "new_connections", "new_identifiers"),
[
pytest.param(
{(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa")},
{("bridgeid", "0123")},
{
(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa"),
(dr.CONNECTION_NETWORK_MAC, "bb:bb:bb:bb:bb:bb"),
},
{("bridgeid", "0123"), ("bridgeid", "4567")},
id="broader_reregistration",
),
pytest.param(
{(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa")},
{("bridgeid", "0123")},
{(dr.CONNECTION_NETWORK_MAC, "bb:bb:bb:bb:bb:bb")},
{("bridgeid", "0123")},
id="disjoint_connection_matched_on_identifier",
),
],
)
async def test_restore_device_reflects_reregistered_identity(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
stored_connections: set[tuple[str, str]],
stored_identifiers: set[tuple[str, str]],
new_connections: set[tuple[str, str]],
new_identifiers: set[tuple[str, str]],
) -> None:
"""A restored device keeps the connections and identifiers it is re-registered with.
The restored device must reflect the identity the integration reports on
re-registration, not its intersection with the stored (deleted) identity, so a
device that now reports a broader (or shifted) set is restored with all of it.
"""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections=stored_connections,
identifiers=stored_identifiers,
)
device_registry.async_remove_device(entry.id)
assert len(device_registry.deleted_devices) == 1
restored = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections=new_connections,
identifiers=new_identifiers,
)
assert restored.id == entry.id
assert restored.connections == new_connections
assert restored.identifiers == new_identifiers
@pytest.mark.parametrize(
("new_connections", "new_identifiers"),
[
pytest.param(
{
(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa"),
(dr.CONNECTION_NETWORK_MAC, "bb:bb:bb:bb:bb:bb"),
},
{("bridgeid", "0123"), ("bridgeid", "4567")},
id="broader_reregistration",
),
pytest.param(
{(dr.CONNECTION_NETWORK_MAC, "cc:cc:cc:cc:cc:cc")},
{("bridgeid", "9999")},
id="disjoint_reregistration",
),
],
)
async def test_deleted_device_to_device_entry_uses_reregistered_identity(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
new_connections: set[tuple[str, str]],
new_identifiers: set[tuple[str, str]],
) -> None:
"""DeletedDeviceEntry.to_device_entry keeps the passed connections and identifiers.
Regression guard: it must not intersect them with the stored ones, which would drop
(or, for a disjoint re-registration, entirely empty) the device's identity.
"""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "aa:aa:aa:aa:aa:aa")},
identifiers={("bridgeid", "0123")},
)
device_registry.async_remove_device(entry.id)
deleted_device = device_registry.deleted_devices[entry.id]
restored = deleted_device.to_device_entry(
mock_config_entry,
None,
new_connections,
new_identifiers,
None,
)
assert restored.connections == new_connections
assert restored.identifiers == new_identifiers
@pytest.mark.parametrize(
("device_disabled_by", "expected_disabled_by"),
[
(None, None),
# A CONFIG_ENTRY disable contradicts the enabled config entry and is
# cleared when the device is restored
(dr.DeviceEntryDisabler.CONFIG_ENTRY, None),
(dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION),
(dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER),
(UNDEFINED, None),
],
)
@pytest.mark.usefixtures("freezer")
async def test_restore_migrated_device_disabled_by(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
device_disabled_by: dr.DeviceEntryDisabler | UndefinedType | None,
expected_disabled_by: dr.DeviceEntryDisabler | None,
) -> None:
"""Check how the disabled_by flag is treated when restoring a device."""
entry_id = mock_config_entry.entry_id
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
entry = device_registry.async_get_or_create(
config_entry_id=entry_id,
config_subentry_id=None,
configuration_url="http://config_url_orig.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
disabled_by=None,
entry_type=dr.DeviceEntryType.SERVICE,
hw_version="hw_version_orig",
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer_orig",
model="model_orig",
model_id="model_id_orig",
name="name_orig",
serial_number="serial_no_orig",
suggested_area="suggested_area_orig",
sw_version="version_orig",
via_device="via_device_id_orig",
)
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
device_registry.async_remove_device(entry.id)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 1
deleted_entry = device_registry.deleted_devices[entry.id]
device_registry.deleted_devices[entry.id] = attr.evolve(
deleted_entry, disabled_by=UNDEFINED
)
# This will restore the original device, user customizations of
# area_id, disabled_by, labels and name_by_user will be restored
entry3 = device_registry.async_get_or_create(
config_entry_id=entry_id,
config_subentry_id=None,
configuration_url="http://config_url_new.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
disabled_by=device_disabled_by,
entry_type=None,
hw_version="hw_version_new",
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer_new",
model="model_new",
model_id="model_id_new",
name="name_new",
serial_number="serial_no_new",
suggested_area="suggested_area_new",
sw_version="version_new",
via_device="via_device_id_new",
)
assert entry3 == dr.DeviceEntry(
area_id="suggested_area_orig",
config_entry_id=entry_id,
config_subentry_id=None,
configuration_url="http://config_url_new.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
created_at=utcnow(),
disabled_by=expected_disabled_by,
entry_type=None,
hw_version="hw_version_new",
id=entry.id,
identifiers={("bridgeid", "0123")},
labels=set(),
manufacturer="manufacturer_new",
model="model_new",
model_id="model_id_new",
modified_at=utcnow(),
name_by_user=None,
name="name_new",
serial_number="serial_no_new",
suggested_area="suggested_area_new",
sw_version="version_new",
)
assert entry.id == entry3.id
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
assert isinstance(entry3.config_entries, set)
assert isinstance(entry3.connections, set)
assert isinstance(entry3.identifiers, set)
await hass.async_block_till_done()
assert len(update_events) == 3
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
assert update_events[1].data == {
"action": "remove",
"device_id": entry.id,
"device": entry.dict_repr,
}
assert update_events[2].data == {
"action": "create",
"device_id": entry3.id,
}
@pytest.mark.parametrize(
(
"config_entry_disabled_by",
"device_disabled_by_deleted",
"device_disabled_by_restored",
),
[
(
None,
None,
None,
),
# Config entry not disabled, device was disabled by config entry.
# Device not disabled when restored.
(
None,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
None,
),
(
None,
dr.DeviceEntryDisabler.INTEGRATION,
dr.DeviceEntryDisabler.INTEGRATION,
),
(
None,
dr.DeviceEntryDisabler.USER,
dr.DeviceEntryDisabler.USER,
),
# Config entry disabled, device not disabled.
# Device disabled by config entry when restored.
(
config_entries.ConfigEntryDisabler.USER,
None,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
),
(
config_entries.ConfigEntryDisabler.USER,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
dr.DeviceEntryDisabler.CONFIG_ENTRY,
),
(
config_entries.ConfigEntryDisabler.USER,
dr.DeviceEntryDisabler.INTEGRATION,
dr.DeviceEntryDisabler.INTEGRATION,
),
(
config_entries.ConfigEntryDisabler.USER,
dr.DeviceEntryDisabler.USER,
dr.DeviceEntryDisabler.USER,
),
],
)
@pytest.mark.usefixtures("freezer")
async def test_restore_disabled_by(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
config_entry_disabled_by: config_entries.ConfigEntryDisabler | None,
device_disabled_by_deleted: dr.DeviceEntryDisabler | None,
device_disabled_by_restored: dr.DeviceEntryDisabler | None,
) -> None:
"""Check how the disabled_by flag is treated when restoring a device."""
entry_id = mock_config_entry.entry_id
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
await hass.config_entries.async_set_disabled_by(
mock_config_entry.entry_id, config_entry_disabled_by
)
entry = device_registry.async_get_or_create(
config_entry_id=entry_id,
config_subentry_id=None,
configuration_url="http://config_url_orig.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
entry_type=dr.DeviceEntryType.SERVICE,
hw_version="hw_version_orig",
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer_orig",
model="model_orig",
model_id="model_id_orig",
name="name_orig",
serial_number="serial_no_orig",
suggested_area="suggested_area_orig",
sw_version="version_orig",
via_device="via_device_id_orig",
)
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
device_registry.async_remove_device(entry.id)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 1
# Simulate the disabled_by flag the device had when it was deleted. The
# device may have been deleted before the config entry's disabled state
# last changed - deleted devices are not updated when a config entry is
# enabled or disabled, so the stored flag can contradict the entry's
# current disabled state.
deleted_entry = device_registry.deleted_devices[entry.id]
device_registry.deleted_devices[entry.id] = attr.evolve(
deleted_entry, disabled_by=device_disabled_by_deleted
)
# This will restore the original device, user customizations of
# area_id, disabled_by, labels and name_by_user will be restored
entry3 = device_registry.async_get_or_create(
config_entry_id=entry_id,
config_subentry_id=None,
configuration_url="http://config_url_new.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
disabled_by=None,
entry_type=None,
hw_version="hw_version_new",
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer_new",
model="model_new",
model_id="model_id_new",
name="name_new",
serial_number="serial_no_new",
suggested_area="suggested_area_new",
sw_version="version_new",
via_device="via_device_id_new",
)
assert entry3 == dr.DeviceEntry(
area_id="suggested_area_orig",
config_entry_id=entry_id,
config_subentry_id=None,
configuration_url="http://config_url_new.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
created_at=utcnow(),
disabled_by=device_disabled_by_restored,
entry_type=None,
hw_version="hw_version_new",
id=entry.id,
identifiers={("bridgeid", "0123")},
labels=set(),
manufacturer="manufacturer_new",
model="model_new",
model_id="model_id_new",
modified_at=utcnow(),
name_by_user=None,
name="name_new",
serial_number="serial_no_new",
suggested_area="suggested_area_new",
sw_version="version_new",
)
assert entry.id == entry3.id
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
assert isinstance(entry3.config_entries, set)
assert isinstance(entry3.connections, set)
assert isinstance(entry3.identifiers, set)
await hass.async_block_till_done()
assert len(update_events) == 3
assert update_events[0].data == {
"action": "create",
"device_id": entry.id,
}
assert update_events[1].data == {
"action": "remove",
"device_id": entry.id,
"device": entry.dict_repr,
}
assert update_events[2].data == {
"action": "create",
"device_id": entry3.id,
}
async def test_get_or_create_empty_then_set_default_values(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating an entry, then setting default name, model, manufacturer."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert entry.name is None
assert entry.model is None
assert entry.manufacturer is None
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
default_name="default name 1",
default_model="default model 1",
default_manufacturer="default manufacturer 1",
)
assert entry.name == "default name 1"
assert entry.model == "default model 1"
assert entry.manufacturer == "default manufacturer 1"
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
default_name="default name 2",
default_model="default model 2",
default_manufacturer="default manufacturer 2",
)
assert entry.name == "default name 1"
assert entry.model == "default model 1"
assert entry.manufacturer == "default manufacturer 1"
async def test_get_or_create_empty_then_update(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating an entry, then setting name, model, manufacturer."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
assert entry.name is None
assert entry.model is None
assert entry.manufacturer is None
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
name="name 1",
model="model 1",
manufacturer="manufacturer 1",
)
assert entry.name == "name 1"
assert entry.model == "model 1"
assert entry.manufacturer == "manufacturer 1"
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
default_name="default name 1",
default_model="default model 1",
default_manufacturer="default manufacturer 1",
)
assert entry.name == "name 1"
assert entry.model == "model 1"
assert entry.manufacturer == "manufacturer 1"
async def test_get_or_create_sets_default_values(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating an entry, then setting default name, model, manufacturer."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
default_name="default name 1",
default_model="default model 1",
default_manufacturer="default manufacturer 1",
)
assert entry.name == "default name 1"
assert entry.model == "default model 1"
assert entry.manufacturer == "default manufacturer 1"
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
default_name="default name 2",
default_model="default model 2",
default_manufacturer="default manufacturer 2",
)
assert entry.name == "default name 1"
assert entry.model == "default model 1"
assert entry.manufacturer == "default manufacturer 1"
async def test_verify_suggested_area_does_not_overwrite_area_id(
device_registry: dr.DeviceRegistry,
area_registry: ar.AreaRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Make sure suggested area does not override a set area id."""
game_room_area = area_registry.async_create("Game Room")
original_entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
sw_version="sw-version",
name="name",
manufacturer="manufacturer",
model="model",
)
entry = device_registry.async_update_device(
original_entry.id, area_id=game_room_area.id
)
assert entry.area_id == game_room_area.id
entry2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
sw_version="sw-version",
name="name",
manufacturer="manufacturer",
model="model",
suggested_area="New Game Room",
)
assert entry2.area_id == game_room_area.id
async def test_disable_config_entry_disables_devices(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test that we disable entities tied to a config entry."""
config_entry = MockConfigEntry(domain="light")
config_entry.add_to_hass(hass)
entry1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
entry2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")},
disabled_by=dr.DeviceEntryDisabler.USER,
)
assert not entry1.disabled
assert entry2.disabled
await hass.config_entries.async_set_disabled_by(
config_entry.entry_id, config_entries.ConfigEntryDisabler.USER
)
await hass.async_block_till_done()
entry1 = device_registry.async_get(entry1.id)
assert entry1.disabled
assert entry1.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
entry2 = device_registry.async_get(entry2.id)
assert entry2.disabled
assert entry2.disabled_by is dr.DeviceEntryDisabler.USER
await hass.config_entries.async_set_disabled_by(config_entry.entry_id, None)
await hass.async_block_till_done()
entry1 = device_registry.async_get(entry1.id)
assert not entry1.disabled
entry2 = device_registry.async_get(entry2.id)
assert entry2.disabled
assert entry2.disabled_by is dr.DeviceEntryDisabler.USER
@pytest.mark.parametrize(
("configuration_url", "expectation"),
[
("http://localhost", nullcontext()),
("http://localhost:8123", nullcontext()),
("https://example.com", nullcontext()),
("http://localhost/config", nullcontext()),
("http://localhost:8123/config", nullcontext()),
("https://example.com/config", nullcontext()),
("homeassistant://config", nullcontext()),
(URL("http://localhost"), nullcontext()),
(URL("http://localhost:8123"), nullcontext()),
(URL("https://example.com"), nullcontext()),
(URL("http://localhost/config"), nullcontext()),
(URL("http://localhost:8123/config"), nullcontext()),
(URL("https://example.com/config"), nullcontext()),
(URL("homeassistant://config"), nullcontext()),
(None, nullcontext()),
("http://", pytest.raises(ValueError)),
("https://", pytest.raises(ValueError)),
("gopher://localhost", pytest.raises(ValueError)),
("homeassistant://", pytest.raises(ValueError)),
(URL("http://"), pytest.raises(ValueError)),
(URL("https://"), pytest.raises(ValueError)),
(URL("gopher://localhost"), pytest.raises(ValueError)),
(URL("homeassistant://"), pytest.raises(ValueError)),
# Exception implements __str__
(Exception("https://example.com"), nullcontext()),
(Exception("https://"), pytest.raises(ValueError)),
(Exception(), pytest.raises(ValueError)),
],
)
async def test_device_info_configuration_url_validation(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
configuration_url: str | URL | None,
expectation: AbstractContextManager,
) -> None:
"""Test configuration URL of device info is properly validated."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
with expectation:
device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
identifiers={("something", "1234")},
name="name",
configuration_url=configuration_url,
)
update_device = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
identifiers={("something", "5678")},
name="name",
)
with expectation:
device_registry.async_update_device(
update_device.id, configuration_url=configuration_url
)
@pytest.mark.parametrize(
"field",
[
"hw_version",
"manufacturer",
"model",
"model_id",
"serial_number",
"sw_version",
],
)
@pytest.mark.parametrize(
("value", "stored_value", "expected_log"),
[
(1.0, "1.0", "passes a non-string value of type float as {field}"),
((1, 2), "(1, 2)", "passes a non-string value of type tuple as {field}"),
("hw-1", "hw-1", ""),
(None, None, ""),
],
)
async def test_device_info_string_field_validation(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
field: str,
value: Any,
stored_value: str | None,
expected_log: str,
) -> None:
"""Test string device info fields are validated and coerced."""
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
config_entry_2 = MockConfigEntry()
config_entry_2.add_to_hass(hass)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry_1.entry_id,
identifiers={("something", "1234")},
name="name",
**{field: value},
)
assert getattr(entry, field) == stored_value
update_device = device_registry.async_get_or_create(
config_entry_id=config_entry_2.entry_id,
identifiers={("something", "5678")},
name="name",
)
updated = device_registry.async_update_device(update_device.id, **{field: value})
assert updated is not None
assert getattr(updated, field) == stored_value
assert expected_log.format(field=field) in caplog.text
@pytest.mark.parametrize("load_registries", [False])
async def test_loading_invalid_configuration_url_from_storage(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test loading stored devices with an invalid URL."""
hass_storage[dr.STORAGE_KEY] = {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"data": {
"devices": [
{
"area_id": None,
"config_entries": [mock_config_entry.entry_id],
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
"config_entry_id": mock_config_entry.entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": "invalid",
"connections": [],
"created_at": "2024-01-01T00:00:00+00:00",
"disabled_by": None,
"entry_type": dr.DeviceEntryType.SERVICE,
"hw_version": None,
"id": "abcdefghijklm",
"identifiers": [["serial", "123456ABCDEF"]],
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "2024-02-01T00:00:00+00:00",
"name_by_user": None,
"name": None,
"primary_config_entry": mock_config_entry.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
assert len(registry.devices) == 1
entry = registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={("serial", "123456ABCDEF")},
)
assert entry.configuration_url == "invalid"
async def test_removing_labels(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Make sure we can clear labels."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
entry = device_registry.async_update_device(entry.id, labels={"label1", "label2"})
device_registry.async_clear_label_id("label1")
entry_cleared_label1 = device_registry.async_get_device({("bridgeid", "0123")})
device_registry.async_clear_label_id("label2")
entry_cleared_label2 = device_registry.async_get_device({("bridgeid", "0123")})
assert entry_cleared_label1
assert entry_cleared_label2
assert entry != entry_cleared_label1
assert entry != entry_cleared_label2
assert entry_cleared_label1 != entry_cleared_label2
assert entry.labels == {"label1", "label2"}
assert entry_cleared_label1.labels == {"label2"}
assert not entry_cleared_label2.labels
async def test_removing_labels_deleted_device(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Make sure we can clear labels."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
entry1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
entry1 = device_registry.async_update_device(entry1.id, labels={"label1", "label2"})
entry2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")},
identifiers={("bridgeid", "1234")},
manufacturer="manufacturer",
model="model",
)
entry2 = device_registry.async_update_device(entry2.id, labels={"label3"})
device_registry.async_remove_device(entry1.id)
device_registry.async_remove_device(entry2.id)
device_registry.async_clear_label_id("label1")
entry1_cleared_label1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
device_registry.async_remove_device(entry1.id)
device_registry.async_clear_label_id("label2")
entry1_cleared_label2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
entry2_restored = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")},
identifiers={("bridgeid", "1234")},
)
assert entry1_cleared_label1
assert entry1_cleared_label2
assert entry1 != entry1_cleared_label1
assert entry1 != entry1_cleared_label2
assert entry1_cleared_label1 != entry1_cleared_label2
assert entry1.labels == {"label1", "label2"}
assert entry1_cleared_label1.labels == {"label2"}
assert not entry1_cleared_label2.labels
assert entry2 != entry2_restored
assert entry2_restored.labels == {"label3"}
async def test_entries_for_label(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test getting device entries by label."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:00")},
identifiers={("bridgeid", "0000")},
manufacturer="manufacturer",
model="model",
)
entry_1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:23")},
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
entry_1 = device_registry.async_update_device(entry_1.id, labels={"label1"})
entry_2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:56")},
identifiers={("bridgeid", "0456")},
manufacturer="manufacturer",
model="model",
)
entry_2 = device_registry.async_update_device(entry_2.id, labels={"label2"})
entry_1_and_2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:89")},
identifiers={("bridgeid", "0789")},
manufacturer="manufacturer",
model="model",
)
entry_1_and_2 = device_registry.async_update_device(
entry_1_and_2.id, labels={"label1", "label2"}
)
entries = dr.async_entries_for_label(device_registry, "label1")
assert len(entries) == 2
assert entries == [entry_1, entry_1_and_2]
entries = dr.async_entries_for_label(device_registry, "label2")
assert len(entries) == 2
assert entries == [entry_2, entry_1_and_2]
assert not dr.async_entries_for_label(device_registry, "unknown")
assert not dr.async_entries_for_label(device_registry, "")
@pytest.mark.parametrize(
(
"translation_key",
"translations",
"placeholders",
"expected_device_name",
),
[
(None, None, None, "Device Bla"),
(
"test_device",
{
"en": {"component.test.device.test_device.name": "English device"},
},
None,
"English device",
),
(
"test_device",
{
"en": {
"component.test.device.test_device.name": (
"{placeholder} English dev"
)
},
},
{"placeholder": "special"},
"special English dev",
),
(
"test_device",
{
"en": {
"component.test.device.test_device.name": (
"English dev {placeholder}"
)
},
},
{"placeholder": "special"},
"English dev special",
),
],
)
async def test_device_name_translation_placeholders(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
translation_key: str | None,
translations: dict[str, str] | None,
placeholders: dict[str, str] | None,
expected_device_name: str | None,
) -> None:
"""Test device name when the device name translation has placeholders."""
def async_get_cached_translations(
hass: HomeAssistant,
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, Any]:
"""Return all backend translations."""
return translations[language]
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
with patch(
"homeassistant.helpers.device_registry.translation.async_get_cached_translations",
side_effect=async_get_cached_translations,
):
entry1 = 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")},
name="Device Bla",
translation_key=translation_key,
translation_placeholders=placeholders,
)
assert entry1.name == expected_device_name
@pytest.mark.parametrize(
(
"translation_key",
"translations",
"placeholders",
"release_channel",
"expectation",
"expected_error",
),
[
(
"test_device",
{
"en": {
"component.test.device.test_device.name": (
"{placeholder} English dev {2ndplaceholder}"
)
},
},
{"placeholder": "special"},
ReleaseChannel.STABLE,
nullcontext(),
(
"has translation placeholders '{'placeholder': 'special'}' which do "
"not match the name '{placeholder} English dev {2ndplaceholder}'"
),
),
(
"test_device",
{
"en": {
"component.test.device.test_device.name": (
"{placeholder} English ent {2ndplaceholder}"
)
},
},
{"placeholder": "special"},
ReleaseChannel.BETA,
pytest.raises(
HomeAssistantError, match="Missing placeholder '2ndplaceholder'"
),
"",
),
(
"test_device",
{
"en": {
"component.test.device.test_device.name": (
"{placeholder} English dev"
)
},
},
None,
ReleaseChannel.STABLE,
nullcontext(),
(
"has translation placeholders '{}' which do "
"not match the name '{placeholder} English dev'"
),
),
],
)
async def test_device_name_translation_placeholders_errors(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
translation_key: str | None,
translations: dict[str, str] | None,
placeholders: dict[str, str] | None,
release_channel: ReleaseChannel,
expectation: AbstractContextManager,
expected_error: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test device name has placeholder issuess."""
def async_get_cached_translations(
hass: HomeAssistant,
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, Any]:
"""Return all backend translations."""
return translations[language]
config_entry_1 = MockConfigEntry()
config_entry_1.add_to_hass(hass)
with (
patch(
"homeassistant.helpers.device_registry.translation.async_get_cached_translations",
side_effect=async_get_cached_translations,
),
patch(
"homeassistant.helpers.device_registry.get_release_channel",
return_value=release_channel,
),
expectation,
):
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")},
name="Device Bla",
translation_key=translation_key,
translation_placeholders=placeholders,
)
assert expected_error in caplog.text
async def test_async_get_or_create_thread_safety(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test async_get_or_create raises when called from wrong thread."""
with pytest.raises(
RuntimeError,
match=(
"Detected code that calls"
" device_registry._async_update_device"
" from a thread."
),
):
await hass.async_add_executor_job(
partial(
device_registry.async_get_or_create,
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers=set(),
manufacturer="manufacturer",
model="model",
)
)
async def test_async_remove_device_thread_safety(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test async_remove_device raises when called from wrong thread."""
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers=set(),
manufacturer="manufacturer",
model="model",
)
with pytest.raises(
RuntimeError,
match=(
"Detected code that calls"
" device_registry.async_remove_device"
" from a thread."
),
):
await hass.async_add_executor_job(
device_registry.async_remove_device, device.id
)
async def test_device_registry_connections_collision(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test connection collisions in the device registry."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "none")},
manufacturer="manufacturer",
model="model",
)
device2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "none")},
manufacturer="manufacturer",
model="model",
)
assert device1.id == device2.id
device3 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
# Attempt to merge connection for device3 with the same
# connection that already exists in device1
with pytest.raises(
HomeAssistantError, match=f"Connections.*already registered.*{device1.id}"
):
device_registry.async_update_device(
device3.id,
merge_connections={
(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"),
(dr.CONNECTION_NETWORK_MAC, "none"),
},
)
# Attempt to add new connections for device3 with the same
# connection that already exists in device1
with pytest.raises(
HomeAssistantError, match=f"Connections.*already registered.*{device1.id}"
):
device_registry.async_update_device(
device3.id,
new_connections={
(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"),
(dr.CONNECTION_NETWORK_MAC, "none"),
},
)
device3_refetched = device_registry.async_get(device3.id)
assert device3_refetched.connections == set()
assert device3_refetched.identifiers == {("bridgeid", "0123")}
device1_refetched = device_registry.async_get(device1.id)
assert device1_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")}
assert device1_refetched.identifiers == set()
device2_refetched = device_registry.async_get(device2.id)
assert device2_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")}
assert device2_refetched.identifiers == set()
assert device2_refetched.id == device1_refetched.id
assert len(device_registry.devices) == 2
# Implicitly merging a connection registered to another device of the same
# config entry raises
with pytest.raises(dr.DeviceInfoError, match="already registered"):
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("bridgeid", "0123")},
connections={
(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"),
(dr.CONNECTION_NETWORK_MAC, "none"),
},
)
assert len(device_registry.devices) == 2
device3_refetched = device_registry.async_get(device3.id)
assert device3_refetched.connections == set()
device1_refetched = device_registry.async_get(device1.id)
assert device1_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")}
async def test_device_registry_identifiers_collision(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test identifiers collisions in the device registry."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
device2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
assert device1.id == device2.id
device3 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("bridgeid", "4567")},
manufacturer="manufacturer",
model="model",
)
# Attempt to merge identifiers for device3 with the same
# connection that already exists in device1
with pytest.raises(
HomeAssistantError, match=f"Identifiers.*already registered.*{device1.id}"
):
device_registry.async_update_device(
device3.id, merge_identifiers={("bridgeid", "0123"), ("bridgeid", "8888")}
)
# Attempt to add new identifiers for device3 with the same
# connection that already exists in device1
with pytest.raises(
HomeAssistantError, match=f"Identifiers.*already registered.*{device1.id}"
):
device_registry.async_update_device(
device3.id, new_identifiers={("bridgeid", "0123"), ("bridgeid", "8888")}
)
device3_refetched = device_registry.async_get(device3.id)
assert device3_refetched.connections == set()
assert device3_refetched.identifiers == {("bridgeid", "4567")}
device1_refetched = device_registry.async_get(device1.id)
assert device1_refetched.connections == set()
assert device1_refetched.identifiers == {("bridgeid", "0123")}
device2_refetched = device_registry.async_get(device2.id)
assert device2_refetched.connections == set()
assert device2_refetched.identifiers == {("bridgeid", "0123")}
assert device2_refetched.id == device1_refetched.id
assert len(device_registry.devices) == 2
# Implicitly merging an identifier registered to another device of the same
# config entry raises
with pytest.raises(dr.DeviceInfoError, match="already registered"):
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("bridgeid", "4567"), ("bridgeid", "0123")},
)
assert len(device_registry.devices) == 2
device3_refetched = device_registry.async_get(device3.id)
assert device3_refetched.identifiers == {("bridgeid", "4567")}
device1_refetched = device_registry.async_get(device1.id)
assert device1_refetched.identifiers == {("bridgeid", "0123")}
async def test_device_registry_deleted_device_collision(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test update collisions with deleted devices in the device registry."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")},
manufacturer="manufacturer",
model="model",
)
assert len(device_registry.deleted_devices) == 0
device_registry.async_remove_device(device1.id)
assert len(device_registry.deleted_devices) == 1
device2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("bridgeid", "0123")},
manufacturer="manufacturer",
model="model",
)
assert len(device_registry.deleted_devices) == 1
device_registry.async_update_device(
device2.id,
merge_connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")},
)
assert len(device_registry.deleted_devices) == 0
async def test_update_device_no_connections_or_identifiers(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test updating a device clearing connections and identifiers."""
mock_config_entry = MockConfigEntry(domain="mqtt", title=None)
mock_config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
)
with pytest.raises(HomeAssistantError):
device_registry.async_update_device(
device.id, new_connections=set(), new_identifiers=set()
)
async def test_connections_validator() -> None:
"""Test checking connections validator."""
with pytest.raises(ValueError, match="Invalid mac address format"):
dr.DeviceEntry(
config_entry_id="mock-config-entry",
connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")},
)
async def test_suggested_area_deprecation(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
area_registry: ar.AreaRegistry,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Make sure we do not duplicate entries."""
entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
identifiers={("bridgeid", "0123")},
sw_version="sw-version",
name="name",
manufacturer="manufacturer",
model="model",
suggested_area="Game Room",
)
game_room_area = area_registry.async_get_area_by_name("Game Room")
assert game_room_area is not None
assert len(area_registry.areas) == 1
assert len(device_registry.devices) == 1
assert entry.area_id == game_room_area.id
assert entry.suggested_area == "Game Room"
assert (
"The deprecated function suggested_area was called. It will be removed in "
"HA Core 2026.9. Use code which ignores suggested_area instead"
) in caplog.text
device_registry.async_update_device(entry.id, suggested_area="TV Room")
assert (
"Detected code that passes a suggested_area to device_registry.async_update "
"device. This will stop working in Home Assistant 2026.9.0, please report "
"this issue"
) in caplog.text
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": [],
},
}
async def test_single_config_entry_and_compat_properties(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A device has a single config entry; the deprecated shims reflect it."""
entry = MockConfigEntry(domain="domain_a")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("domain_a", "1")}
)
assert device.config_entry_id == entry.entry_id
assert device.config_subentry_id is None
assert device.config_entries == {entry.entry_id}
assert device.config_entries_subentries == {entry.entry_id: {None}}
assert device.primary_config_entry == entry.entry_id
async def test_identifiers_unique_per_config_entry(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""The same identifier under two config entries yields two devices."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
device_a = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("shared", "1")}
)
device_b = device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id, identifiers={("shared", "1")}
)
assert device_a.id != device_b.id
# Scoped lookup returns the owning device
assert (
_get_device_for_config_entry(
device_registry, entry_a.entry_id, identifiers={("shared", "1")}
).id
== device_a.id
)
assert (
_get_device_for_config_entry(
device_registry, entry_b.entry_id, identifiers={("shared", "1")}
).id
== device_b.id
)
async def test_collision_only_within_same_config_entry(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A collision is raised only for two devices of the same config entry."""
entry = MockConfigEntry(domain="domain_a")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("domain_a", "1")}
)
other = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("domain_a", "2")}
)
with pytest.raises(dr.DeviceIdentifierCollisionError):
device_registry.async_update_device(
other.id, merge_identifiers={("domain_a", "1")}
)
assert device_registry.async_get(device.id) is not None
async def test_remove_shadowed_collision_keeps_index_consistent(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Removing a device that shadows a same-entry collision keeps the index consistent.
allow_collisions lets a device absorb an identifier another device of the same config
entry holds, shadowing it in the index. When a second config entry also shares that
identifier, removing the shadowed device then the indexed one must not delete the wrong
slot or raise KeyError on the mapping the second entry keeps.
"""
entry_a = MockConfigEntry(domain="test")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="test")
entry_b.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("test", "1")}
)
shadowed = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("test", "2")}
)
# The second config entry keeps its own slot for the shared identifier
other_entry_device = device_registry.async_get_or_create(
config_entry_id=entry_b.entry_id, identifiers={("test", "2")}
)
# allow_collisions lets `device` absorb the shadowed device's identifier
device_registry._async_update_device(
device.id, merge_identifiers={("test", "2")}, allow_collisions=True
)
assert device_registry.async_get(device.id).identifiers == {
("test", "1"),
("test", "2"),
}
assert shadowed.id in device_registry.devices
# Remove the shadowed device, then the indexed one - neither must raise
device_registry.async_remove_device(shadowed.id)
device_registry.async_remove_device(device.id)
# The second config entry's device is still reachable by the shared identifier
assert (
device_registry.async_get_device(identifiers={("test", "2")})
is other_entry_device
)
async def test_remove_device_promotes_shadowed_duplicate(
hass: HomeAssistant,
) -> None:
"""Removing the indexed holder of a duplicate key promotes the shadowed one."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_registry = mock_device_registry(
hass,
{
"shadowed": dr.DeviceEntry(
id="shadowed",
config_entry_id=entry.entry_id,
identifiers={("test", "shared")},
),
"winner": dr.DeviceEntry(
id="winner",
config_entry_id=entry.entry_id,
identifiers={("test", "shared")},
),
},
)
assert (
device_registry.async_get_device(identifiers={("test", "shared")}).id
== "winner"
)
device_registry.async_remove_device("winner")
assert (
device_registry.async_get_device(identifiers={("test", "shared")}).id
== "shadowed"
)
async def test_update_device_reindexes_shadowed_duplicate(
hass: HomeAssistant,
) -> None:
"""Updating a shadowed duplicate re-indexes it, so it wins the lookups."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_registry = mock_device_registry(
hass,
{
"shadowed": dr.DeviceEntry(
id="shadowed",
config_entry_id=entry.entry_id,
identifiers={("test", "shared")},
),
"winner": dr.DeviceEntry(
id="winner",
config_entry_id=entry.entry_id,
identifiers={("test", "shared")},
),
},
)
assert (
device_registry.async_get_device(identifiers={("test", "shared")}).id
== "winner"
)
# The updated shadowed duplicate takes over the lookup slot (last indexed wins)
device_registry.async_update_device("shadowed", name_by_user="renamed")
assert (
device_registry.async_get_device(identifiers={("test", "shared")}).id
== "shadowed"
)
# An updated slot holder stays in the slot
device_registry.async_update_device("shadowed", name_by_user="renamed again")
assert (
device_registry.async_get_device(identifiers={("test", "shared")}).id
== "shadowed"
)
assert len(device_registry.devices) == 2
@pytest.mark.parametrize(
("device_extra", "stale_extra", "register_extra"),
[
pytest.param(
{"identifiers": {("test", "device"), ("test", "shared")}},
{"identifiers": {("test", "stale"), ("test", "shared")}},
{"identifiers": {("test", "device")}},
id="identifiers",
),
pytest.param(
{
"identifiers": {("test", "device")},
"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
},
{
"identifiers": {("test", "stale")},
"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
},
{
"identifiers": {("test", "device")},
"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
},
id="connections",
),
],
)
async def test_legacy_duplicate_keys_stripped_on_registration(
hass: HomeAssistant,
hass_storage: dict[str, Any],
device_extra: dict[str, set[tuple[str, str]]],
stale_extra: dict[str, set[tuple[str, str]]],
register_extra: dict[str, set[tuple[str, str]]],
) -> None:
"""Registering a device strips its keys from stale same-entry duplicates.
The stale duplicate keeps its other keys; another config entry is not affected.
"""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
other_entry = MockConfigEntry(domain="test")
other_entry.add_to_hass(hass)
shared_keys = device_extra.keys() & stale_extra.keys()
device_registry = mock_device_registry(
hass,
{
"device": dr.DeviceEntry(
id="device", config_entry_id=entry.entry_id, **device_extra
),
"stale": dr.DeviceEntry(
id="stale", config_entry_id=entry.entry_id, **stale_extra
),
"other": dr.DeviceEntry(
id="other",
config_entry_id=other_entry.entry_id,
**{key: device_extra[key] & stale_extra[key] for key in shared_keys},
),
},
)
registered = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, **register_extra
)
assert registered.id == "device"
assert registered.identifiers == device_extra["identifiers"]
assert registered.connections == device_extra.get("connections", set())
# The stale duplicate lost the shared keys but keeps its own
stale = device_registry.async_get("stale")
assert stale.identifiers == {("test", "stale")}
assert stale.connections == set()
# The registered device is reachable by the previously shared keys
assert (
_get_device_for_config_entry(
device_registry,
entry.entry_id,
identifiers=device_extra["identifiers"],
connections=device_extra.get("connections"),
).id
== "device"
)
# A device of another config entry sharing a key is not affected
other = device_registry.async_get("other")
assert other.identifiers == device_extra["identifiers"] & stale_extra["identifiers"]
assert other.connections == device_extra.get(
"connections", set()
) & stale_extra.get("connections", set())
# Registering again is a no-op
registered = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, **register_extra
)
assert registered.id == "device"
assert len(device_registry.devices) == 3
# The stripped keys are persisted
await flush_store(device_registry._store)
stored_stale = next(
device
for device in hass_storage[dr.STORAGE_KEY]["data"]["devices"]
if device["id"] == "stale"
)
assert {tuple(identifier) for identifier in stored_stale["identifiers"]} == {
("test", "stale")
}
assert stored_stale["connections"] == []
async def test_legacy_duplicate_fully_stripped_device_removed(
hass: HomeAssistant,
) -> None:
"""A stale duplicate left without any keys is removed, also from deleted devices."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_registry = mock_device_registry(
hass,
{
"device": dr.DeviceEntry(
id="device",
config_entry_id=entry.entry_id,
identifiers={("test", "device"), ("test", "shared")},
),
"stale": dr.DeviceEntry(
id="stale",
config_entry_id=entry.entry_id,
identifiers={("test", "shared")},
),
},
)
registered = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "device")}
)
assert registered.id == "device"
assert registered.identifiers == {("test", "device"), ("test", "shared")}
assert device_registry.async_get("stale") is None
assert "stale" not in device_registry.deleted_devices
assert (
device_registry.async_get_device(identifiers={("test", "shared")}).id
== "device"
)
@pytest.mark.parametrize("load_registries", [False])
async def test_loading_from_storage_with_legacy_duplicates(
hass: HomeAssistant,
hass_storage: dict[str, Any],
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Duplicates from an old store are tracked on load and reconciled on registration."""
entry_id = mock_config_entry.entry_id
created_at = "2024-01-01T00:00:00+00:00"
def _stored_device(
device_id: str, identifiers: list[list[str]], connections: list[list[str]]
) -> dict[str, Any]:
return {
"area_id": None,
"config_entries": [entry_id],
"config_entries_subentries": {entry_id: [None]},
"config_entry_id": entry_id,
"config_subentry_id": None,
"composite_device_id": None,
"composite_primary_config_entry": None,
"split_at": None,
"has_composite_identifiers": False,
"configuration_url": None,
"connections": connections,
"created_at": created_at,
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": device_id,
"identifiers": identifiers,
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": created_at,
"name_by_user": None,
"name": None,
"primary_config_entry": entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
}
hass_storage[dr.STORAGE_KEY] = {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
"data": {
"devices": [
_stored_device(
"old",
[["test", "shared"]],
[[dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"]],
),
_stored_device(
"new",
[["test", "shared"], ["test", "own"]],
[[dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"]],
),
],
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
registry = dr.async_get(hass)
assert (
"Loaded 2 identifiers/connections registered to multiple devices of one "
"config entry" in caplog.text
)
# The last stored duplicate wins the lookups
assert registry.async_get_device(identifiers={("test", "shared")}).id == "new"
assert (
registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}
).id
== "new"
)
assert len(registry.devices) == 2
# Registration reconciles: the fully shadowed duplicate is removed
registered = registry.async_get_or_create(
config_entry_id=entry_id,
identifiers={("test", "shared")},
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
)
assert registered.id == "new"
assert registry.async_get("old") is None
assert "old" not in registry.deleted_devices
# The reconciled state is persisted
await flush_store(registry._store)
stored_devices = hass_storage[dr.STORAGE_KEY]["data"]["devices"]
assert [device["id"] for device in stored_devices] == ["new"]
assert hass_storage[dr.STORAGE_KEY]["data"]["deleted_devices"] == []
async def test_registration_purges_same_entry_deleted_duplicates(
hass: HomeAssistant,
hass_storage: dict[str, Any],
) -> None:
"""Registering a device purges same-entry deleted devices holding its keys.
Whole records are purged, shadowed included; another config entry is not affected.
"""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
other_entry = MockConfigEntry(domain="test")
other_entry.add_to_hass(hass)
device_registry = mock_device_registry(
hass,
{
"device": dr.DeviceEntry(
id="device",
config_entry_id=entry.entry_id,
identifiers={("test", "device"), ("test", "shared")},
),
},
)
device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device(
"deleted_shadowed", entry.entry_id, {("test", "shared"), ("test", "other")}
)
device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device(
"deleted_winner", entry.entry_id, {("test", "shared")}
)
device_registry.deleted_devices["deleted_other_entry"] = _mock_deleted_device(
"deleted_other_entry", other_entry.entry_id, {("test", "shared")}
)
registered = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "device")}
)
assert registered.id == "device"
assert "deleted_winner" not in device_registry.deleted_devices
assert "deleted_shadowed" not in device_registry.deleted_devices
assert "deleted_other_entry" in device_registry.deleted_devices
# The purge is persisted
await flush_store(device_registry._store)
assert [
device["id"]
for device in hass_storage[dr.STORAGE_KEY]["data"]["deleted_devices"]
] == ["deleted_other_entry"]
async def test_restore_purges_same_entry_deleted_duplicate(
hass: HomeAssistant,
) -> None:
"""Restoring a deleted device purges its same-entry deleted duplicates."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_registry = mock_device_registry(hass)
device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device(
"deleted_shadowed", entry.entry_id, {("test", "shared")}
)
device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device(
"deleted_winner", entry.entry_id, {("test", "shared")}
)
restored = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "shared")}
)
assert restored.id == "deleted_winner"
assert "deleted_winner" not in device_registry.deleted_devices
assert "deleted_shadowed" not in device_registry.deleted_devices
assert len(device_registry.devices) == 1
async def test_add_identifier_prunes_shadowed_deleted_duplicates(
hass: HomeAssistant,
) -> None:
"""Gaining an identifier removes all matching same-entry deleted devices.
Whole records are removed, shadowed duplicates included.
"""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_registry = mock_device_registry(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "device")}
)
device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device(
"deleted_shadowed", entry.entry_id, {("test", "shared")}
)
device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device(
"deleted_winner", entry.entry_id, {("test", "shared"), ("test", "other")}
)
device_registry.async_update_device(
device.id, merge_identifiers={("test", "shared")}
)
assert "deleted_winner" not in device_registry.deleted_devices
assert "deleted_shadowed" not in device_registry.deleted_devices
async def test_via_device_id_to_removed_stale_duplicate_raises(
hass: HomeAssistant,
) -> None:
"""A via_device_id to a stale duplicate removed by reconciliation raises."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_registry = mock_device_registry(
hass,
{
"device": dr.DeviceEntry(
id="device",
config_entry_id=entry.entry_id,
identifiers={("test", "device"), ("test", "shared")},
),
"stale": dr.DeviceEntry(
id="stale",
config_entry_id=entry.entry_id,
identifiers={("test", "shared")},
),
},
)
with pytest.raises(dr.DeviceInfoError, match="not a registered device id"):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "device")},
via_device_id="stale",
)
assert device_registry.async_get("stale") is None
async def test_key_collision_reconciled_after_config_entry_reload(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""A key moved between devices raises while loaded and reconciles after reload."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
mock_integration(
hass,
MockModule(
"test",
async_setup_entry=AsyncMock(return_value=True),
async_unload_entry=AsyncMock(return_value=True),
),
)
mock_platform(hass, "test.config_flow", None)
class MockFlow(config_entries.ConfigFlow):
"""Test flow."""
with mock_config_flow("test", MockFlow):
assert await hass.config_entries.async_setup(entry.entry_id)
connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")
device_a = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "device_a")},
connections={connection},
)
device_b = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "device_b")},
)
# Both devices are registered this setup session, so moving the key raises
with pytest.raises(dr.DeviceInfoError, match="already registered"):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "device_b")},
connections={connection},
)
device_a_refetched = device_registry.async_get(device_a.id)
assert device_a_refetched is not None
assert device_a_refetched.connections == {connection}
assert await hass.config_entries.async_unload(entry.entry_id)
assert await hass.config_entries.async_setup(entry.entry_id)
# In the new setup session the registering device is authoritative
device_b_refetched = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "device_b")},
connections={connection},
)
assert device_b_refetched.id == device_b.id
assert device_b_refetched.connections == {connection}
device_a_refetched = device_registry.async_get(device_a.id)
assert device_a_refetched is not None
assert device_a_refetched.connections == set()
async def test_key_collision_reconciled_after_setup_retry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""A key moved between devices reconciles on the retry after a failed setup."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")
async def first_attempt(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
) -> bool:
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "device_a")},
connections={connection},
)
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "device_b")},
)
raise ConfigEntryNotReady
async def second_attempt(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
) -> bool:
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "device_b")},
connections={connection},
)
return True
attempts = [first_attempt, second_attempt]
async def async_setup_entry(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
) -> bool:
return await attempts.pop(0)(hass, config_entry)
mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry))
mock_platform(hass, "test.config_flow", None)
class MockFlow(config_entries.ConfigFlow):
"""Test flow."""
with mock_config_flow("test", MockFlow):
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY
async_fire_time_changed(hass, utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
# The retry reconciled the key away from the failed attempt's device
assert entry.state is config_entries.ConfigEntryState.LOADED
device_b = device_registry.async_get_device(identifiers={("test", "device_b")})
assert device_b is not None
assert device_b.connections == {connection}
device_a = device_registry.async_get_device(identifiers={("test", "device_a")})
assert device_a is not None
assert device_a.connections == set()
@pytest.mark.parametrize(
("identity", "merge_kwarg", "merge_extra", "error"),
[
pytest.param(
{"identifiers": {("test", "shared")}},
"merge_identifiers",
{("test", "extra")},
dr.DeviceIdentifierCollisionError,
id="identifiers",
),
pytest.param(
{"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}},
"merge_connections",
{(dr.CONNECTION_NETWORK_MAC, "ab:cd:ef:12:34:56")},
dr.DeviceConnectionCollisionError,
id="connections",
),
],
)
async def test_move_with_merge_validates_retained_identity(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
identity: dict[str, set[tuple[str, str]]],
merge_kwarg: str,
merge_extra: set[tuple[str, str]],
error: type[Exception],
) -> None:
"""A move that also merges must validate the retained identity against the target.
The merged additions are validated, but the retained old identity must be too, or the
move silently overwrites the target entry's index slot for a device already there.
"""
entry_a = MockConfigEntry(domain="test")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="test")
entry_b.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, **identity
)
# entry_b already owns a device with the same identity
device_registry.async_get_or_create(config_entry_id=entry_b.entry_id, **identity)
# Moving device to entry_b retains its identity, which collides with entry_b's
# existing device, so the move must raise rather than silently shadow it.
with pytest.raises(error):
device_registry.async_update_device(
device.id,
new_config_entry_id=entry_b.entry_id,
**{merge_kwarg: merge_extra},
)
async def test_move_two_calls_add_then_remove(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test add_config_entry_id records a pending move; the later remove performs it."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")}
)
# add alone does nothing yet
device_registry.async_update_device(device.id, add_config_entry_id=entry_b.entry_id)
assert device_registry.async_get(device.id).config_entry_id == entry_a.entry_id
# remove of the current owner performs the pending move
device_registry.async_update_device(
device.id, remove_config_entry_id=entry_a.entry_id
)
moved = device_registry.async_get(device.id)
assert moved is not None
assert moved.config_entry_id == entry_b.entry_id
async def test_move_new_config_entry_id(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test new_config_entry_id moves the device immediately."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")}
)
device_registry.async_update_device(device.id, new_config_entry_id=entry_b.entry_id)
assert device_registry.async_get(device.id).config_entry_id == entry_b.entry_id
async def test_move_new_and_add_raises(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test mixing new_config_entry_id with add/remove raises."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")}
)
with pytest.raises(HomeAssistantError, match="Can't combine"):
device_registry.async_update_device(
device.id,
new_config_entry_id=entry_b.entry_id,
add_config_entry_id=entry_b.entry_id,
)
async def test_async_get_or_create_unknown_config_entry(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test async_get_or_create raises for an unknown config entry."""
with pytest.raises(
HomeAssistantError,
match="Can't link device to unknown config entry unknown-config-entry",
):
device_registry.async_get_or_create(
config_entry_id="unknown-config-entry", identifiers={("bridgeid", "0123")}
)
@pytest.mark.parametrize(
("make_update_kwargs", "error_match"),
[
pytest.param(
lambda entry: {"add_config_entry_id": "unknown-config-entry"},
"Can't link device to unknown config entry unknown-config-entry",
id="add-unknown-config-entry",
),
pytest.param(
lambda entry: {"add_config_subentry_id": "mock-subentry-id-2"},
"Can't add config subentry without specifying config entry",
id="add-subentry-without-config-entry",
),
pytest.param(
lambda entry: {
"add_config_entry_id": entry.entry_id,
"add_config_subentry_id": "unknown-subentry",
},
"has no subentry unknown-subentry",
id="add-unknown-subentry",
),
pytest.param(
lambda entry: {"remove_config_subentry_id": "mock-subentry-id-1"},
"Can't remove config subentry without specifying config entry",
id="remove-subentry-without-config-entry",
),
pytest.param(
lambda entry: {"new_config_entry_id": "unknown-config-entry"},
"Can't move device to unknown config entry unknown-config-entry",
id="new-unknown-config-entry",
),
pytest.param(
lambda entry: {"new_config_subentry_id": "unknown-subentry"},
"has no subentry unknown-subentry",
id="new-unknown-subentry",
),
pytest.param(
lambda entry: {
"new_config_entry_id": entry.entry_id,
"add_config_entry_id": entry.entry_id,
},
"Can't combine new_config_entry_id or new_config_subentry_id",
id="combine-new-and-add",
),
],
)
async def test_update_device_config_entry_grammar_errors(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
make_update_kwargs: Callable[[MockConfigEntry], dict[str, Any]],
error_match: str,
) -> None:
"""The config-entry/subentry mutation grammar validates its arguments."""
entry = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
config_subentry_id="mock-subentry-id-1",
identifiers={("bridgeid", "0123")},
)
with pytest.raises(HomeAssistantError, match=error_match):
device_registry.async_update_device(device.id, **make_update_kwargs(entry))
async def test_move_device_to_config_subentry(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A device can be moved to another subentry of its config entry.
Immediately via new_config_subentry_id, or deferred via a pending move
(add_config_entry_id + add_config_subentry_id, completed by removing the current
owner). There is no subentry-only deferred move - add_config_subentry_id and
remove_config_subentry_id without a config entry raise (see
test_update_device_config_entry_grammar_errors).
"""
entry = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
config_subentry_id="mock-subentry-id-1",
identifiers={("bridgeid", "0123")},
)
# new_config_subentry_id moves the device immediately
moved = device_registry.async_update_device(
device.id, new_config_subentry_id="mock-subentry-id-2"
)
assert moved.config_entry_id == entry.entry_id
assert moved.config_subentry_id == "mock-subentry-id-2"
# Deferred move: adding the (same) config entry with the target subentry records a
# pending move; it does not move the device on its own
device_registry.async_update_device(
device.id,
add_config_entry_id=entry.entry_id,
add_config_subentry_id="mock-subentry-id-1",
)
assert (
device_registry.async_get(device.id).config_subentry_id == "mock-subentry-id-2"
)
# Removing the current owner performs the pending move to the target subentry
moved_back = device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
assert moved_back is not None
assert moved_back.config_subentry_id == "mock-subentry-id-1"
async def test_move_device_to_config_entry_and_subentry(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A deferred move can target another config entry and one of its subentries."""
entry_a = MockConfigEntry()
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-b",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
entry_b.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("bridgeid", "0123")}
)
# The pending move carries the (config entry, subentry) pair
device_registry.async_update_device(
device.id,
add_config_entry_id=entry_b.entry_id,
add_config_subentry_id="mock-subentry-id-b",
)
moved = device_registry.async_update_device(
device.id, remove_config_entry_id=entry_a.entry_id
)
assert moved is not None
assert moved.config_entry_id == entry_b.entry_id
assert moved.config_subentry_id == "mock-subentry-id-b"
async def test_pending_move_overwritten_by_later_add(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A later add_config_entry_id / add_config_subentry_id overwrites the pending move."""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-2",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
entry_2.add_to_hass(hass)
entry_3 = MockConfigEntry()
entry_3.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")}
)
# Each add records a pending move, overwriting the previous one: first a subentry ...
device_registry.async_update_device(
device.id,
add_config_entry_id=entry_2.entry_id,
add_config_subentry_id="mock-subentry-id-1",
)
# ... a later add to the same entry overwrites just the subentry ...
device_registry.async_update_device(
device.id,
add_config_entry_id=entry_2.entry_id,
add_config_subentry_id="mock-subentry-id-2",
)
# ... a later add to a different entry overwrites the entry (subentry resets to None)
device_registry.async_update_device(device.id, add_config_entry_id=entry_3.entry_id)
# None of the adds moved the device
assert device_registry.async_get(device.id).config_entry_id == entry_1.entry_id
# Removing the owner performs the last recorded pending move
moved = device_registry.async_update_device(
device.id, remove_config_entry_id=entry_1.entry_id
)
assert moved is not None
assert moved.config_entry_id == entry_3.entry_id
assert moved.config_subentry_id is None
async def test_new_config_entry_id_clears_pending_move(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An immediate new_config_entry_id move clears an earlier pending move.
Otherwise removing the new owner would perform the stale deferred move instead of
deleting the device, which has no other config entry.
"""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
entry_3 = MockConfigEntry()
entry_3.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")}
)
# Record a pending move to entry_2, then immediately move the device to entry_3
device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id)
device_registry.async_update_device(device.id, new_config_entry_id=entry_3.entry_id)
assert device_registry.async_get(device.id)._pending_move is None
# Removing the new owner deletes the device rather than performing the stale move
assert (
device_registry.async_update_device(
device.id, remove_config_entry_id=entry_3.entry_id
)
is None
)
assert device_registry.async_get(device.id) is None
async def test_pending_move_canceled_by_cross_domain_removal(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A removal from a different integration than the one that armed the move cancels it.
Otherwise an incidental add_config_entry_id (e.g. device_tracker attaching a shared
MAC) would hijack the owning integration's later cleanup and move the device instead
of deleting it.
"""
entry_owner = MockConfigEntry(domain="owner")
entry_owner.add_to_hass(hass)
entry_target = MockConfigEntry(domain="attacher")
entry_target.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_owner.entry_id, identifiers={("test", "1")}
)
# The "attacher" integration arms a deferred move to its own entry
with patch.object(dr, "_current_integration_domain", return_value="attacher"):
device_registry.async_update_device(
device.id, add_config_entry_id=entry_target.entry_id
)
assert (
device_registry.async_get(device.id)._pending_move.origin_domain == "attacher"
)
# The owning integration later removes its entry - a different domain, so the stale
# move is canceled and the device is deleted rather than transferred.
with patch.object(dr, "_current_integration_domain", return_value="owner"):
result = device_registry.async_update_device(
device.id, remove_config_entry_id=entry_owner.entry_id
)
assert result is None
assert device_registry.async_get(device.id) is None
async def test_pending_move_completed_by_same_domain_removal(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""A removal from the same integration that armed the move completes it."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "1")}
)
with patch.object(dr, "_current_integration_domain", return_value="mover"):
device_registry.async_update_device(
device.id, add_config_entry_id=entry_2.entry_id
)
moved = device_registry.async_update_device(
device.id, remove_config_entry_id=entry_1.entry_id
)
assert moved is not None
assert moved.config_entry_id == entry_2.entry_id
assert device_registry.async_get(device.id) is moved
async def test_composite_move_clears_sibling_pending_moves(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Completing one split's move clears the pending move on its composite siblings.
Arming add_config_entry_id on a composite fans out to every split; once one split
moves to the target, the others must not also move there and collide.
"""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
entry_target = MockConfigEntry(domain="test")
entry_target.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "shared")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "shared")}
)
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
)
# Arm a deferred move on the composite id: fans out to both splits
with patch.object(dr, "_current_integration_domain", return_value="test"):
device_registry.async_update_device(
old_id, add_config_entry_id=entry_target.entry_id
)
assert device_registry.async_get(device_1.id)._pending_move is not None
assert device_registry.async_get(device_2.id)._pending_move is not None
# Complete the move on split 1; split 2's pending move must be cleared
with patch.object(dr, "_current_integration_domain", return_value="test"):
device_registry.async_update_device(
device_1.id, remove_config_entry_id=entry_1.entry_id
)
assert (
device_registry.async_get(device_1.id).config_entry_id == entry_target.entry_id
)
assert device_registry.async_get(device_2.id)._pending_move is None
# Split 2's own removal now deletes it instead of colliding on the shared identifier
with patch.object(dr, "_current_integration_domain", return_value="test"):
assert (
device_registry.async_update_device(
device_2.id, remove_config_entry_id=entry_2.entry_id
)
is None
)
assert device_registry.async_get(device_2.id) is None
async def test_composite_move_unknown_via_device_id_keeps_sibling_moves(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""An unknown via_device_id raises before a move clears sibling pending moves."""
entry_1 = MockConfigEntry(domain="test")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="test")
entry_2.add_to_hass(hass)
entry_target = MockConfigEntry(domain="test")
entry_target.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("test", "shared")}
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id, identifiers={("test", "shared")}
)
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
)
# Arm a deferred move on the composite id: fans out to both splits
with patch.object(dr, "_current_integration_domain", return_value="test"):
device_registry.async_update_device(
old_id, add_config_entry_id=entry_target.entry_id
)
# The failed call must not move the device or clear the sibling's pending move
with (
patch.object(dr, "_current_integration_domain", return_value="test"),
pytest.raises(HomeAssistantError, match="unknown via device unknown-device-id"),
):
device_registry.async_update_device(
device_1.id,
remove_config_entry_id=entry_1.entry_id,
via_device_id="unknown-device-id",
)
assert device_registry.async_get(device_1.id).config_entry_id == entry_1.entry_id
assert device_registry.async_get(device_1.id)._pending_move is not None
assert device_registry.async_get(device_2.id)._pending_move is not None
# The armed moves are intact and can still complete
with patch.object(dr, "_current_integration_domain", return_value="test"):
device_registry.async_update_device(
device_1.id, remove_config_entry_id=entry_1.entry_id
)
assert (
device_registry.async_get(device_1.id).config_entry_id == entry_target.entry_id
)
assert device_registry.async_get(device_2.id)._pending_move is None
async def test_add_and_remove_config_entry_in_one_call(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""add_config_entry_id and remove_config_entry_id of the owner move in a single call."""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(
subentries_data=[
config_entries.ConfigSubentryData(
data={},
subentry_id="mock-subentry-id-1",
subentry_type="test",
title="Mock title",
unique_id="test",
),
]
)
entry_2.add_to_hass(hass)
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")}
)
# Adding the new entry/subentry and removing the current owner in one call moves at once
moved = device_registry.async_update_device(
device.id,
add_config_entry_id=entry_2.entry_id,
add_config_subentry_id="mock-subentry-id-1",
remove_config_entry_id=entry_1.entry_id,
)
assert moved is not None
assert moved.config_entry_id == entry_2.entry_id
assert moved.config_subentry_id == "mock-subentry-id-1"
await hass.async_block_till_done()
assert len(update_events) == 2
assert update_events[1].data == {
"action": "update",
"device_id": device.id,
"changes": {
"config_entry_id": entry_1.entry_id,
"config_subentry_id": None,
},
}
async def test_remove_non_owner_config_entry_keeps_device(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""remove_config_entry_id of a non-owning entry does not perform the pending move."""
entry_1 = MockConfigEntry()
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry()
entry_2.add_to_hass(hass)
entry_3 = MockConfigEntry()
entry_3.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")}
)
# Add a pending move to entry_2, but remove a config entry the device does not own
result = device_registry.async_update_device(
device.id,
add_config_entry_id=entry_2.entry_id,
remove_config_entry_id=entry_3.entry_id,
)
# The device is neither moved nor removed: only removing the owner performs the move
assert result is not None
assert result.config_entry_id == entry_1.entry_id
# The pending move to entry_2 was still recorded; removing the owner now performs it
moved = device_registry.async_update_device(
device.id, remove_config_entry_id=entry_1.entry_id
)
assert moved is not None
assert moved.config_entry_id == entry_2.entry_id
@pytest.mark.parametrize("load_registries", [False])
async def test_reregistration_replaces_composite_identifiers(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""First re-registration replaces the copied identifiers with the provided ones."""
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)
dr.async_setup(hass)
await dr.async_load(hass)
device_registry = dr.async_get(hass)
split_a = _get_device_for_config_entry(
device_registry, entry_a.entry_id, identifiers={("domain_a", "1")}
)
assert split_a.has_composite_identifiers is True
reregistered = device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")}
)
assert reregistered.id == split_a.id
assert reregistered.identifiers == {("domain_a", "1")} # domain_b copy pruned
# assert the copied composite connection is cleared
assert reregistered.connections == set()
assert reregistered.has_composite_identifiers is False
@pytest.mark.parametrize("load_registries", [False])
async def test_async_get_returns_restored_composite(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test async_get on the legacy id returns a merged, on-demand composite."""
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)
dr.async_setup(hass)
await dr.async_load(hass)
device_registry = dr.async_get(hass)
composite = device_registry.async_get(COMPOSITE_ID)
assert composite is not None
assert composite.id == COMPOSITE_ID
assert composite.config_entries == {entry_a.entry_id, entry_b.entry_id}
assert composite.config_entries_subentries == {
entry_a.entry_id: {None},
entry_b.entry_id: {None},
}
assert composite.identifiers == {("domain_a", "1"), ("domain_b", "1")}
assert composite.serial_number == "SERIAL"
# Invisible to membership, enumeration and identifier search
assert COMPOSITE_ID not in device_registry.devices
assert COMPOSITE_ID not in {d.id for d in device_registry.devices.values()}
assert (
device_registry.async_get_device(identifiers={("domain_a", "1")}).id
!= COMPOSITE_ID
)
@pytest.mark.parametrize("load_registries", [False])
async def test_restored_composite_preserves_primary_config_entry(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""The restored composite reports the pre-migration composite's former primary.
The composite's primary_config_entry is recorded on each split device
(composite_primary_config_entry) so the restored composite can report it, even when
it is not the first split.
"""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
# The composite's primary is entry_b, which is not its first config entry
storage = _composite_device_storage(entry_a, entry_b)
storage["data"]["devices"][0]["primary_config_entry"] = entry_b.entry_id
hass_storage[dr.STORAGE_KEY] = storage
dr.async_setup(hass)
await dr.async_load(hass)
device_registry = dr.async_get(hass)
composite = device_registry.async_get(COMPOSITE_ID)
splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID)
# The former primary (entry_b) is preserved, even though it is not the first split
assert composite.primary_config_entry == entry_b.entry_id
assert composite.primary_config_entry != splits[0].config_entry_id
# It is a valid member of the merged config entries
assert composite.primary_config_entry in composite.config_entries
@pytest.mark.parametrize("load_registries", [False])
async def test_get_composite_splits(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test getting the mapping of composite device ids to their split devices."""
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)
dr.async_setup(hass)
await dr.async_load(hass)
device_registry = dr.async_get(hass)
split_a = _get_device_for_config_entry(
device_registry, entry_a.entry_id, identifiers={("domain_a", "1")}
)
split_b = _get_device_for_config_entry(
device_registry, entry_b.entry_id, identifiers={("domain_b", "1")}
)
splits = device_registry.devices.get_composite_splits()
assert set(splits) == {COMPOSITE_ID}
assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id}
# A device which is not split from a composite is not included
device_registry.async_get_or_create(
config_entry_id=entry_a.entry_id, identifiers={("domain_a", "2")}
)
splits = device_registry.devices.get_composite_splits()
assert set(splits) == {COMPOSITE_ID}
assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id}
# A removed split is dropped from the mapping
device_registry.async_remove_device(split_a.id)
splits = device_registry.devices.get_composite_splits()
assert {device.id for device in splits[COMPOSITE_ID]} == {split_b.id}
# Removing the last split drops the composite id from the mapping
device_registry.async_remove_device(split_b.id)
assert device_registry.devices.get_composite_splits() == {}
@pytest.mark.parametrize("load_registries", [False])
async def test_clear_config_entry_clears_composite_primary_config_entry(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Clearing the composite's former primary config entry clears the dangling ref."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
# The composite's former primary is entry_a
hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b)
dr.async_setup(hass)
await dr.async_load(hass)
device_registry = dr.async_get(hass)
split_b = _get_device_for_config_entry(
device_registry, entry_b.entry_id, identifiers={("domain_a", "1")}
)
assert split_b.composite_primary_config_entry == entry_a.entry_id
# Clearing entry_a removes its split and clears the reference on entry_b's split
device_registry.async_clear_config_entry(entry_a.entry_id)
assert (
_get_device_for_config_entry(
device_registry, entry_a.entry_id, identifiers={("domain_a", "1")}
)
is None
)
split_b = _get_device_for_config_entry(
device_registry, entry_b.entry_id, identifiers={("domain_a", "1")}
)
assert split_b is not None
assert split_b.composite_primary_config_entry is None
# The restored composite still works, falling back to the remaining split
composite = device_registry.async_get(COMPOSITE_ID)
assert composite is not None
assert composite.primary_config_entry == entry_b.entry_id
@pytest.mark.parametrize("load_registries", [False])
async def test_clear_non_primary_config_entry_keeps_composite_primary_config_entry(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Clearing a non-primary config entry leaves composite_primary_config_entry intact."""
entry_a = MockConfigEntry(domain="domain_a")
entry_a.add_to_hass(hass)
entry_b = MockConfigEntry(domain="domain_b")
entry_b.add_to_hass(hass)
# The composite's former primary is entry_a
hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b)
dr.async_setup(hass)
await dr.async_load(hass)
device_registry = dr.async_get(hass)
# Clearing entry_b (not the former primary) removes its split but keeps the reference
device_registry.async_clear_config_entry(entry_b.entry_id)
split_a = _get_device_for_config_entry(
device_registry, entry_a.entry_id, identifiers={("domain_a", "1")}
)
assert split_a is not None
assert split_a.composite_primary_config_entry == entry_a.entry_id
async def test_dict_repr_dual_writes_deprecated_keys(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test dict_repr exposes both the new and the deprecated compatibility keys."""
entry = MockConfigEntry(domain="domain_a")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("domain_a", "1")}
)
repr_ = device.dict_repr
assert repr_["config_entry_id"] == entry.entry_id
assert repr_["config_subentry_id"] is None
assert repr_["config_entries"] == [entry.entry_id]
assert repr_["config_entries_subentries"] == {entry.entry_id: [None]}
assert repr_["primary_config_entry"] == entry.entry_id
# Internal split-migration fields are not exposed in dict_repr
assert "composite_device_id" not in repr_
assert "composite_primary_config_entry" not in repr_
assert "split_at" not in repr_
assert "has_composite_identifiers" not in repr_