Skip to content

Commit 40f553a

Browse files
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 <[email protected]>
1 parent bc46894 commit 40f553a

File tree

2 files changed

+201
-33
lines changed

2 files changed

+201
-33
lines changed

homeassistant/helpers/device_registry.py

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
)
5757
STORAGE_KEY = "core.device_registry"
5858
STORAGE_VERSION_MAJOR = 1
59-
STORAGE_VERSION_MINOR = 10
59+
STORAGE_VERSION_MINOR = 11
6060

6161
CLEANUP_DELAY = 10
6262

@@ -266,6 +266,48 @@ def _validate_configuration_url(value: Any) -> str | None:
266266
return url_as_str
267267

268268

269+
@lru_cache(maxsize=512)
270+
def format_mac(mac: str) -> str:
271+
"""Format the mac address string for entry into dev reg."""
272+
to_test = mac
273+
274+
if len(to_test) == 17 and to_test.count(":") == 5:
275+
return to_test.lower()
276+
277+
if len(to_test) == 17 and to_test.count("-") == 5:
278+
to_test = to_test.replace("-", "")
279+
elif len(to_test) == 14 and to_test.count(".") == 2:
280+
to_test = to_test.replace(".", "")
281+
282+
if len(to_test) == 12:
283+
# no : included
284+
return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2))
285+
286+
# Not sure how formatted, return original
287+
return mac
288+
289+
290+
def _normalize_connections(
291+
connections: Iterable[tuple[str, str]],
292+
) -> set[tuple[str, str]]:
293+
"""Normalize connections to ensure we can match mac addresses."""
294+
return {
295+
(key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
296+
for key, value in connections
297+
}
298+
299+
300+
def _normalize_connections_validator(
301+
instance: Any,
302+
attribute: Any,
303+
connections: Iterable[tuple[str, str]],
304+
) -> None:
305+
"""Check connections normalization used as attrs validator."""
306+
for key, value in connections:
307+
if key == CONNECTION_NETWORK_MAC and format_mac(value) != value:
308+
raise ValueError(f"Invalid mac address format: {value}")
309+
310+
269311
@attr.s(frozen=True, slots=True)
270312
class DeviceEntry:
271313
"""Device Registry Entry."""
@@ -274,7 +316,9 @@ class DeviceEntry:
274316
config_entries: set[str] = attr.ib(converter=set, factory=set)
275317
config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict)
276318
configuration_url: str | None = attr.ib(default=None)
277-
connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
319+
connections: set[tuple[str, str]] = attr.ib(
320+
converter=set, factory=set, validator=_normalize_connections_validator
321+
)
278322
created_at: datetime = attr.ib(factory=utcnow)
279323
disabled_by: DeviceEntryDisabler | None = attr.ib(default=None)
280324
entry_type: DeviceEntryType | None = attr.ib(default=None)
@@ -397,7 +441,9 @@ class DeletedDeviceEntry:
397441
area_id: str | None = attr.ib()
398442
config_entries: set[str] = attr.ib()
399443
config_entries_subentries: dict[str, set[str | None]] = attr.ib()
400-
connections: set[tuple[str, str]] = attr.ib()
444+
connections: set[tuple[str, str]] = attr.ib(
445+
validator=_normalize_connections_validator
446+
)
401447
created_at: datetime = attr.ib()
402448
disabled_by: DeviceEntryDisabler | None = attr.ib()
403449
id: str = attr.ib()
@@ -459,31 +505,10 @@ def as_storage_fragment(self) -> json_fragment:
459505
)
460506

461507

462-
@lru_cache(maxsize=512)
463-
def format_mac(mac: str) -> str:
464-
"""Format the mac address string for entry into dev reg."""
465-
to_test = mac
466-
467-
if len(to_test) == 17 and to_test.count(":") == 5:
468-
return to_test.lower()
469-
470-
if len(to_test) == 17 and to_test.count("-") == 5:
471-
to_test = to_test.replace("-", "")
472-
elif len(to_test) == 14 and to_test.count(".") == 2:
473-
to_test = to_test.replace(".", "")
474-
475-
if len(to_test) == 12:
476-
# no : included
477-
return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2))
478-
479-
# Not sure how formatted, return original
480-
return mac
481-
482-
483508
class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
484509
"""Store entity registry data."""
485510

