Warn when implicitly moving a device to another subentry (#177034)

This commit is contained in:
Erik Montnemery
2026-07-28 10:29:17 +02:00
committed by GitHub
parent f2ef4da5bf
commit 504fddd216
2 changed files with 58 additions and 29 deletions
+21 -3
View File
@@ -1780,6 +1780,27 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if device_info_type == "primary" and (not name or name is UNDEFINED):
name = config_entry.title
elif (
config_subentry_id is not UNDEFINED
and device.config_subentry_id != config_subentry_id
):
# A device belongs to a single config subentry. Re-registering an existing
# device under a different subentry of the same config entry (e.g. entities
# from several subentries sharing one device_info identity) silently moves
# it. This is deprecated since moves should be explicit via
# async_update_device(new_config_subentry_id=...).
# For now warn and fall through to the move below, but it will raise in HA
# Core 2027.8.
report_usage(
"assigns an existing device to a different config subentry, by calling "
"`async_get_or_create` or by adding entities from several subentries that "
"share a device; this silently moves the device. A device belongs to one "
"subentry - keep a shared device in a single subentry, or move it with "
"`async_update_device`",
core_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2027.8.0",
)
if default_manufacturer is not UNDEFINED and device.manufacturer is None:
validated_fields["manufacturer"] = default_manufacturer
@@ -1846,9 +1867,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
is_new=is_new,
name=name,
has_composite_identifiers=has_composite_identifiers,
# Move the device if the integration re-registers it under a different
# subentry; UNDEFINED leaves the subentry unchanged. Also validates an
# explicitly provided subentry for new devices.
new_config_subentry_id=config_subentry_id,
suggested_area=suggested_area,
via_device_id=via_device_id,
+37 -26
View File
@@ -234,7 +234,9 @@ async def test_multiple_config_entries(
async def test_multiple_config_subentries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
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(
@@ -265,29 +267,35 @@ async def test_multiple_config_subentries(
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-2",
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.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",
)
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 moves the device rather than duplicating
# 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 entry.id == entry2.id == entry3.id
assert entry2.config_subentry_id == "mock-subentry-id-2"
assert entry3.config_subentry_id == "mock-subentry-id-1"
assert (
device_registry.async_get(entry.id).config_subentry_id == "mock-subentry-id-2"
)
@pytest.mark.parametrize("load_registries", [False])
@@ -2176,15 +2184,18 @@ async def test_migration_collapses_multi_subentry_device(
)
async def test_async_get_or_create_moves_device_between_subentries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
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 moves the device, not duplicates it.
"""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 moves the existing device - it neither creates a
duplicate nor raises.
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=[
@@ -2206,7 +2217,7 @@ async def test_async_get_or_create_moves_device_between_subentries(
)
entry.add_to_hass(hass)
# Same identifier, different subentry -> the existing device is moved
# 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",
@@ -2218,23 +2229,23 @@ async def test_async_get_or_create_moves_device_between_subentries(
config_subentry_id="sub-2",
identifiers={("test", "1")},
)
assert "A device belongs to one subentry" in caplog.text
assert moved.id == device.id
assert moved.config_subentry_id == "sub-2"
assert device_registry.async_get(device.id).config_subentry_id == "sub-2"
assert len(device_registry.devices) == 1
# Same connection, different subentry -> also moved, not duplicated
# 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")},
)
moved_2 = device_registry.async_get_or_create(
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 moved_2.id == device_2.id
assert moved_2.config_subentry_id == "sub-2"
assert device_registry.async_get(device_2.id).config_subentry_id == "sub-2"
assert len(device_registry.devices) == 2