mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
12639 lines
464 KiB
Python
12639 lines
464 KiB
Python
"""Tests for the Device Registry."""
|
|
|
|
from collections.abc import Callable, Generator, Iterable
|
|
from contextlib import AbstractContextManager, nullcontext
|
|
from copy import deepcopy
|
|
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,
|
|
frame,
|
|
)
|
|
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,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _downgrade_device_registry_deprecation_reports(
|
|
request: pytest.FixtureRequest,
|
|
) -> Generator[None]:
|
|
"""Keep the deprecated device registry APIs from raising in tests.
|
|
|
|
async_get_device, async_is_composite_device_id, the config entry parameters and
|
|
merge_connections/merge_identifiers parameters of async_update_device, and via_device
|
|
on async_get_or_create are deprecated and raise for core and core integration callers,
|
|
disable them here so we can run tests without triggering deprecation errors.
|
|
|
|
Tests which use `mock_integration_frame` will not be affected by this fixture, so
|
|
they can test the deprecation.
|
|
"""
|
|
if "mock_integration_frame" in request.fixturenames:
|
|
yield
|
|
return
|
|
|
|
def _log_only(what: str, **kwargs: Any) -> None:
|
|
kwargs["core_behavior"] = frame.ReportBehavior.LOG
|
|
kwargs["core_integration_behavior"] = frame.ReportBehavior.LOG
|
|
kwargs["custom_integration_behavior"] = frame.ReportBehavior.LOG
|
|
frame.report_usage(what, **kwargs)
|
|
|
|
with patch.object(dr, "report_usage", _log_only):
|
|
yield
|
|
|
|
|
|
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.async_get_devices(
|
|
identifiers=identifiers, connections=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": {
|
|
"child_devices": [
|
|
{
|
|
"area_id": None,
|
|
"config_entry_id": mock_config_entry.entry_id,
|
|
"config_subentry_id": None,
|
|
"created_at": created_at,
|
|
"disabled_by": "device",
|
|
"id": "childdeviceid",
|
|
"identifiers": [["test", "strip_outlet_1"]],
|
|
"labels": [],
|
|
"modified_at": modified_at,
|
|
"name_by_user": None,
|
|
"name": "Outlet 1",
|
|
"parent_device_id": "abcdefghijklm",
|
|
}
|
|
],
|
|
"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
|
|
|
|
# A stored child device is loaded, with disabled_by "device" restored to the enum
|
|
loaded_child = registry.async_get("childdeviceid", include_main_devices=False)
|
|
assert loaded_child is not None
|
|
assert loaded_child.parent_device_id == "abcdefghijklm"
|
|
assert loaded_child.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
assert loaded_child.identifiers == {("test", "strip_outlet_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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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_clears_via_device_self_reference(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""The version 3.3 migration clears a device's via_device_id self-reference."""
|
|
entry = MockConfigEntry()
|
|
entry.add_to_hass(hass)
|
|
device_id = "selfref00000000000000000000000"
|
|
hass_storage[dr.STORAGE_KEY] = {
|
|
"version": 3,
|
|
"minor_version": 2,
|
|
"key": dr.STORAGE_KEY,
|
|
"data": {
|
|
"devices": [
|
|
{
|
|
"area_id": None,
|
|
"config_entry_id": 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": [],
|
|
"created_at": "1970-01-01T00:00:00+00:00",
|
|
"disabled_by": None,
|
|
"entry_type": None,
|
|
"hw_version": None,
|
|
"id": device_id,
|
|
"identifiers": [["test", "self"]],
|
|
"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,
|
|
# Buggy self-reference that the migration must clear
|
|
"via_device_id": device_id,
|
|
}
|
|
],
|
|
"deleted_devices": [],
|
|
},
|
|
}
|
|
|
|
dr.async_setup(hass)
|
|
await dr.async_load(hass)
|
|
registry = dr.async_get(hass)
|
|
|
|
device = registry.async_get(device_id)
|
|
assert device is not None
|
|
assert device.via_device_id is None
|
|
|
|
|
|
@pytest.mark.parametrize("load_registries", [False])
|
|
async def test_migration_clears_composite_via_device_self_reference(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""A self-reference the 3.2 split remapping introduces is cleared.
|
|
|
|
A pre-migration composite device linking to itself via via_device_id is split into
|
|
one device per config entry; the 3.2 remapping points each split's stale link at the
|
|
split owning its config entry, i.e. itself. The 3.3 step runs afterwards and clears
|
|
the resulting self-references.
|
|
"""
|
|
entry_1 = MockConfigEntry()
|
|
entry_1.add_to_hass(hass)
|
|
entry_2 = MockConfigEntry()
|
|
entry_2.add_to_hass(hass)
|
|
composite_id = "composite000000000000000000000"
|
|
hass_storage[dr.STORAGE_KEY] = {
|
|
"version": 1,
|
|
"minor_version": 12,
|
|
"key": dr.STORAGE_KEY,
|
|
"data": {
|
|
"devices": [
|
|
{
|
|
"area_id": None,
|
|
"config_entries": [entry_1.entry_id, entry_2.entry_id],
|
|
"config_entries_subentries": {
|
|
entry_1.entry_id: [None],
|
|
entry_2.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": composite_id,
|
|
"identifiers": [["test", "composite"]],
|
|
"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_1.entry_id,
|
|
"serial_number": None,
|
|
"sw_version": None,
|
|
# Buggy self-reference remapped to each split by the 3.2 migration
|
|
"via_device_id": composite_id,
|
|
}
|
|
],
|
|
"deleted_devices": [],
|
|
},
|
|
}
|
|
|
|
dr.async_setup(hass)
|
|
await dr.async_load(hass)
|
|
registry = dr.async_get(hass)
|
|
|
|
splits = registry._devices.get_devices_for_composite_device_id(composite_id)
|
|
assert len(splits) == 2
|
|
assert all(split.via_device_id is None for split in splits)
|
|
|
|
|
|
@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(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(RuntimeError),
|
|
1,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_async_is_composite_device_id_deprecated(
|
|
device_registry: dr.DeviceRegistry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test async_is_composite_device_id is deprecated.
|
|
|
|
It logs for custom integrations and raises for core and core integrations. Use
|
|
async_get with include_composite_devices=False instead.
|
|
"""
|
|
what = "calls `device_registry.async_is_composite_device_id`"
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
device_registry.async_is_composite_device_id("some_device_id")
|
|
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
async def test_async_get_include_composite_devices(
|
|
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
|
) -> None:
|
|
"""Test async_get gates main, child and composite devices independently."""
|
|
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")}
|
|
)
|
|
child_device = device_registry.async_get_or_create_child(
|
|
config_entry_id=entry_1.entry_id,
|
|
identifiers={("test", "child")},
|
|
parent_device_id=device_1.id,
|
|
name="Child",
|
|
)
|
|
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
|
|
)
|
|
|
|
# By default a composite id resolves to the synthesized composite
|
|
composite = device_registry.async_get(old_id)
|
|
assert composite is not None
|
|
assert composite.id == old_id
|
|
assert device_registry.async_get(old_id, include_child_devices=False) == composite
|
|
|
|
# include_composite_devices=False resolves a composite id to None, matching
|
|
# `old_id in device_registry._devices`, which is composite-blind
|
|
assert old_id not in device_registry._devices
|
|
assert device_registry.async_get(old_id, include_composite_devices=False) is None
|
|
assert (
|
|
device_registry.async_get(
|
|
old_id, include_child_devices=False, include_composite_devices=False
|
|
)
|
|
is None
|
|
)
|
|
|
|
# A registered main device resolves regardless of include_composite_devices
|
|
assert (
|
|
device_registry.async_get(device_1.id, include_composite_devices=False).id
|
|
== device_1.id
|
|
)
|
|
assert (
|
|
device_registry.async_get(
|
|
device_1.id, include_child_devices=False, include_composite_devices=False
|
|
).id
|
|
== device_1.id
|
|
)
|
|
|
|
# An unknown id is None with or without the flag
|
|
assert (
|
|
device_registry.async_get("unknown_id", include_composite_devices=False) is None
|
|
)
|
|
|
|
# include_main_devices=False, include_child_devices=False resolves only a composite
|
|
assert (
|
|
device_registry.async_get(
|
|
old_id, include_main_devices=False, include_child_devices=False
|
|
)
|
|
== composite
|
|
)
|
|
# a registered main device, a child device and an unknown id resolve to None
|
|
assert (
|
|
device_registry.async_get(
|
|
device_1.id, include_main_devices=False, include_child_devices=False
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
device_registry.async_get(
|
|
child_device.id, include_main_devices=False, include_child_devices=False
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
device_registry.async_get(
|
|
"unknown_id", include_main_devices=False, include_child_devices=False
|
|
)
|
|
is None
|
|
)
|
|
|
|
# include_main_devices=False, include_composite_devices=False resolves only a child:
|
|
# a composite id resolves to None, a child device still resolves
|
|
assert (
|
|
device_registry.async_get(
|
|
old_id, include_main_devices=False, include_composite_devices=False
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
device_registry.async_get(
|
|
child_device.id,
|
|
include_main_devices=False,
|
|
include_composite_devices=False,
|
|
)
|
|
== child_device
|
|
)
|
|
|
|
|
|
@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_devices_collection_operations(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test the supported `Collection[DeviceEntry]` surface of `DeviceRegistry.devices`.
|
|
|
|
Iteration yields the entries (not the ids), `len()` returns the count, and
|
|
`DeviceEntry` membership works.
|
|
"""
|
|
entry = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("bridgeid", "0123")},
|
|
)
|
|
|
|
assert list(device_registry.devices) == [entry]
|
|
assert [device.id for device in device_registry.devices] == [entry.id]
|
|
assert len(device_registry.devices) == 1
|
|
assert entry in device_registry.devices
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(RuntimeError),
|
|
1,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_devices_mapping_access_deprecated(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test mapping-style access to `DeviceRegistry.devices` is deprecated.
|
|
|
|
It logs for custom integrations and raises for core and core integrations, while
|
|
iterating the view keeps working for every caller.
|
|
"""
|
|
entry = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("bridgeid", "0123")},
|
|
)
|
|
what = "uses `device_registry.devices` as a mapping"
|
|
|
|
# Iterating the view is the supported API and is never reported.
|
|
assert list(device_registry.devices) == [entry]
|
|
assert caplog.text.count(what) == 0
|
|
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
_ = device_registry.devices[entry.id]
|
|
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"integration_frame_path", ["custom_components/test_integration"]
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_devices_membership_by_entry_supported_by_id_deprecated(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test `DeviceEntry` membership is supported while device-id (str) membership warns."""
|
|
entry = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("bridgeid", "0123")},
|
|
)
|
|
what = "uses `device_registry.devices` as a mapping"
|
|
|
|
# DeviceEntry (value) membership is supported and never reported.
|
|
assert entry in device_registry.devices
|
|
assert caplog.text.count(what) == 0
|
|
|
|
# Device-id (str) membership is the deprecated key lookup; it warns here (custom
|
|
# integration).
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()):
|
|
assert entry.id in device_registry.devices
|
|
assert caplog.text.count(what) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(RuntimeError),
|
|
1,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_deleted_devices_deprecated(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test accessing `DeviceRegistry.deleted_devices` is deprecated.
|
|
|
|
It logs for custom integrations and raises for core and core integrations.
|
|
"""
|
|
entry = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("bridgeid", "0123")},
|
|
)
|
|
device_registry.async_remove_device(entry.id)
|
|
what = "accesses `device_registry.deleted_devices`"
|
|
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
deleted_devices = device_registry.deleted_devices
|
|
# Custom integrations still receive the underlying container.
|
|
assert entry.id in deleted_devices
|
|
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(RuntimeError),
|
|
1,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_async_get_device_deprecated(
|
|
device_registry: dr.DeviceRegistry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test async_get_device is deprecated.
|
|
|
|
It logs for custom integrations and raises for core and core integrations.
|
|
"""
|
|
what = "calls `device_registry.async_get_device`"
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
device_registry.async_get_device(identifiers={("some_domain", "some_id")})
|
|
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("parameter", "value", "replacement"),
|
|
[
|
|
("default_manufacturer", "manufacturer", "manufacturer"),
|
|
("default_model", "model", "model"),
|
|
("default_name", "name", "name"),
|
|
("via_device", ("some_domain", "via_id"), "via_device_id"),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(RuntimeError),
|
|
1,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_async_get_or_create_deprecated_parameters(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
parameter: str,
|
|
value: Any,
|
|
replacement: str,
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test passing deprecated parameters to async_get_or_create.
|
|
|
|
They log for custom integrations and raise for core and core integrations.
|
|
"""
|
|
config_entry = MockConfigEntry()
|
|
config_entry.add_to_hass(hass)
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id, identifiers={("some_domain", "via_id")}
|
|
)
|
|
|
|
what = (
|
|
"calls `device_registry.async_get_or_create` with a deprecated "
|
|
f"`{parameter}` parameter; use `{replacement}` instead"
|
|
)
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id,
|
|
identifiers={("some_domain", "some_id")},
|
|
**{parameter: value},
|
|
)
|
|
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("parameter", "value"),
|
|
[
|
|
("default_manufacturer", "manufacturer"),
|
|
("default_model", "model"),
|
|
("default_name", "name"),
|
|
("via_device", ("some_domain", "via_id")),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_async_get_or_create_deprecated_parameter_reported_before_mutation(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
parameter: str,
|
|
value: Any,
|
|
) -> None:
|
|
"""A deprecated parameter is reported before the registry is mutated.
|
|
|
|
The default frame is a core integration, so the report raises; the new device must
|
|
not be left partially created.
|
|
"""
|
|
config_entry = MockConfigEntry()
|
|
config_entry.add_to_hass(hass)
|
|
|
|
with (
|
|
patch.object(frame, "_REPORTED_INTEGRATIONS", set()),
|
|
pytest.raises(RuntimeError),
|
|
):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id,
|
|
identifiers={("some_domain", "new_device")},
|
|
**{parameter: value},
|
|
)
|
|
|
|
# The report raised before insertion, so no partial device was left behind.
|
|
assert (
|
|
device_registry.async_get_device_by_identifier(
|
|
("some_domain", "new_device"), config_entry.entry_id
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
async def test_async_get_or_create_unexpected_keyword_argument(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
) -> None:
|
|
"""Test passing an unexpected keyword argument to async_get_or_create raises."""
|
|
config_entry = MockConfigEntry()
|
|
config_entry.add_to_hass(hass)
|
|
|
|
with pytest.raises(
|
|
TypeError,
|
|
match="got unexpected keyword arguments 'unexpected'",
|
|
):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id,
|
|
identifiers={("some_domain", "some_id")},
|
|
unexpected="value",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(RuntimeError),
|
|
1,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_async_update_device_config_entry_params_deprecated(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test the config entry params of async_update_device are deprecated.
|
|
|
|
Passing any of add_config_entry_id, add_config_subentry_id, remove_config_entry_id
|
|
or remove_config_subentry_id logs for custom integrations, and raises for core and
|
|
core integrations.
|
|
"""
|
|
config_entry = MockConfigEntry()
|
|
config_entry.add_to_hass(hass)
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id, identifiers={("some_domain", "some_id")}
|
|
)
|
|
|
|
what = "calls `device_registry.async_update_device` with one of"
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
device_registry.async_update_device(
|
|
device.id, remove_config_entry_id=config_entry.entry_id
|
|
)
|
|
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"update_kwargs",
|
|
[
|
|
pytest.param(
|
|
{"merge_connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}},
|
|
id="merge_connections",
|
|
),
|
|
pytest.param(
|
|
{"merge_identifiers": {("some_domain", "extra")}}, id="merge_identifiers"
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(RuntimeError),
|
|
1,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_async_update_device_merge_params_deprecated(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
update_kwargs: dict[str, set[tuple[str, str]]],
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test merge_connections/merge_identifiers of async_update_device are deprecated.
|
|
|
|
Passing either logs for custom integrations, and raises for core and core
|
|
integrations. The parameters stay available on the protected _async_update_device
|
|
for internal use by the registry.
|
|
"""
|
|
config_entry = MockConfigEntry()
|
|
config_entry.add_to_hass(hass)
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id, identifiers={("some_domain", "some_id")}
|
|
)
|
|
|
|
what = "calls `device_registry.async_update_device` with `merge_connections`"
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
device_registry.async_update_device(device.id, **update_kwargs)
|
|
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
async def test_get_or_create_via_device_self_reference_ignored(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A device referencing itself via the deprecated via_device is ignored and logged."""
|
|
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", "self")}
|
|
)
|
|
|
|
updated = device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id,
|
|
identifiers={("hue", "self")},
|
|
via_device=("hue", "self"),
|
|
)
|
|
|
|
assert updated.id == device.id
|
|
assert updated.via_device_id is None
|
|
assert (
|
|
"calls `device_registry.async_get_or_create` with a `via_device` "
|
|
"referencing the device itself" in caplog.text
|
|
)
|
|
|
|
|
|
async def test_get_or_create_via_device_id_self_reference_raises(
|
|
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
|
) -> None:
|
|
"""A device referencing itself via via_device_id raises, leaving it 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", "self")}
|
|
)
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError, match="A device can not be its own via device"
|
|
):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=config_entry.entry_id,
|
|
identifiers={("hue", "self")},
|
|
via_device_id=device.id,
|
|
)
|
|
|
|
assert device_registry.async_get(device.id).via_device_id is None
|
|
|
|
|
|
async def test_update_device_via_device_id_self_reference_raises(
|
|
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
|
) -> None:
|
|
"""Updating a device to reference itself via via_device_id raises."""
|
|
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", "self")}
|
|
)
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError, match="A device can not be its own via device"
|
|
):
|
|
device_registry.async_update_device(device.id, via_device_id=device.id)
|
|
|
|
assert device_registry.async_get(device.id).via_device_id is None
|
|
|
|
|
|
async def test_update_device_via_device_id_self_reference_raises_before_removal(
|
|
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
|
) -> None:
|
|
"""A self-referencing 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="A device can not be its own via device"
|
|
):
|
|
device_registry.async_update_device(
|
|
device.id,
|
|
remove_config_entry_id=config_entry.entry_id,
|
|
via_device_id=device.id,
|
|
)
|
|
|
|
# The device was not removed
|
|
assert device_registry.async_get(device.id) == device
|
|
|
|
|
|
async def test_update_device_composite_via_device_id_self_reference_raises_before_removal(
|
|
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
|
) -> None:
|
|
"""A composite via_device_id self-reference raises before a removal in one call."""
|
|
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
|
|
)
|
|
|
|
# old_id resolves to device_1 (the split owned by entry_1), so linking device_1 to
|
|
# it is a self-reference; it must raise before the removal deletes the device.
|
|
with pytest.raises(
|
|
HomeAssistantError, match="A device can not be its own via device"
|
|
):
|
|
device_registry.async_update_device(
|
|
device_1.id,
|
|
remove_config_entry_id=entry_1.entry_id,
|
|
via_device_id=old_id,
|
|
)
|
|
|
|
assert device_1.id in device_registry._devices
|
|
|
|
|
|
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"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "default_field"),
|
|
[
|
|
("name", "default_name"),
|
|
("manufacturer", "default_manufacturer"),
|
|
("model", "default_model"),
|
|
],
|
|
)
|
|
async def test_get_or_create_rejects_field_and_its_default(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
field: str,
|
|
default_field: str,
|
|
) -> None:
|
|
"""Test passing both an explicit field and its default_ counterpart is rejected."""
|
|
with pytest.raises(
|
|
dr.DeviceInfoError,
|
|
match=f"passing both `{field}` and `{default_field}` is not allowed",
|
|
):
|
|
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")},
|
|
**{field: "explicit value", default_field: "default value"},
|
|
)
|
|
|
|
|
|
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": {
|
|
"child_devices": [],
|
|
"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": {
|
|
"child_devices": [],
|
|
"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}
|
|
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() == {}
|
|
|
|
|
|
async def test_async_get_device_and_config_entry_for_domain(
|
|
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
|
) -> None:
|
|
"""Test getting the device and config entry of a domain owning a device."""
|
|
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 dr.async_get_device_and_config_entry_for_domain(
|
|
hass, device.id, domain="domain_a"
|
|
) == (device, entry)
|
|
# A domain not owning the device still gets the device
|
|
assert dr.async_get_device_and_config_entry_for_domain(
|
|
hass, device.id, domain="domain_b"
|
|
) == (device, None)
|
|
# An unknown device id
|
|
assert dr.async_get_device_and_config_entry_for_domain(
|
|
hass, "unknown_id", domain="domain_a"
|
|
) == (None, None)
|
|
|
|
|
|
@pytest.mark.parametrize("load_registries", [False])
|
|
async def test_async_get_device_and_config_entry_for_domain_composite(
|
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
|
) -> None:
|
|
"""Test getting the device and config entry via a composite device id."""
|
|
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")}
|
|
)
|
|
|
|
# The returned pair is consistent: the domain's split device, not the composite
|
|
assert dr.async_get_device_and_config_entry_for_domain(
|
|
hass, COMPOSITE_ID, domain="domain_a"
|
|
) == (split_a, entry_a)
|
|
assert dr.async_get_device_and_config_entry_for_domain(
|
|
hass, COMPOSITE_ID, domain="domain_b"
|
|
) == (split_b, entry_b)
|
|
# A domain owning none of the splits gets the restored composite and no entry
|
|
device, config_entry = dr.async_get_device_and_config_entry_for_domain(
|
|
hass, COMPOSITE_ID, domain="domain_c"
|
|
)
|
|
assert config_entry is None
|
|
assert device is not None
|
|
assert device.id == COMPOSITE_ID
|
|
assert device.config_entries == {entry_a.entry_id, entry_b.entry_id}
|
|
|
|
|
|
@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_
|
|
|
|
|
|
def _create_parent_and_child(
|
|
device_registry: dr.DeviceRegistry,
|
|
config_entry_id: str,
|
|
*,
|
|
config_subentry_id: str | UndefinedType = UNDEFINED,
|
|
) -> tuple[dr.DeviceEntry, dr.ChildDeviceEntry]:
|
|
"""Create a parent device with one child device."""
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=config_entry_id,
|
|
config_subentry_id=config_subentry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
child_device = device_registry.async_get_or_create_child(
|
|
config_entry_id=config_entry_id,
|
|
config_subentry_id=config_subentry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
return parent, child_device
|
|
|
|
|
|
async def test_child_device_create(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test creating a child device."""
|
|
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
assert isinstance(child_device, dr.ChildDeviceEntry)
|
|
assert child_device.parent_device_id == parent.id
|
|
assert child_device.config_entry_id == mock_config_entry.entry_id
|
|
assert child_device.config_subentry_id is None
|
|
assert child_device.identifiers == {("test", "strip_outlet_1")}
|
|
assert child_device.name == "Outlet 1"
|
|
assert child_device.area_id is None
|
|
assert child_device.disabled_by is None
|
|
|
|
assert device_registry.async_get(child_device.id) is child_device
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_main_devices=False)
|
|
is child_device
|
|
)
|
|
assert len(device_registry.devices) == 1
|
|
assert len(device_registry.child_devices) == 1
|
|
assert dr.async_entries_for_parent_device(device_registry, parent.id) == [
|
|
child_device
|
|
]
|
|
assert dr.async_child_entries_for_config_entry(
|
|
device_registry, mock_config_entry.entry_id
|
|
) == [child_device]
|
|
assert dr.async_entries_for_config_entry(
|
|
device_registry, mock_config_entry.entry_id
|
|
) == [parent]
|
|
|
|
await hass.async_block_till_done()
|
|
assert [event.data for event in update_events] == [
|
|
{"action": "create", "device_id": parent.id},
|
|
{"action": "create", "device_id": child_device.id},
|
|
]
|
|
|
|
assert child_device.dict_repr == {
|
|
"area_id": None,
|
|
"config_entry_id": mock_config_entry.entry_id,
|
|
"config_subentry_id": None,
|
|
"created_at": child_device.created_at.timestamp(),
|
|
"disabled_by": None,
|
|
"id": child_device.id,
|
|
"identifiers": [("test", "strip_outlet_1")],
|
|
"labels": [],
|
|
"modified_at": child_device.modified_at.timestamp(),
|
|
"name_by_user": None,
|
|
"name": "Outlet 1",
|
|
"parent_device_id": parent.id,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("attr_name", "expected_default"),
|
|
[
|
|
("configuration_url", None),
|
|
("connections", set()),
|
|
("entry_type", None),
|
|
("hw_version", None),
|
|
("manufacturer", None),
|
|
("model", None),
|
|
("model_id", None),
|
|
("serial_number", None),
|
|
("sw_version", None),
|
|
("via_device_id", None),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("integration_frame_path", "expectation", "expected_log"),
|
|
[
|
|
pytest.param(
|
|
"homeassistant/test_core", pytest.raises(AttributeError), 0, id="core"
|
|
),
|
|
pytest.param(
|
|
"homeassistant/components/test_integration",
|
|
pytest.raises(AttributeError),
|
|
0,
|
|
id="core integration",
|
|
),
|
|
pytest.param(
|
|
"custom_components/test_integration",
|
|
nullcontext(),
|
|
1,
|
|
id="custom integration",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_integration_frame")
|
|
async def test_child_device_deprecated_device_entry_attrs(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
attr_name: str,
|
|
expected_default: Any,
|
|
expectation: AbstractContextManager,
|
|
expected_log: int,
|
|
) -> None:
|
|
"""Test accessing a DeviceEntry-only attribute on a child device.
|
|
|
|
Custom integrations get the DeviceEntry default value and a deprecation warning;
|
|
core and core integrations raise AttributeError so the attribute reads as missing.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
what = (
|
|
f"accesses ChildDeviceEntry.{attr_name}, which does not exist on child devices"
|
|
)
|
|
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation:
|
|
assert getattr(child_device, attr_name) == expected_default
|
|
assert caplog.text.count(what) == expected_log
|
|
|
|
|
|
@pytest.mark.parametrize("attr_name", sorted(dr._CHILD_DEVICE_COMPAT_ATTRS))
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_deprecated_attrs_missing_for_core(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
attr_name: str,
|
|
) -> None:
|
|
"""Test DeviceEntry-only attributes read as missing without an integration frame.
|
|
|
|
This is the template/pure-core path: hasattr must be False so device_attr and
|
|
is_device_attr fall back to None instead of raising.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
assert hasattr(child_device, attr_name) is False
|
|
with pytest.raises(AttributeError, match=f"has no attribute '{attr_name}'"):
|
|
getattr(child_device, attr_name)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_unknown_attribute_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test accessing a genuinely unknown attribute on a child device raises."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
with pytest.raises(AttributeError, match="has no attribute 'does_not_exist'"):
|
|
getattr(child_device, "does_not_exist") # noqa: B009
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_async_get_exclude_child_devices(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test async_get with include_child_devices=False treats children as absent."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
assert device_registry.async_get(child_device.id) is child_device
|
|
assert device_registry.async_get(parent.id, include_child_devices=False) is parent
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_child_devices=False) is None
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_async_get_child_device_by_identifier(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test looking up a child device by identifier."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
assert (
|
|
device_registry.async_get_child_device_by_identifier(
|
|
("test", "strip_outlet_1"), mock_config_entry.entry_id
|
|
)
|
|
is child_device
|
|
)
|
|
assert (
|
|
device_registry.async_get_child_device_by_identifier(
|
|
("test", "unknown"), mock_config_entry.entry_id
|
|
)
|
|
is None
|
|
)
|
|
# Only child devices are searched, so a main device's identifier is not found
|
|
assert (
|
|
device_registry.async_get_child_device_by_identifier(
|
|
("test", "strip"), mock_config_entry.entry_id
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_create_idempotent(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test re-registering a child device is idempotent and applies updates."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
child_device_2 = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet one",
|
|
)
|
|
assert child_device_2.id == child_device.id
|
|
assert child_device_2.name == "Outlet one"
|
|
assert len(device_registry.child_devices) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_create_with_suggested_area(
|
|
area_registry: ar.AreaRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test suggested_area sets the initial area of a new child device only."""
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
child_device = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
suggested_area="Garden",
|
|
)
|
|
garden = area_registry.async_get_area_by_name("Garden")
|
|
assert garden is not None
|
|
assert child_device.area_id == garden.id
|
|
|
|
# suggested_area is a one-shot hint for a new child device
|
|
child_device_2 = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
suggested_area="Garage",
|
|
)
|
|
assert child_device_2.area_id == garden.id
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("identifiers", "parent_key", "error"),
|
|
[
|
|
pytest.param(
|
|
{("test", "strip_outlet_1")},
|
|
"unknown",
|
|
"must be created before its child devices",
|
|
id="unknown_parent",
|
|
),
|
|
pytest.param(
|
|
{("test", "grandchild")},
|
|
"child",
|
|
"can't be the parent of another child device",
|
|
id="parent_is_child",
|
|
),
|
|
pytest.param(
|
|
{("test", "strip_outlet_1")},
|
|
"other_strip",
|
|
"reparenting is not supported",
|
|
id="reparent",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_create_errors(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
identifiers: set[tuple[str, str]],
|
|
parent_key: str,
|
|
error: str,
|
|
) -> None:
|
|
"""Test invalid child device registrations raise DeviceInfoError."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
other_strip = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "other_strip")},
|
|
name="Other strip",
|
|
)
|
|
parent_device_ids = {
|
|
"unknown": "nonexistent-device-id",
|
|
"child": child_device.id,
|
|
"other_strip": other_strip.id,
|
|
}
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match=error):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers=identifiers,
|
|
parent_device_id=parent_device_ids[parent_key],
|
|
name="Nope",
|
|
)
|
|
|
|
# Validation precedes mutation, so the registry is unchanged by the rejection
|
|
assert len(device_registry.devices) == 2
|
|
assert len(device_registry.child_devices) == 1
|
|
unchanged_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert unchanged_child is not None
|
|
assert unchanged_child.parent_device_id == parent.id
|
|
|
|
|
|
async def test_child_device_parent_in_other_config_entry(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a child device must share its parent's config entry."""
|
|
other_entry = MockConfigEntry(title=None)
|
|
other_entry.add_to_hass(hass)
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=other_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="same config entry"):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
|
|
# Validation precedes mutation, so no child device is created
|
|
assert not device_registry.child_devices
|
|
assert len(device_registry.devices) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_subentry(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry_with_subentries: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a child device lives in the same config subentry as its parent."""
|
|
entry_id = mock_config_entry_with_subentries.entry_id
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, entry_id, config_subentry_id="mock-subentry-id-1-1"
|
|
)
|
|
assert child_device.config_subentry_id == "mock-subentry-id-1-1"
|
|
assert child_device.parent_device_id == parent.id
|
|
|
|
# A child device in a different subentry than its parent is rejected
|
|
with pytest.raises(dr.DeviceInfoError, match="same config subentry"):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-2",
|
|
identifiers={("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 2",
|
|
)
|
|
with pytest.raises(dr.DeviceInfoError, match="same config subentry"):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=entry_id,
|
|
identifiers={("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 2",
|
|
)
|
|
|
|
# Neither rejected registration created a child device
|
|
assert len(device_registry.child_devices) == 1
|
|
assert (
|
|
device_registry.async_get_child_device_by_identifier(
|
|
("test", "strip_outlet_2"), entry_id
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
async def test_child_device_update(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test updating a child device."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
|
|
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id,
|
|
area_id="garden",
|
|
labels={"outdoor"},
|
|
name_by_user="Garden lamp plug",
|
|
)
|
|
assert isinstance(updated, dr.ChildDeviceEntry)
|
|
assert updated.area_id == "garden"
|
|
assert updated.labels == {"outdoor"}
|
|
assert updated.name_by_user == "Garden lamp plug"
|
|
|
|
# Clearing the area restores inheriting the parent's area
|
|
updated = device_registry.async_update_child_device(child_device.id, area_id=None)
|
|
assert updated.area_id is None
|
|
|
|
await hass.async_block_till_done()
|
|
assert [event.data for event in update_events] == [
|
|
{
|
|
"action": "update",
|
|
"device_id": child_device.id,
|
|
"changes": {"area_id": None, "labels": set(), "name_by_user": None},
|
|
},
|
|
{
|
|
"action": "update",
|
|
"device_id": child_device.id,
|
|
"changes": {"area_id": "garden"},
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_update_main_device_rejects_disabled_by_device(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test disabled_by=DEVICE is rejected on a main device, on create and update.
|
|
|
|
DeviceEntryDisabler.DEVICE means "disabled because the parent is disabled" and is
|
|
only valid for a child device. The rejection runs before any registry mutation, so
|
|
a rejected create leaves no phantom device behind and a rejected update leaves the
|
|
device unchanged.
|
|
"""
|
|
match = "disabled_by=DeviceEntryDisabler.DEVICE is only valid for a child device"
|
|
|
|
# The create path rejects it, before a (phantom) device is created
|
|
with pytest.raises(HomeAssistantError, match=match):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "main")},
|
|
disabled_by=dr.DeviceEntryDisabler.DEVICE,
|
|
)
|
|
assert len(device_registry.devices) == 0
|
|
|
|
# The update path rejects it, leaving the device unchanged
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "main")},
|
|
)
|
|
assert device.disabled_by is None
|
|
with pytest.raises(HomeAssistantError, match=match):
|
|
device_registry.async_update_device(
|
|
device.id, disabled_by=dr.DeviceEntryDisabler.DEVICE
|
|
)
|
|
assert device_registry.async_get(device.id).disabled_by is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_update_identifiers(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test setting child device identifiers with new_identifiers, incl. collisions."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
# new_identifiers replaces the child device's identifiers
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id,
|
|
new_identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_1_alias")},
|
|
)
|
|
assert updated.identifiers == {
|
|
("test", "strip_outlet_1"),
|
|
("test", "strip_outlet_1_alias"),
|
|
}
|
|
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id, new_identifiers={("test", "strip_outlet_1")}
|
|
)
|
|
assert updated.identifiers == {("test", "strip_outlet_1")}
|
|
|
|
with pytest.raises(HomeAssistantError, match="must have at least one identifier"):
|
|
device_registry.async_update_child_device(
|
|
child_device.id, new_identifiers=set()
|
|
)
|
|
|
|
# A child device can't take an identifier registered by a device (its parent)
|
|
with pytest.raises(dr.DeviceIdentifierCollisionError):
|
|
device_registry.async_update_child_device(
|
|
child_device.id, new_identifiers={("test", "strip")}
|
|
)
|
|
|
|
# A device can't take an identifier registered by a child device
|
|
with pytest.raises(dr.DeviceIdentifierCollisionError):
|
|
device_registry.async_update_device(
|
|
parent.id, merge_identifiers={("test", "strip_outlet_1")}
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_get_or_create_rejects_invalid_identifier_count(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test creating a child device with zero or multiple identifiers is rejected."""
|
|
parent, _child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="must have at least one identifier"):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers=set(),
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
# The rejected registration leaves the existing child unchanged
|
|
assert len(device_registry.child_devices) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_get_or_create_merges_identifiers(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test re-registering a child device merges additional identifiers into it.
|
|
|
|
merge_identifiers is rejected on the public update path for a child device, so
|
|
the internal merge only happens through async_get_or_create.
|
|
"""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
merged = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_1_alias")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert merged.id == child_device.id
|
|
assert merged.identifiers == {
|
|
("test", "strip_outlet_1"),
|
|
("test", "strip_outlet_1_alias"),
|
|
}
|
|
assert len(device_registry.child_devices) == 1
|
|
|
|
|
|
async def test_remove_parent_cascades_to_children(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test removing a parent device removes its child devices first."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
remove_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
|
|
|
|
device_registry.async_remove_device(parent.id)
|
|
|
|
assert device_registry.async_get(parent.id) is None
|
|
assert device_registry.async_get(child_device.id) is None
|
|
assert not device_registry.child_devices
|
|
assert child_device.id in device_registry._deleted_devices
|
|
assert parent.id in device_registry._deleted_devices
|
|
|
|
await hass.async_block_till_done()
|
|
assert [event.data for event in remove_events] == [
|
|
{
|
|
"action": "remove",
|
|
"device_id": child_device.id,
|
|
"device": child_device.dict_repr,
|
|
},
|
|
{"action": "remove", "device_id": parent.id, "device": parent.dict_repr},
|
|
]
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_remove_child_device_and_restore(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test removing and restoring a child device preserves id and user data."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_child_device(
|
|
child_device.id, area_id="garden", labels={"outdoor"}, name_by_user="Lamp"
|
|
)
|
|
|
|
device_registry.async_remove_device(child_device.id)
|
|
assert device_registry.async_get(child_device.id) is None
|
|
assert device_registry.async_get(parent.id) is not None
|
|
|
|
restored = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert restored.id == child_device.id
|
|
assert restored.area_id == "garden"
|
|
assert restored.labels == {"outdoor"}
|
|
assert restored.name_by_user == "Lamp"
|
|
assert restored.parent_device_id == parent.id
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_parent_disable_cascades_to_children(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test disabling and enabling a parent device cascades to its children."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
updated_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert updated_child is not None
|
|
assert updated_child.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
|
|
device_registry.async_update_device(parent.id, disabled_by=None)
|
|
updated_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert updated_child is not None
|
|
assert updated_child.disabled_by is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_parent_enable_keeps_user_disabled_child(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test enabling a parent does not enable a user-disabled child device."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_child_device(
|
|
child_device.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
device_registry.async_update_device(parent.id, disabled_by=None)
|
|
|
|
updated_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert updated_child is not None
|
|
assert updated_child.disabled_by is dr.DeviceEntryDisabler.USER
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_disabled_by_reconciled_with_parent(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test a child device's disabled_by is reconciled with the parent state."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
# DEVICE on a child of an enabled parent is inconsistent and ignored
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id, disabled_by=dr.DeviceEntryDisabler.DEVICE
|
|
)
|
|
assert updated.disabled_by is None
|
|
assert "whose parent device is enabled" in caplog.text
|
|
|
|
# A child of a disabled parent can't be enabled; it stays disabled by the parent
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id, disabled_by=None
|
|
)
|
|
assert updated.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
assert "whose parent device is disabled" in caplog.text
|
|
|
|
# A child device created under a disabled parent is born disabled
|
|
new_child = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 2",
|
|
)
|
|
assert new_child.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
|
|
|
|
async def test_config_entry_reenable_with_user_disabled_parent_no_warning(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test re-enabling a config entry with a user-disabled parent does not warn.
|
|
|
|
The config-entry re-enable cascade clears the child's CONFIG_ENTRY disable by passing
|
|
disabled_by=None; with the parent still user-disabled the child is coerced back to
|
|
DEVICE. This internal reconciliation must not emit the "sets disabled_by to None"
|
|
report, while a direct external enable of such a child must.
|
|
"""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
await hass.config_entries.async_set_disabled_by(
|
|
mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER
|
|
)
|
|
await hass.async_block_till_done()
|
|
disabled_parent = device_registry.async_get(parent.id)
|
|
disabled_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert disabled_parent is not None
|
|
assert disabled_child is not None
|
|
assert disabled_parent.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
assert disabled_child.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
|
|
# User disables the parent while the config entry is disabled; the child keeps its
|
|
# CONFIG_ENTRY disable because it is already disabled
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
child_after_parent_disable = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert child_after_parent_disable is not None
|
|
assert child_after_parent_disable.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
|
|
caplog.clear()
|
|
await hass.config_entries.async_set_disabled_by(mock_config_entry.entry_id, None)
|
|
await hass.async_block_till_done()
|
|
updated_parent = device_registry.async_get(parent.id)
|
|
updated_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert updated_parent is not None
|
|
assert updated_child is not None
|
|
assert updated_parent.disabled_by is dr.DeviceEntryDisabler.USER
|
|
assert updated_child.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
assert "Detected code that" not in caplog.text
|
|
|
|
# A direct external enable of the same child still warns, as it is not the cascade
|
|
caplog.clear()
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id, disabled_by=None
|
|
)
|
|
assert updated.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
assert "whose parent device is disabled" in caplog.text
|
|
|
|
|
|
async def test_config_entry_disable_with_children(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test disabling and enabling a config entry cascades to child devices."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
await hass.config_entries.async_set_disabled_by(
|
|
mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER
|
|
)
|
|
await hass.async_block_till_done()
|
|
updated_parent = device_registry.async_get(parent.id)
|
|
updated_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert updated_parent is not None
|
|
assert updated_child is not None
|
|
assert updated_parent.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
assert updated_child.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
|
|
await hass.config_entries.async_set_disabled_by(mock_config_entry.entry_id, None)
|
|
await hass.async_block_till_done()
|
|
updated_parent = device_registry.async_get(parent.id)
|
|
updated_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert updated_parent is not None
|
|
assert updated_child is not None
|
|
assert updated_parent.disabled_by is None
|
|
assert updated_child.disabled_by is None
|
|
|
|
|
|
async def test_disable_child_device_directly_disables_entities(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test disabling a child device directly disables and re-enables its entities."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
entity_entry = entity_registry.async_get_or_create(
|
|
"switch",
|
|
"test",
|
|
"outlet_1",
|
|
config_entry=mock_config_entry,
|
|
device_id=child_device.id,
|
|
)
|
|
|
|
device_registry.async_update_child_device(
|
|
child_device.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
updated_entity = entity_registry.async_get(entity_entry.entity_id)
|
|
assert updated_entity is not None
|
|
assert updated_entity.disabled_by is er.RegistryEntryDisabler.DEVICE
|
|
|
|
device_registry.async_update_child_device(child_device.id, disabled_by=None)
|
|
await hass.async_block_till_done()
|
|
|
|
updated_entity = entity_registry.async_get(entity_entry.entity_id)
|
|
assert updated_entity is not None
|
|
assert updated_entity.disabled_by is None
|
|
|
|
|
|
async def test_move_parent_with_children_rejected(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a parent device with child devices can't move."""
|
|
other_entry = MockConfigEntry(title=None)
|
|
other_entry.add_to_hass(hass)
|
|
parent, _ = _create_parent_and_child(device_registry, mock_config_entry.entry_id)
|
|
|
|
with pytest.raises(HomeAssistantError, match="has child devices"):
|
|
device_registry.async_update_device(
|
|
parent.id, new_config_entry_id=other_entry.entry_id
|
|
)
|
|
|
|
# The rejected move leaves the parent and its child untouched
|
|
unchanged_parent = device_registry.async_get(parent.id)
|
|
assert unchanged_parent is not None
|
|
assert unchanged_parent.config_entry_id == mock_config_entry.entry_id
|
|
assert len(device_registry.child_devices) == 1
|
|
|
|
|
|
async def test_convert_device_to_child_device(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test converting an already-split device to a child device keeps its id."""
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
old_split = 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={("test", "strip_outlet_1")},
|
|
manufacturer="acme",
|
|
name="Outlet 1",
|
|
via_device_id=parent.id,
|
|
)
|
|
device_registry.async_update_device(
|
|
old_split.id, area_id="garden", name_by_user="Lamp"
|
|
)
|
|
# A new setup session: the split device is no longer live, so the integration
|
|
# can now adopt it as a child device.
|
|
device_registry.async_config_entry_unloaded(mock_config_entry.entry_id)
|
|
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
|
|
|
|
converted = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert isinstance(converted, dr.ChildDeviceEntry)
|
|
assert converted.id == old_split.id
|
|
assert converted.parent_device_id == parent.id
|
|
assert converted.area_id == "garden"
|
|
assert converted.name_by_user == "Lamp"
|
|
assert converted.identifiers == {("test", "strip_outlet_1")}
|
|
assert len(device_registry.devices) == 1
|
|
assert len(device_registry.child_devices) == 1
|
|
|
|
await hass.async_block_till_done()
|
|
assert [event.data for event in update_events] == [
|
|
{
|
|
"action": "update",
|
|
"device_id": old_split.id,
|
|
"changes": {
|
|
"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")},
|
|
"manufacturer": "acme",
|
|
"parent_device_id": None,
|
|
"via_device_id": parent.id,
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("identifiers", "error"),
|
|
[
|
|
pytest.param(
|
|
{("test", "other")},
|
|
"can't be its own parent",
|
|
id="self_parent",
|
|
),
|
|
pytest.param(
|
|
{("test", "strip")},
|
|
"has child devices itself",
|
|
id="has_children",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_convert_device_to_child_device_errors(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
identifiers: set[tuple[str, str]],
|
|
error: str,
|
|
) -> None:
|
|
"""Test invalid device to child device conversions."""
|
|
_create_parent_and_child(device_registry, mock_config_entry.entry_id)
|
|
other = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "other")},
|
|
name="Other",
|
|
)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match=error):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers=identifiers,
|
|
parent_device_id=other.id,
|
|
name="Nope",
|
|
)
|
|
|
|
# The conversion guards run before any reconcile, so the rejection mutates nothing
|
|
assert len(device_registry.devices) == 2
|
|
assert len(device_registry.child_devices) == 1
|
|
assert device_registry.async_get(other.id) is other
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"identifiers",
|
|
[
|
|
pytest.param({("test", "strip_outlet_1")}, id="exact_identifiers"),
|
|
pytest.param(
|
|
{("test", "strip_outlet_1"), ("test", "strip_outlet_1_alias")},
|
|
id="extra_identifiers",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_link_device_info_matching_child_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
identifiers: set[tuple[str, str]],
|
|
) -> None:
|
|
"""Test a bare-identifier device info matching a child device raises."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers=identifiers,
|
|
)
|
|
|
|
# The child device is left untouched: not converted, no new device created
|
|
assert len(device_registry.devices) == 1
|
|
assert len(device_registry.child_devices) == 1
|
|
assert device_registry._child_devices[child_device.id] == child_device
|
|
assert child_device.identifiers == {("test", "strip_outlet_1")}
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_convert_device_to_child_detaches_via_links(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test converting a device to a child detaches inbound via_device links."""
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
outlet = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "outlet")},
|
|
name="Outlet",
|
|
via_device_id=parent.id,
|
|
)
|
|
nested = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "nested")},
|
|
name="Nested",
|
|
via_device_id=outlet.id,
|
|
)
|
|
assert nested.via_device_id == outlet.id
|
|
# A new setup session: the outlet is no longer live and can be adopted as a child
|
|
device_registry.async_config_entry_unloaded(mock_config_entry.entry_id)
|
|
|
|
converted = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "outlet")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet",
|
|
)
|
|
assert isinstance(converted, dr.ChildDeviceEntry)
|
|
assert converted.id == outlet.id
|
|
|
|
# The inbound via link is detached so it can no longer resolve to a child device
|
|
nested_after = device_registry.async_get_device(identifiers={("test", "nested")})
|
|
assert nested_after is not None
|
|
assert nested_after.via_device_id is None
|
|
# No live device links to a child device through via_device_id
|
|
child_via_targets = [
|
|
device.id
|
|
for device in device_registry.devices
|
|
if device.via_device_id is not None
|
|
and device_registry.async_get(device.via_device_id, include_main_devices=False)
|
|
is not None
|
|
]
|
|
assert child_via_targets == []
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_convert_device_to_child_same_session_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test registering a live device's identifiers as a child raises."""
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
name="Outlet 1",
|
|
)
|
|
|
|
with pytest.raises(
|
|
dr.DeviceInfoError,
|
|
match="registered as a device and as a child device",
|
|
):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
|
|
# The conversion guard runs before any reconcile, so the rejection leaves the
|
|
# device a main device and creates no child device
|
|
assert device_registry.async_get(device.id) is device
|
|
assert not device_registry.child_devices
|
|
assert len(device_registry.devices) == 2
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_primary_device_info_matching_child_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a primary device info whose identifiers belong to a child raises."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
manufacturer="acme",
|
|
name="Outlet 1",
|
|
)
|
|
|
|
# The rejection leaves the child device untouched and creates no main device for
|
|
# its identifiers
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_main_devices=False)
|
|
is child_device
|
|
)
|
|
assert len(device_registry.child_devices) == 1
|
|
assert len(device_registry.devices) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_get_or_create_via_device_id_naming_child_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a via_device_id resolving to a child device is rejected before mutation."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
with pytest.raises(
|
|
dr.DeviceInfoError,
|
|
match="is a child device, which can't be a via device",
|
|
):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "new_device")},
|
|
via_device_id=child_device.id,
|
|
)
|
|
|
|
# Validation precedes mutation, so the rejected device is not partially created
|
|
assert (
|
|
device_registry.async_get_device(identifiers={("test", "new_device")}) is None
|
|
)
|
|
assert len(device_registry.devices) == 1
|
|
|
|
# The deprecated via_device (identifier form) resolves against main devices only, so
|
|
# a child's identifier is treated as an unknown via device: it logs a deprecation and
|
|
# links nothing rather than raising. Only the id form enforces the invariant.
|
|
linked = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "new_device")},
|
|
via_device=("test", "strip_outlet_1"),
|
|
)
|
|
assert linked.via_device_id is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_update_device_via_device_id_naming_child_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test updating via_device_id to a child device raises, leaving it unchanged."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "device")},
|
|
name="Device",
|
|
)
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError,
|
|
match="is a child device, which can't be a via device",
|
|
):
|
|
device_registry.async_update_device(device.id, via_device_id=child_device.id)
|
|
|
|
assert device_registry.async_get(device.id).via_device_id is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_deleted_device_restored_as_child_device(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a deleted device can restore as a child device and vice versa."""
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
name="Outlet 1",
|
|
manufacturer="acme",
|
|
)
|
|
device_registry.async_update_device(device.id, area_id="garden")
|
|
device_registry.async_remove_device(device.id)
|
|
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
restored_child = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert isinstance(restored_child, dr.ChildDeviceEntry)
|
|
assert restored_child.id == device.id
|
|
assert restored_child.area_id == "garden"
|
|
|
|
# And a deleted child device can restore as a device
|
|
device_registry.async_remove_device(restored_child.id)
|
|
restored_device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
name="Outlet 1",
|
|
manufacturer="acme",
|
|
)
|
|
assert isinstance(restored_device, dr.DeviceEntry)
|
|
assert restored_device.id == device.id
|
|
assert restored_device.area_id == "garden"
|
|
|
|
|
|
async def test_child_device_orphan_restore(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a child device orphaned by config entry removal restores."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_child_device(child_device.id, area_id="garden")
|
|
|
|
device_registry.async_clear_config_entry(mock_config_entry.entry_id)
|
|
assert not device_registry._devices
|
|
assert not device_registry.child_devices
|
|
|
|
new_entry = MockConfigEntry(title=None)
|
|
new_entry.add_to_hass(hass)
|
|
new_parent = device_registry.async_get_or_create(
|
|
config_entry_id=new_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
restored = device_registry.async_get_or_create_child(
|
|
config_entry_id=new_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=new_parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert restored.id == child_device.id
|
|
assert restored.area_id == "garden"
|
|
assert restored.config_entry_id == new_entry.entry_id
|
|
|
|
|
|
async def test_child_device_load_and_save(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test child devices round-trip through the store, unchanged on re-save."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_child_device(
|
|
child_device.id, area_id="garden", labels={"outdoor"}, name_by_user="Lamp"
|
|
)
|
|
|
|
registry2 = dr.DeviceRegistry(hass)
|
|
await flush_store(device_registry._store)
|
|
first_save = deepcopy(hass_storage[dr.STORAGE_KEY]["data"])
|
|
await registry2.async_load()
|
|
|
|
assert list(device_registry._devices) == list(registry2._devices)
|
|
assert list(device_registry.child_devices) == list(registry2.child_devices)
|
|
loaded_child = registry2.async_get(child_device.id, include_main_devices=False)
|
|
assert loaded_child is not None
|
|
assert loaded_child.parent_device_id == parent.id
|
|
assert loaded_child.area_id == "garden"
|
|
assert loaded_child.labels == {"outdoor"}
|
|
assert loaded_child.name_by_user == "Lamp"
|
|
assert loaded_child.identifiers == {("test", "strip_outlet_1")}
|
|
|
|
# Loading must not silently mutate a child device, so re-saving the freshly
|
|
# loaded registry reproduces the same stored data.
|
|
registry2.async_schedule_save()
|
|
await flush_store(registry2._store)
|
|
assert hass_storage[dr.STORAGE_KEY]["data"] == first_save
|
|
|
|
|
|
@pytest.mark.parametrize("load_registries", [False])
|
|
async def test_migration_3_3_to_3_4(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test migration from 3.3 adds the child devices list."""
|
|
hass_storage[dr.STORAGE_KEY] = {
|
|
"version": 3,
|
|
"minor_version": 3,
|
|
"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": [],
|
|
"created_at": "2024-01-01T00:00:00+00:00",
|
|
"disabled_by": None,
|
|
"entry_type": None,
|
|
"hw_version": None,
|
|
"id": "abcdefghijklm",
|
|
"identifiers": [["test", "strip"]],
|
|
"labels": [],
|
|
"manufacturer": None,
|
|
"model": None,
|
|
"model_id": None,
|
|
"modified_at": "2024-01-01T00:00:00+00:00",
|
|
"name_by_user": None,
|
|
"name": "Power strip",
|
|
"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
|
|
assert not registry.child_devices
|
|
|
|
await flush_store(registry._store)
|
|
assert hass_storage[dr.STORAGE_KEY]["minor_version"] == 4
|
|
assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == []
|
|
|
|
|
|
@pytest.mark.parametrize("load_registries", [False])
|
|
async def test_loading_child_device_with_missing_parent(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test a stored child device without its parent is dropped with an error."""
|
|
hass_storage[dr.STORAGE_KEY] = {
|
|
"version": dr.STORAGE_VERSION_MAJOR,
|
|
"minor_version": dr.STORAGE_VERSION_MINOR,
|
|
"key": dr.STORAGE_KEY,
|
|
"data": {
|
|
"devices": [],
|
|
"child_devices": [
|
|
{
|
|
"area_id": None,
|
|
"config_entry_id": mock_config_entry.entry_id,
|
|
"config_subentry_id": None,
|
|
"created_at": "2024-01-01T00:00:00+00:00",
|
|
"disabled_by": None,
|
|
"id": "childdeviceid",
|
|
"identifiers": [["test", "strip_outlet_1"]],
|
|
"labels": [],
|
|
"modified_at": "2024-01-01T00:00:00+00:00",
|
|
"name_by_user": None,
|
|
"name": "Outlet 1",
|
|
"parent_device_id": "missingparent",
|
|
}
|
|
],
|
|
"deleted_devices": [],
|
|
},
|
|
}
|
|
|
|
dr.async_setup(hass)
|
|
await dr.async_load(hass)
|
|
registry = dr.async_get(hass)
|
|
|
|
assert not registry.child_devices
|
|
assert "Dropping child device childdeviceid" in caplog.text
|
|
|
|
# The drop scheduled a save, so it persists instead of leaving the store dirty
|
|
# until an unrelated write
|
|
await flush_store(registry._store)
|
|
assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == []
|
|
|
|
|
|
async def test_effective_area_id(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test effective area resolution for devices and child devices."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
assert dr.async_get_effective_area_id(hass, parent) is None
|
|
assert dr.async_get_effective_area_id(hass, child_device) is None
|
|
|
|
# The child inherits the parent's area, resolved at read time
|
|
updated_parent = device_registry.async_update_device(parent.id, area_id="garage")
|
|
child_device = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert dr.async_get_effective_area_id(hass, child_device) == "garage"
|
|
|
|
# An explicitly set area overrides the inherited one
|
|
child_device = device_registry.async_update_child_device(
|
|
child_device.id, area_id="garden"
|
|
)
|
|
assert dr.async_get_effective_area_id(hass, child_device) == "garden"
|
|
|
|
# A parent area change is reflected immediately for inheriting children
|
|
child_device = device_registry.async_update_child_device(
|
|
child_device.id, area_id=None
|
|
)
|
|
device_registry.async_update_device(updated_parent.id, area_id="attic")
|
|
assert dr.async_get_effective_area_id(hass, child_device) == "attic"
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_entries_for_area_with_child_devices(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test area queries include child devices by effective area."""
|
|
parent, inheriting_child = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
overriding_child = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 2",
|
|
)
|
|
device_registry.async_update_device(parent.id, area_id="garage")
|
|
device_registry.async_update_child_device(overriding_child.id, area_id="garden")
|
|
|
|
parent = device_registry.async_get(parent.id)
|
|
inheriting_child = device_registry.async_get(
|
|
inheriting_child.id, include_main_devices=False
|
|
)
|
|
overriding_child = device_registry.async_get(
|
|
overriding_child.id, include_main_devices=False
|
|
)
|
|
|
|
assert dr.async_entries_for_area(device_registry, "garage") == [
|
|
parent,
|
|
inheriting_child,
|
|
]
|
|
assert dr.async_entries_for_area(device_registry, "garden") == [overriding_child]
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_clear_area_id_with_child_devices(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test deleting an area clears explicitly set child device areas."""
|
|
parent, inheriting_child = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_device(parent.id, area_id="garage")
|
|
device_registry.async_update_child_device(inheriting_child.id, area_id="garage")
|
|
|
|
device_registry.async_clear_area_id("garage")
|
|
|
|
updated_parent = device_registry.async_get(parent.id)
|
|
updated_child = device_registry.async_get(
|
|
inheriting_child.id, include_main_devices=False
|
|
)
|
|
assert updated_parent is not None
|
|
assert updated_child is not None
|
|
assert updated_parent.area_id is None
|
|
assert updated_child.area_id is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_clear_label_id_with_child_devices(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test deleting a label removes it from child devices."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_child_device(
|
|
child_device.id, labels={"outdoor", "xmas"}
|
|
)
|
|
|
|
device_registry.async_clear_label_id("xmas")
|
|
|
|
updated_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert updated_child is not None
|
|
assert updated_child.labels == {"outdoor"}
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_entries_for_label_includes_child_devices(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test label queries include child devices carrying the label."""
|
|
parent, labeled_child = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
unlabeled_child = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 2",
|
|
)
|
|
parent = device_registry.async_update_device(parent.id, labels={"label1"})
|
|
labeled_child = device_registry.async_update_child_device(
|
|
labeled_child.id, labels={"label1"}
|
|
)
|
|
|
|
entries = dr.async_entries_for_label(device_registry, "label1")
|
|
assert len(entries) == 2
|
|
assert parent in entries
|
|
assert labeled_child in entries
|
|
# Labels are never inherited, so a child without the label is excluded even though
|
|
# its parent carries it
|
|
assert unlabeled_child not in entries
|
|
|
|
|
|
async def test_async_cleanup_removes_child_device_with_missing_parent(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
device_registry: dr.DeviceRegistry,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test cleanup removes a child device whose parent is gone."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
# Simulate store corruption: drop the parent without the remove cascade
|
|
del device_registry._devices[parent.id]
|
|
|
|
dr.async_cleanup(hass, device_registry, entity_registry)
|
|
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_main_devices=False) is None
|
|
)
|
|
assert "Removing child device" in caplog.text
|
|
|
|
# The removal scheduled a save, so the drop persists instead of leaving the store
|
|
# dirty until an unrelated write
|
|
await flush_store(device_registry._store)
|
|
assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == []
|
|
|
|
|
|
@pytest.mark.parametrize("load_registries", [False])
|
|
async def test_async_cleanup_removes_child_device_with_stale_config_entry(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test cleanup removes a stored child device whose config entry no longer exists.
|
|
|
|
A child device shares its parent's config entry, but a corrupt store can pair a
|
|
valid parent with a child naming a config entry that no longer exists. Load only
|
|
guards against a missing parent, so the stale config entry is caught by cleanup.
|
|
"""
|
|
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": [],
|
|
"created_at": "2024-01-01T00:00:00+00:00",
|
|
"disabled_by": None,
|
|
"entry_type": None,
|
|
"hw_version": None,
|
|
"id": "parentdeviceid",
|
|
"identifiers": [["test", "strip"]],
|
|
"labels": [],
|
|
"manufacturer": None,
|
|
"model": None,
|
|
"model_id": None,
|
|
"modified_at": "2024-01-01T00:00:00+00:00",
|
|
"name_by_user": None,
|
|
"name": "Power strip",
|
|
"primary_config_entry": mock_config_entry.entry_id,
|
|
"serial_number": None,
|
|
"sw_version": None,
|
|
"via_device_id": None,
|
|
}
|
|
],
|
|
"child_devices": [
|
|
{
|
|
"area_id": None,
|
|
"config_entry_id": "stale-config-entry-id",
|
|
"config_subentry_id": None,
|
|
"created_at": "2024-01-01T00:00:00+00:00",
|
|
"disabled_by": None,
|
|
"id": "childdeviceid",
|
|
"identifiers": [["test", "strip_outlet_1"]],
|
|
"labels": [],
|
|
"modified_at": "2024-01-01T00:00:00+00:00",
|
|
"name_by_user": None,
|
|
"name": "Outlet 1",
|
|
"parent_device_id": "parentdeviceid",
|
|
}
|
|
],
|
|
"deleted_devices": [],
|
|
},
|
|
}
|
|
|
|
dr.async_setup(hass)
|
|
await dr.async_load(hass)
|
|
registry = dr.async_get(hass)
|
|
|
|
# The child loads because its parent is present; the stale config entry is only
|
|
# caught by the cleanup sweep below
|
|
assert registry.async_get("childdeviceid", include_main_devices=False) is not None
|
|
|
|
entity_registry = er.EntityRegistry(hass)
|
|
await entity_registry.async_load()
|
|
dr.async_cleanup(hass, registry, entity_registry)
|
|
|
|
assert registry.async_get("childdeviceid", include_main_devices=False) is None
|
|
assert "its config entry stale-config-entry-id no longer exists" in caplog.text
|
|
|
|
# The parent, on a valid config entry, is left untouched
|
|
assert registry.async_get("parentdeviceid") is not None
|
|
|
|
# The removal scheduled a save, so the drop persists
|
|
await flush_store(registry._store)
|
|
assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == []
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_device_info_with_connections_matching_child_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a device info with connections claiming a child's identifier raises.
|
|
|
|
Child device identifier collisions are always rejected, even for a stale child
|
|
and even when the device info carries connections.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
# A new setup session: the child device is stale, but is still not adopted
|
|
device_registry.async_config_entry_unloaded(mock_config_entry.entry_id)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="overlap with those of child 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={("test", "strip_outlet_1")},
|
|
name="Not an outlet",
|
|
)
|
|
|
|
# The rejection leaves the child device untouched and creates no main device
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_main_devices=False)
|
|
is child_device
|
|
)
|
|
assert len(device_registry.child_devices) == 1
|
|
assert len(device_registry.devices) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_live_child_device_identifier_collision_raises(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a device colliding with a live child device raises."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
hub = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "hub")},
|
|
name="Hub",
|
|
)
|
|
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
|
|
|
|
# A device matched by its own identifier that also claims a live child's identifier
|
|
# collides with the child and is rejected
|
|
with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "hub"), ("test", "strip_outlet_1")},
|
|
name="Hub",
|
|
)
|
|
|
|
# The raise precedes reconciliation, so nothing changed
|
|
unchanged_child = device_registry.async_get(child_device.id)
|
|
assert isinstance(unchanged_child, dr.ChildDeviceEntry)
|
|
assert unchanged_child is child_device
|
|
assert unchanged_child.parent_device_id == parent.id
|
|
assert unchanged_child.identifiers == {("test", "strip_outlet_1")}
|
|
assert device_registry.async_get(hub.id) is hub
|
|
assert len(device_registry.devices) == 2
|
|
assert len(device_registry.child_devices) == 1
|
|
await hass.async_block_till_done()
|
|
assert len(update_events) == 0
|
|
|
|
|
|
async def test_child_and_main_device_same_identifier_across_entries(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a child and a main device in different entries can share an identifier.
|
|
|
|
Identifiers are unique only within a config entry, so a child device in one entry
|
|
coexists with a main device of another entry sharing its identifier, each resolvable
|
|
in its own namespace.
|
|
"""
|
|
entry_a = mock_config_entry
|
|
_, child_device = _create_parent_and_child(device_registry, entry_a.entry_id)
|
|
|
|
entry_b = MockConfigEntry(title=None)
|
|
entry_b.add_to_hass(hass)
|
|
main_device = device_registry.async_get_or_create(
|
|
config_entry_id=entry_b.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
name="Standalone outlet",
|
|
)
|
|
|
|
assert isinstance(main_device, dr.DeviceEntry)
|
|
assert main_device.id != child_device.id
|
|
assert len(device_registry.devices) == 2
|
|
assert len(device_registry.child_devices) == 1
|
|
|
|
# The shared identifier resolves to the child in entry A and the main device in B
|
|
assert (
|
|
device_registry.async_get_child_device_by_identifier(
|
|
("test", "strip_outlet_1"), entry_a.entry_id
|
|
)
|
|
is child_device
|
|
)
|
|
assert (
|
|
device_registry.async_get_child_device_by_identifier(
|
|
("test", "strip_outlet_1"), entry_b.entry_id
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
device_registry.async_get_device_by_identifier(
|
|
("test", "strip_outlet_1"), entry_b.entry_id
|
|
)
|
|
is main_device
|
|
)
|
|
assert (
|
|
device_registry.async_get_device_by_identifier(
|
|
("test", "strip_outlet_1"), entry_a.entry_id
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_config_entry_compat_shims(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test the deprecated config-entry compatibility shims on a child device entry.
|
|
|
|
A child device is keyed by a single config entry and subentry; the deprecated
|
|
multi-entry shims inherited from BaseDeviceEntry report that single membership.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
assert child_device.config_entries == {mock_config_entry.entry_id}
|
|
assert child_device.config_entries_subentries == {
|
|
mock_config_entry.entry_id: {None}
|
|
}
|
|
assert child_device.primary_config_entry == mock_config_entry.entry_id
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_deleted_child_device_restored_as_device_clears_device_disable(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test restoring a device-disabled deleted child as a full device clears it.
|
|
|
|
A child disabled by its parent (DeviceEntryDisabler.DEVICE) keeps that disable as a
|
|
deleted device; restoring its identifiers as a full device, which has no parent,
|
|
drops the parent-device disable.
|
|
"""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
disabled_child = device_registry.async_get(
|
|
child_device.id, include_main_devices=False
|
|
)
|
|
assert disabled_child is not None
|
|
assert disabled_child.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
|
|
device_registry.async_remove_device(child_device.id)
|
|
|
|
restored_device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
manufacturer="acme",
|
|
name="Outlet 1",
|
|
)
|
|
assert isinstance(restored_device, dr.DeviceEntry)
|
|
assert restored_device.id == child_device.id
|
|
assert restored_device.disabled_by is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_deleted_device_disabled_restored_as_child_rederives_disable(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a device-disabled deleted device re-derives its disable when restored.
|
|
|
|
The stored DeviceEntryDisabler.DEVICE is recomputed from the (now-enabled) parent
|
|
on restore, so the restored child is enabled.
|
|
"""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
device_registry.async_remove_device(child_device.id)
|
|
device_registry.async_update_device(parent.id, disabled_by=None)
|
|
|
|
restored = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert isinstance(restored, dr.ChildDeviceEntry)
|
|
assert restored.id == child_device.id
|
|
assert restored.disabled_by is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_deleted_device_restored_as_child_of_disabled_parent(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test restoring a deleted device as a child of a disabled parent disables it."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_remove_device(child_device.id)
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
|
|
restored = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert restored.id == child_device.id
|
|
assert restored.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
|
|
|
|
async def test_deleted_device_restored_as_child_under_disabled_config_entry(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test restoring a deleted device as a child under a disabled config entry."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry.async_remove_device(child_device.id)
|
|
|
|
await hass.config_entries.async_set_disabled_by(
|
|
mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
restored = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert restored.id == child_device.id
|
|
assert restored.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
|
|
|
|
async def test_config_entry_disabled_deleted_device_restored_as_child(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a config-entry-disabled deleted device restores as an enabled child.
|
|
|
|
The stored DeviceEntryDisabler.CONFIG_ENTRY is cleared because the config entry is
|
|
enabled again by the time the device restores as a child.
|
|
"""
|
|
await hass.config_entries.async_set_disabled_by(
|
|
mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER
|
|
)
|
|
await hass.async_block_till_done()
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
manufacturer="acme",
|
|
name="Outlet 1",
|
|
)
|
|
assert device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
device_registry.async_remove_device(device.id)
|
|
|
|
await hass.config_entries.async_set_disabled_by(mock_config_entry.entry_id, None)
|
|
await hass.async_block_till_done()
|
|
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
restored = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert restored.id == device.id
|
|
assert restored.disabled_by is None
|
|
|
|
|
|
@pytest.mark.parametrize("load_registries", [False])
|
|
async def test_legacy_undefined_disabled_deleted_device_restored_as_child(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test restoring a legacy deleted device (disabled_by undefined) as a child.
|
|
|
|
A deleted device stored before disabled_by was tracked loads with an undefined
|
|
disable; restoring it as a child resolves that to None.
|
|
"""
|
|
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": [],
|
|
"created_at": "2024-01-01T00:00:00+00:00",
|
|
"disabled_by": None,
|
|
"entry_type": None,
|
|
"hw_version": None,
|
|
"id": "parentdeviceid",
|
|
"identifiers": [["test", "strip"]],
|
|
"labels": [],
|
|
"manufacturer": None,
|
|
"model": None,
|
|
"model_id": None,
|
|
"modified_at": "2024-01-01T00:00:00+00:00",
|
|
"name_by_user": None,
|
|
"name": "Power strip",
|
|
"primary_config_entry": mock_config_entry.entry_id,
|
|
"serial_number": None,
|
|
"sw_version": None,
|
|
"via_device_id": None,
|
|
}
|
|
],
|
|
"child_devices": [],
|
|
"deleted_devices": [
|
|
{
|
|
"area_id": None,
|
|
"config_entry_id": mock_config_entry.entry_id,
|
|
"config_subentry_id": None,
|
|
"connections": [],
|
|
"created_at": "2024-01-01T00:00:00+00:00",
|
|
"disabled_by": None,
|
|
"disabled_by_undefined": True,
|
|
"id": "outletdeviceid",
|
|
"identifiers": [["test", "strip_outlet_1"]],
|
|
"labels": [],
|
|
"modified_at": "2024-01-01T00:00:00+00:00",
|
|
"name_by_user": None,
|
|
"orphaned_timestamp": None,
|
|
"domain": None,
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
dr.async_setup(hass)
|
|
await dr.async_load(hass)
|
|
registry = dr.async_get(hass)
|
|
|
|
restored = registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id="parentdeviceid",
|
|
name="Outlet 1",
|
|
)
|
|
assert isinstance(restored, dr.ChildDeviceEntry)
|
|
assert restored.id == "outletdeviceid"
|
|
assert restored.disabled_by is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_convert_device_to_child_subentry_mismatch(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry_with_subentries: MockConfigEntry,
|
|
) -> None:
|
|
"""Test converting a device to a child rejects a config subentry mismatch."""
|
|
entry_id = mock_config_entry_with_subentries.entry_id
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-1",
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-2",
|
|
identifiers={("test", "strip_outlet_1")},
|
|
name="Outlet 1",
|
|
)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="same config subentry"):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-1",
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
|
|
# The conversion guard runs before any reconcile, so the device is untouched
|
|
assert device_registry.async_get(device.id) is device
|
|
assert not device_registry.child_devices
|
|
|
|
|
|
async def test_convert_device_with_composite_identifiers_to_child(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test converting a composite-split device to a child replaces its identifiers.
|
|
|
|
Identifiers copied from a pre-migration composite are replaced, not merged, so the
|
|
extra composite identifier is dropped.
|
|
"""
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_1_stale")},
|
|
name="Outlet 1",
|
|
via_device_id=parent.id,
|
|
)
|
|
device_registry._async_update_device(device.id, has_composite_identifiers=True)
|
|
device_registry.async_config_entry_unloaded(mock_config_entry.entry_id)
|
|
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
|
|
|
|
converted = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert isinstance(converted, dr.ChildDeviceEntry)
|
|
assert converted.id == device.id
|
|
assert converted.identifiers == {("test", "strip_outlet_1")}
|
|
|
|
await hass.async_block_till_done()
|
|
# The conversion reports the replaced identifiers as the old value
|
|
assert update_events[0].data["changes"]["identifiers"] == {
|
|
("test", "strip_outlet_1"),
|
|
("test", "strip_outlet_1_stale"),
|
|
}
|
|
|
|
|
|
async def test_convert_device_to_child_of_disabled_parent(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test converting a device to a child of a disabled parent disables the child."""
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
device_registry.async_update_device(
|
|
parent.id, disabled_by=dr.DeviceEntryDisabler.USER
|
|
)
|
|
device = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
name="Outlet 1",
|
|
via_device_id=parent.id,
|
|
)
|
|
assert device.disabled_by is None
|
|
device_registry.async_config_entry_unloaded(mock_config_entry.entry_id)
|
|
update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED)
|
|
|
|
converted = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert isinstance(converted, dr.ChildDeviceEntry)
|
|
assert converted.id == device.id
|
|
assert converted.disabled_by is dr.DeviceEntryDisabler.DEVICE
|
|
|
|
await hass.async_block_till_done()
|
|
assert update_events[0].data["changes"]["disabled_by"] is None
|
|
|
|
|
|
async def test_move_parent_with_pending_move_and_children_rejected(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test completing a deferred move of a parent with child devices is rejected."""
|
|
other_entry = MockConfigEntry(title=None)
|
|
other_entry.add_to_hass(hass)
|
|
parent, _ = _create_parent_and_child(device_registry, mock_config_entry.entry_id)
|
|
|
|
# Arm a deferred move of the parent to another config entry
|
|
device_registry._async_update_device(
|
|
parent.id, add_config_entry_id=other_entry.entry_id
|
|
)
|
|
|
|
# Completing the pending move by removing the current owner is rejected
|
|
with pytest.raises(HomeAssistantError, match="has child devices"):
|
|
device_registry._async_update_device(
|
|
parent.id, remove_config_entry_id=mock_config_entry.entry_id
|
|
)
|
|
|
|
unchanged_parent = device_registry.async_get(parent.id)
|
|
assert unchanged_parent is not None
|
|
assert unchanged_parent.config_entry_id == mock_config_entry.entry_id
|
|
assert len(device_registry.child_devices) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_update_child_device_both_identifier_args_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test defining both merge_identifiers and new_identifiers is rejected.
|
|
|
|
Only the internal update path can pass both, so the private method is exercised.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError,
|
|
match="Cannot define both merge_identifiers and new_identifiers",
|
|
):
|
|
device_registry._async_update_child_device(
|
|
child_device.id,
|
|
merge_identifiers={("test", "a")},
|
|
new_identifiers={("test", "b")},
|
|
)
|
|
|
|
|
|
async def test_update_child_disabled_by_none_on_disabled_config_entry_reports(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test enabling a child of a disabled config entry is ignored and reported."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
await hass.config_entries.async_set_disabled_by(
|
|
mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
caplog.clear()
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id, disabled_by=None
|
|
)
|
|
assert updated.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
assert (
|
|
"sets disabled_by to None on a child device belonging to the disabled "
|
|
"config entry" in caplog.text
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_update_child_disabled_by_config_entry_on_enabled_entry_reports(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test CONFIG_ENTRY disabling a child of an enabled config entry is ignored."""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
|
|
updated = device_registry.async_update_child_device(
|
|
child_device.id, disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
)
|
|
assert updated.disabled_by is None
|
|
assert (
|
|
"sets disabled_by to DeviceEntryDisabler.CONFIG_ENTRY on a child device "
|
|
"belonging to the enabled config entry" in caplog.text
|
|
)
|
|
|
|
|
|
async def test_create_child_device_under_disabled_config_entry(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a child device created under a disabled config entry is disabled by it."""
|
|
await hass.config_entries.async_set_disabled_by(
|
|
mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
parent = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip")},
|
|
name="Power strip",
|
|
)
|
|
assert parent.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
child_device = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 1",
|
|
)
|
|
assert child_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_recreate_child_clears_stale_config_entry_disable(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test the create reconciliation clears a stale CONFIG_ENTRY disable.
|
|
|
|
A child carrying a CONFIG_ENTRY disable while its config entry is enabled is an
|
|
inconsistent leftover; the create-time (is_new) reconciliation clears it. The state
|
|
is only reachable internally, so the private update path is exercised.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
device_registry._child_devices[child_device.id] = attr.evolve(
|
|
device_registry.async_get(child_device.id, include_main_devices=False),
|
|
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
|
|
)
|
|
|
|
result = device_registry._async_update_child_device(
|
|
child_device.id, is_new=True, merge_identifiers=child_device.identifiers
|
|
)
|
|
assert result is not None
|
|
assert result.disabled_by is None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_update_child_identifiers_purges_colliding_deleted_device(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test adding an identifier to a child purges a colliding deleted device.
|
|
|
|
A deleted device holding an identity the child now owns can never restore, so it is
|
|
dropped.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
ghost = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "ghost")},
|
|
name="Ghost",
|
|
)
|
|
device_registry.async_remove_device(ghost.id)
|
|
assert ghost.id in device_registry._deleted_devices
|
|
|
|
device_registry.async_update_child_device(
|
|
child_device.id,
|
|
new_identifiers={("test", "strip_outlet_1"), ("test", "ghost")},
|
|
)
|
|
assert ghost.id not in device_registry._deleted_devices
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_child_device_identifier_collision_with_other_child(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a child device can't take an identifier registered by another child."""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
other_child = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 2",
|
|
)
|
|
|
|
with pytest.raises(dr.DeviceIdentifierCollisionError):
|
|
device_registry.async_update_child_device(
|
|
child_device.id, new_identifiers={("test", "strip_outlet_2")}
|
|
)
|
|
# The rejected update leaves both children unchanged
|
|
assert (
|
|
device_registry.async_get(other_child.id, include_main_devices=False)
|
|
is other_child
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_stale_child_device_identifier_collision_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a device claiming a stale child's identifier raises.
|
|
|
|
Child device identifier collisions are rejected regardless of whether the child
|
|
was registered this setup session; stale children are never stripped or removed.
|
|
"""
|
|
_, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
hub = device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "hub")},
|
|
name="Hub",
|
|
)
|
|
# A new setup session: every device of the config entry is now stale
|
|
device_registry.async_config_entry_unloaded(mock_config_entry.entry_id)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"):
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "hub"), ("test", "strip_outlet_1")},
|
|
name="Hub",
|
|
)
|
|
|
|
# The rejection leaves the child device and the hub untouched
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_main_devices=False)
|
|
is child_device
|
|
)
|
|
assert child_device.identifiers == {("test", "strip_outlet_1")}
|
|
assert device_registry.async_get(hub.id) is hub
|
|
assert hub.identifiers == {("test", "hub")}
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_get_or_create_child_identifier_owned_by_other_child_raises(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a child registration claiming another child's identifier raises.
|
|
|
|
A registration spanning the identifiers of two children is rejected instead of
|
|
merging them, even when the children are stale.
|
|
"""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
other_child = device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Outlet 2",
|
|
)
|
|
# A new setup session: both children are stale
|
|
device_registry.async_config_entry_unloaded(mock_config_entry.entry_id)
|
|
|
|
with pytest.raises(dr.DeviceInfoError, match="already registered for child"):
|
|
device_registry.async_get_or_create_child(
|
|
config_entry_id=mock_config_entry.entry_id,
|
|
identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_2")},
|
|
parent_device_id=parent.id,
|
|
name="Merged outlet",
|
|
)
|
|
|
|
# The rejection leaves both children untouched
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_main_devices=False)
|
|
is child_device
|
|
)
|
|
assert (
|
|
device_registry.async_get(other_child.id, include_main_devices=False)
|
|
is other_child
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_clear_config_entry_removes_orphaned_child_device(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test clearing a config entry removes a child device orphaned by corruption.
|
|
|
|
A child always shares its parent's config entry, so the parent cascade removes it;
|
|
this defensive sweep only fires for a corrupt store. Deleting the parent from the
|
|
live registry without the cascade reproduces that state.
|
|
"""
|
|
parent, child_device = _create_parent_and_child(
|
|
device_registry, mock_config_entry.entry_id
|
|
)
|
|
del device_registry._devices[parent.id]
|
|
|
|
device_registry.async_clear_config_entry(mock_config_entry.entry_id)
|
|
|
|
assert (
|
|
device_registry.async_get(child_device.id, include_main_devices=False) is None
|
|
)
|
|
assert not device_registry.child_devices
|
|
|
|
|
|
@pytest.mark.usefixtures("hass")
|
|
async def test_clear_config_subentry_removes_orphaned_child_device(
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_config_entry_with_subentries: MockConfigEntry,
|
|
) -> None:
|
|
"""Test clearing a config subentry removes only that subentry's orphaned children.
|
|
|
|
A child always shares its parent's subentry, so the parent cascade removes it; this
|
|
defensive sweep only fires for a corrupt store, and skips children in another
|
|
subentry. Deleting the parents from the live registry reproduces that state.
|
|
"""
|
|
entry_id = mock_config_entry_with_subentries.entry_id
|
|
parent_1 = device_registry.async_get_or_create(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-1",
|
|
identifiers={("test", "strip_1")},
|
|
name="Strip 1",
|
|
)
|
|
child_1 = device_registry.async_get_or_create_child(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-1",
|
|
identifiers={("test", "outlet_1")},
|
|
parent_device_id=parent_1.id,
|
|
name="Outlet 1",
|
|
)
|
|
parent_2 = device_registry.async_get_or_create(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-2",
|
|
identifiers={("test", "strip_2")},
|
|
name="Strip 2",
|
|
)
|
|
child_2 = device_registry.async_get_or_create_child(
|
|
config_entry_id=entry_id,
|
|
config_subentry_id="mock-subentry-id-1-2",
|
|
identifiers={("test", "outlet_2")},
|
|
parent_device_id=parent_2.id,
|
|
name="Outlet 2",
|
|
)
|
|
del device_registry._devices[parent_1.id]
|
|
del device_registry._devices[parent_2.id]
|
|
|
|
device_registry.async_clear_config_subentry(entry_id, "mock-subentry-id-1-1")
|
|
|
|
# The child in the cleared subentry is removed, the one in the other subentry kept
|
|
assert device_registry.async_get(child_1.id, include_main_devices=False) is None
|
|
assert device_registry.async_get(child_2.id, include_main_devices=False) is not None
|