486-
async def _async_migrate_func(
511+
async def _async_migrate_func( # noqa: C901
487512
self,
488513
old_major_version: int,
489514
old_minor_version: int,
@@ -559,6 +584,16 @@ async def _async_migrate_func(
559584
device["disabled_by"] = None
560585
device["labels"] = []
561586
device["name_by_user"] = None
587+
if old_minor_version < 11:
588+
# Normalization of stored CONNECTION_NETWORK_MAC, introduced in 2025.8
589+
for device in old_data["devices"]:
590+
device["connections"] = _normalize_connections(
591+
device["connections"]
592+
)
593+
for device in old_data["deleted_devices"]:
594+
device["connections"] = _normalize_connections(
595+
device["connections"]
596+
)
562597

563598
if old_major_version > 2:
564599
raise NotImplementedError
@@ -1696,11 +1731,3 @@ def _on_homeassistant_stop(event: Event) -> None:
16961731
debounced_cleanup.async_cancel()
16971732

16981733
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop)
1699-
1700-
1701-
def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]:
1702-
"""Normalize connections to ensure we can match mac addresses."""
1703-
return {
1704-
(key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
1705-
for key, value in connections
1706-
}

tests/helpers/test_device_registry.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,6 +1432,141 @@ async def test_migration_from_1_7(
14321432
}
14331433

14341434

1435+
@pytest.mark.parametrize("load_registries", [False])
1436+
@pytest.mark.usefixtures("freezer")
1437+
async def test_migration_from_1_10(
1438+
hass: HomeAssistant,
1439+
hass_storage: dict[str, Any],
1440+
mock_config_entry: MockConfigEntry,
1441+
) -> None:
1442+
"""Test migration from version 1.10."""
1443+
hass_storage[dr.STORAGE_KEY] = {
1444+
"version": 1,
1445+
"minor_version": 10,
1446+
"key": dr.STORAGE_KEY,
1447+
"data": {
1448+
"devices": [
1449+
{
1450+
"area_id": None,
1451+
"config_entries": [mock_config_entry.entry_id],
1452+
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
1453+
"configuration_url": None,
1454+
"connections": [["mac", "123456ABCDEF"]],
1455+
"created_at": "1970-01-01T00:00:00+00:00",
1456+
"disabled_by": None,
1457+
"entry_type": "service",
1458+
"hw_version": "hw_version",
1459+
"id": "abcdefghijklm",
1460+
"identifiers": [["serial", "123456ABCDEF"]],
1461+
"labels": ["blah"],
1462+
"manufacturer": "manufacturer",
1463+
"model": "model",
1464+
"name": "name",
1465+
"model_id": None,
1466+
"modified_at": "1970-01-01T00:00:00+00:00",
1467+
"name_by_user": None,
1468+
"primary_config_entry": mock_config_entry.entry_id,
1469+
"serial_number": None,
1470+
"sw_version": "new_version",
1471+
"via_device_id": None,
1472+
},
1473+
],
1474+
"deleted_devices": [
1475+
{
1476+
"area_id": None,
1477+
"config_entries": ["234567"],
1478+
"config_entries_subentries": {"234567": [None]},
1479+
"connections": [["mac", "123456ABCDAB"]],
1480+
"created_at": "1970-01-01T00:00:00+00:00",
1481+
"disabled_by": None,
1482+
"id": "abcdefghijklm2",
1483+
"identifiers": [["serial", "123456ABCDAB"]],
1484+
"labels": [],
1485+
"modified_at": "1970-01-01T00:00:00+00:00",
1486+
"name_by_user": None,
1487+
"orphaned_timestamp": "1970-01-01T00:00:00+00:00",
1488+
},
1489+
],
1490+
},
1491+
}
1492+
1493+
await dr.async_load(hass)
1494+
registry = dr.async_get(hass)
1495+
1496+
# Test data was loaded
1497+
entry = registry.async_get_or_create(
1498+
config_entry_id=mock_config_entry.entry_id,
1499+
identifiers={("serial", "123456ABCDEF")},
1500+
)
1501+
assert entry.id == "abcdefghijklm"
1502+
deleted_entry = registry.deleted_devices.get_entry(
1503+
connections=set(),
1504+
identifiers={("serial", "123456ABCDAB")},
1505+
)
1506+
assert deleted_entry.id == "abcdefghijklm2"
1507+
1508+
# Update to trigger a store
1509+
entry = registry.async_get_or_create(
1510+
config_entry_id=mock_config_entry.entry_id,
1511+
identifiers={("serial", "123456ABCDEF")},
1512+
sw_version="new_version",
1513+
)
1514+
assert entry.id == "abcdefghijklm"
1515+
1516+
# Check we store migrated data
1517+
await flush_store(registry._store)
1518+
1519+
assert hass_storage[dr.STORAGE_KEY] == {
1520+
"version": dr.STORAGE_VERSION_MAJOR,
1521+
"minor_version": dr.STORAGE_VERSION_MINOR,
1522+
"key": dr.STORAGE_KEY,
1523+
"data": {
1524+
"devices": [
1525+
{
1526+
"area_id": None,
1527+
"config_entries": [mock_config_entry.entry_id],
1528+
"config_entries_subentries": {mock_config_entry.entry_id: [None]},
1529+
"configuration_url": None,
1530+
"connections": [["mac", "12:34:56:ab:cd:ef"]],
1531+
"created_at": "1970-01-01T00:00:00+00:00",
1532+
"disabled_by": None,
1533+
"entry_type": "service",
1534+
"hw_version": "hw_version",
1535+
"id": "abcdefghijklm",
1536+
"identifiers": [["serial", "123456ABCDEF"]],
1537+
"labels": ["blah"],
1538+
"manufacturer": "manufacturer",
1539+
"model": "model",
1540+
"name": "name",
1541+
"model_id": None,
1542+
"modified_at": "1970-01-01T00:00:00+00:00",
1543+
"name_by_user": None,
1544+
"primary_config_entry": mock_config_entry.entry_id,
1545+
"serial_number": None,
1546+
"sw_version": "new_version",
1547+
"via_device_id": None,
1548+
},
1549+
],
1550+
"deleted_devices": [
1551+
{
1552+
"area_id": None,
1553+
"config_entries": ["234567"],
1554+
"config_entries_subentries": {"234567": [None]},
1555+
"connections": [["mac", "12:34:56:ab:cd:ab"]],
1556+
"created_at": "1970-01-01T00:00:00+00:00",
1557+
"disabled_by": None,
1558+
"id": "abcdefghijklm2",
1559+
"identifiers": [["serial", "123456ABCDAB"]],
1560+
"labels": [],
1561+
"modified_at": "1970-01-01T00:00:00+00:00",
1562+
"name_by_user": None,
1563+
"orphaned_timestamp": "1970-01-01T00:00:00+00:00",
1564+
},
1565+
],
1566+
},
1567+
}
1568+
1569+
14351570
async def test_removing_config_entries(
14361571
hass: HomeAssistant, device_registry: dr.DeviceRegistry
14371572
) -> None:
@@ -4753,3 +4888,9 @@ async def test_update_device_no_connections_or_identifiers(
47534888
device_registry.async_update_device(
47544889
device.id, new_connections=set(), new_identifiers=set()
47554890
)
4891+
4892+
4893+
async def test_connections_validator() -> None:
4894+
"""Test checking connections validator."""
4895+
with pytest.raises(ValueError, match="Invalid mac address format"):
4896+
dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")})

0 commit comments

Comments
 (0)