mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Migrate device connections to a normalized form (#140383)
* Normalize device connections migration * Update version * Slightly improve tests * Update homeassistant/helpers/device_registry.py * Add validators * Fix validator * Move format mac function too * Add validator test --------- Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
parent
bc46894b74
commit
40f553a007
@ -56,7 +56,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event
|
|||||||
)
|
)
|
||||||
STORAGE_KEY = "core.device_registry"
|
STORAGE_KEY = "core.device_registry"
|
||||||
STORAGE_VERSION_MAJOR = 1
|
STORAGE_VERSION_MAJOR = 1
|
||||||
STORAGE_VERSION_MINOR = 10
|
STORAGE_VERSION_MINOR = 11
|
||||||
|
|
||||||
CLEANUP_DELAY = 10
|
CLEANUP_DELAY = 10
|
||||||
|
|
||||||
@ -266,6 +266,48 @@ def _validate_configuration_url(value: Any) -> str | None:
|
|||||||
return url_as_str
|
return url_as_str
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def format_mac(mac: str) -> str:
|
||||||
|
"""Format the mac address string for entry into dev reg."""
|
||||||
|
to_test = mac
|
||||||
|
|
||||||
|
if len(to_test) == 17 and to_test.count(":") == 5:
|
||||||
|
return to_test.lower()
|
||||||
|
|
||||||
|
if len(to_test) == 17 and to_test.count("-") == 5:
|
||||||
|
to_test = to_test.replace("-", "")
|
||||||
|
elif len(to_test) == 14 and to_test.count(".") == 2:
|
||||||
|
to_test = to_test.replace(".", "")
|
||||||
|
|
||||||
|
if len(to_test) == 12:
|
||||||
|
# no : included
|
||||||
|
return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2))
|
||||||
|
|
||||||
|
# Not sure how formatted, return original
|
||||||
|
return mac
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_connections(
|
||||||
|
connections: Iterable[tuple[str, str]],
|
||||||
|
) -> set[tuple[str, str]]:
|
||||||
|
"""Normalize connections to ensure we can match mac addresses."""
|
||||||
|
return {
|
||||||
|
(key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
|
||||||
|
for key, value in connections
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_connections_validator(
|
||||||
|
instance: Any,
|
||||||
|
attribute: Any,
|
||||||
|
connections: Iterable[tuple[str, str]],
|
||||||
|
) -> None:
|
||||||
|
"""Check connections normalization used as attrs validator."""
|
||||||
|
for key, value in connections:
|
||||||
|
if key == CONNECTION_NETWORK_MAC and format_mac(value) != value:
|
||||||
|
raise ValueError(f"Invalid mac address format: {value}")
|
||||||
|
|
||||||
|
|
||||||
@attr.s(frozen=True, slots=True)
|
@attr.s(frozen=True, slots=True)
|
||||||
class DeviceEntry:
|
class DeviceEntry:
|
||||||
"""Device Registry Entry."""
|
"""Device Registry Entry."""
|
||||||
@ -274,7 +316,9 @@ class DeviceEntry:
|
|||||||
config_entries: set[str] = attr.ib(converter=set, factory=set)
|
config_entries: set[str] = attr.ib(converter=set, factory=set)
|
||||||
config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict)
|
config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict)
|
||||||
configuration_url: str | None = attr.ib(default=None)
|
configuration_url: str | None = attr.ib(default=None)
|
||||||
connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
|
connections: set[tuple[str, str]] = attr.ib(
|
||||||
|
converter=set, factory=set, validator=_normalize_connections_validator
|
||||||
|
)
|
||||||
created_at: datetime = attr.ib(factory=utcnow)
|
created_at: datetime = attr.ib(factory=utcnow)
|
||||||
disabled_by: DeviceEntryDisabler | None = attr.ib(default=None)
|
disabled_by: DeviceEntryDisabler | None = attr.ib(default=None)
|
||||||
entry_type: DeviceEntryType | None = attr.ib(default=None)
|
entry_type: DeviceEntryType | None = attr.ib(default=None)
|
||||||
@ -397,7 +441,9 @@ class DeletedDeviceEntry:
|
|||||||
area_id: str | None = attr.ib()
|
area_id: str | None = attr.ib()
|
||||||
config_entries: set[str] = attr.ib()
|
config_entries: set[str] = attr.ib()
|
||||||
config_entries_subentries: dict[str, set[str | None]] = attr.ib()
|
config_entries_subentries: dict[str, set[str | None]] = attr.ib()
|
||||||
connections: set[tuple[str, str]] = attr.ib()
|
connections: set[tuple[str, str]] = attr.ib(
|
||||||
|
validator=_normalize_connections_validator
|
||||||
|
)
|
||||||
created_at: datetime = attr.ib()
|
created_at: datetime = attr.ib()
|
||||||
disabled_by: DeviceEntryDisabler | None = attr.ib()
|
disabled_by: DeviceEntryDisabler | None = attr.ib()
|
||||||
id: str = attr.ib()
|
id: str = attr.ib()
|
||||||
@ -459,31 +505,10 @@ class DeletedDeviceEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=512)
|
|
||||||
def format_mac(mac: str) -> str:
|
|
||||||
"""Format the mac address string for entry into dev reg."""
|
|
||||||
to_test = mac
|
|
||||||
|
|
||||||
if len(to_test) == 17 and to_test.count(":") == 5:
|
|
||||||
return to_test.lower()
|
|
||||||
|
|
||||||
if len(to_test) == 17 and to_test.count("-") == 5:
|
|
||||||
to_test = to_test.replace("-", "")
|
|
||||||
elif len(to_test) == 14 and to_test.count(".") == 2:
|
|
||||||
to_test = to_test.replace(".", "")
|
|
||||||
|
|
||||||
if len(to_test) == 12:
|
|
||||||
# no : included
|
|
||||||
return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2))
|
|
||||||
|
|
||||||
# Not sure how formatted, return original
|
|
||||||
return mac
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
||||||
"""Store entity registry data."""
|
"""Store entity registry data."""
|
||||||
|
|
||||||
async def _async_migrate_func(
|
async def _async_migrate_func( # noqa: C901
|
||||||
self,
|
self,
|
||||||
old_major_version: int,
|
old_major_version: int,
|
||||||
old_minor_version: int,
|
old_minor_version: int,
|
||||||
@ -559,6 +584,16 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
|||||||
device["disabled_by"] = None
|
device["disabled_by"] = None
|
||||||
device["labels"] = []
|
device["labels"] = []
|
||||||
device["name_by_user"] = None
|
device["name_by_user"] = None
|
||||||
|
if old_minor_version < 11:
|
||||||
|
# Normalization of stored CONNECTION_NETWORK_MAC, introduced in 2025.8
|
||||||
|
for device in old_data["devices"]:
|
||||||
|
device["connections"] = _normalize_connections(
|
||||||
|
device["connections"]
|
||||||
|
)
|
||||||
|
for device in old_data["deleted_devices"]:
|
||||||
|
device["connections"] = _normalize_connections(
|
||||||
|
device["connections"]
|
||||||
|
)
|
||||||
|
|
||||||
if old_major_version > 2:
|
if old_major_version > 2:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@ -1696,11 +1731,3 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None:
|
|||||||
debounced_cleanup.async_cancel()
|
debounced_cleanup.async_cancel()
|
||||||
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]:
|
|
||||||
"""Normalize connections to ensure we can match mac addresses."""
|
|
||||||
return {
|
|
||||||
(key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
|
|
||||||
for key, value in connections
|
|
||||||
}
|
|
||||||
|
@ -1432,6 +1432,141 @@ async def test_migration_from_1_7(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await dr.async_load(hass)
|
||||||
|
registry = dr.async_get(hass)
|
||||||
|
|
||||||
|
# Test data was loaded
|
||||||
|
entry = registry.async_get_or_create(
|
||||||
|
config_entry_id=mock_config_entry.entry_id,
|
||||||
|
identifiers={("serial", "123456ABCDEF")},
|
||||||
|
)
|
||||||
|
assert entry.id == "abcdefghijklm"
|
||||||
|
deleted_entry = registry.deleted_devices.get_entry(
|
||||||
|
connections=set(),
|
||||||
|
identifiers={("serial", "123456ABCDAB")},
|
||||||
|
)
|
||||||
|
assert deleted_entry.id == "abcdefghijklm2"
|
||||||
|
|
||||||
|
# Update to trigger a store
|
||||||
|
entry = registry.async_get_or_create(
|
||||||
|
config_entry_id=mock_config_entry.entry_id,
|
||||||
|
identifiers={("serial", "123456ABCDEF")},
|
||||||
|
sw_version="new_version",
|
||||||
|
)
|
||||||
|
assert entry.id == "abcdefghijklm"
|
||||||
|
|
||||||
|
# Check we store migrated data
|
||||||
|
await flush_store(registry._store)
|
||||||
|
|
||||||
|
assert hass_storage[dr.STORAGE_KEY] == {
|
||||||
|
"version": dr.STORAGE_VERSION_MAJOR,
|
||||||
|
"minor_version": dr.STORAGE_VERSION_MINOR,
|
||||||
|
"key": dr.STORAGE_KEY,
|
||||||
|
"data": {
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"area_id": None,
|
||||||
|
"config_entries": [mock_config_entry.entry_id],
|
||||||
|
"config_entries_subentries": {mock_config_entry.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": "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", "12:34:56:ab:cd:ab"]],
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_removing_config_entries(
|
async def test_removing_config_entries(
|
||||||
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -4753,3 +4888,9 @@ async def test_update_device_no_connections_or_identifiers(
|
|||||||
device_registry.async_update_device(
|
device_registry.async_update_device(
|
||||||
device.id, new_connections=set(), new_identifiers=set()
|
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(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user