From 40f553a0070d0ad1af405e0caa889f0a0eab11ba Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:33:34 +0200 Subject: [PATCH] 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 --- homeassistant/helpers/device_registry.py | 93 +++++++++------ tests/helpers/test_device_registry.py | 141 +++++++++++++++++++++++ 2 files changed, 201 insertions(+), 33 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index a6313381492..bad772abaff 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -56,7 +56,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event ) STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 10 +STORAGE_VERSION_MINOR = 11 CLEANUP_DELAY = 10 @@ -266,6 +266,48 @@ def _validate_configuration_url(value: Any) -> str | None: 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) class DeviceEntry: """Device Registry Entry.""" @@ -274,7 +316,9 @@ class DeviceEntry: config_entries: set[str] = attr.ib(converter=set, factory=set) config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) 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) disabled_by: DeviceEntryDisabler | 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() config_entries: set[str] = 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() disabled_by: DeviceEntryDisabler | None = 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]]]]): """Store entity registry data.""" - async def _async_migrate_func( + async def _async_migrate_func( # noqa: C901 self, old_major_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["labels"] = [] 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: raise NotImplementedError @@ -1696,11 +1731,3 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: debounced_cleanup.async_cancel() 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 - } diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index c8ec83934ac..58933ca4314 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -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( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -4753,3 +4888,9 @@ async def test_update_device_no_connections_or_identifiers( 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(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")})