Protect the DeviceRegistry.deleted_devices container (#179720)

This commit is contained in:
Erik Montnemery
2026-08-21 16:36:02 +02:00
committed by GitHub
parent 1a1b3c39bd
commit bbea0c5a65
3 changed files with 168 additions and 105 deletions
+44 -28
View File
@@ -1789,7 +1789,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
devices: Collection[DeviceEntry]
_child_devices: ChildDeviceRegistryItems
child_devices: Collection[ChildDeviceEntry]
deleted_devices: DeletedDeviceRegistryItems
_deleted_devices: DeletedDeviceRegistryItems
_device_data: dict[str, DeviceEntry]
_child_device_data: dict[str, ChildDeviceEntry]
@@ -1810,6 +1810,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
serialize_in_event_loop=False,
)
@property
def deleted_devices(self) -> DeletedDeviceRegistryItems:
"""Return the deleted devices container (deprecated).
Can be removed in release 2027.9.
"""
report_usage(
"accesses `device_registry.deleted_devices`, which is deprecated and "
"an internal implementation detail of the device registry",
breaks_in_ha_version="2027.9.0",
core_behavior=ReportBehavior.ERROR,
core_integration_behavior=ReportBehavior.ERROR,
custom_integration_behavior=ReportBehavior.LOG,
)
return self._deleted_devices
@overload
def async_get(
self,
@@ -2390,7 +2406,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if device is None:
is_new = True
deleted_device = self.deleted_devices.get_entry(
deleted_device = self._deleted_devices.get_entry(
connections=connections,
identifiers=identifiers,
config_entry_id=config_entry_id,
@@ -2401,7 +2417,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# rather than create a fresh device. Matching on the recorded domain keeps
# a chance identifier/connection collision from restoring another
# integration's device.
deleted_device = self.deleted_devices.get_orphaned_entry(
deleted_device = self._deleted_devices.get_orphaned_entry(
identifiers, connections, config_entry.domain
)
if deleted_device is None:
@@ -2428,7 +2444,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
)
else:
self.deleted_devices.pop(deleted_device.id)
self._deleted_devices.pop(deleted_device.id)
device = deleted_device.to_device_entry(
config_entry,
# Interpret not specifying a subentry as None
@@ -2739,14 +2755,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if child_device is None:
is_new = True
deleted_device = self.deleted_devices.get_entry(
deleted_device = self._deleted_devices.get_entry(
identifiers=identifiers,
config_entry_id=config_entry_id,
)
if deleted_device is None:
# Fall back to an orphan (its owning config entry was removed), as
# for a full device
deleted_device = self.deleted_devices.get_orphaned_entry(
deleted_device = self._deleted_devices.get_orphaned_entry(
identifiers, None, domain
)
if deleted_device is None:
@@ -2768,7 +2784,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
parent_device_id=parent.id,
)
else:
self.deleted_devices.pop(deleted_device.id)
self._deleted_devices.pop(deleted_device.id)
child_device = deleted_device.to_child_device_entry(
config_entry,
effective_config_subentry_id,
@@ -3385,13 +3401,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
match_identifiers = added_identifiers
match_connections = added_connections
# A deleted device holding an identity the device now owns can never restore
for deleted_device_id in self.deleted_devices.get_colliding_device_ids(
for deleted_device_id in self._deleted_devices.get_colliding_device_ids(
match_identifiers or set(),
match_connections or set(),
config_entry_id=effective_config_entry_id,
exclude_device_id=None,
):
del self.deleted_devices[deleted_device_id]
del self._deleted_devices[deleted_device_id]
# If its only run time attributes (suggested_area)
# that do not get saved we do not want to write
@@ -3577,13 +3593,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# A deleted device holding an identity the child device now owns can never
# restore
for deleted_device_id in self.deleted_devices.get_colliding_device_ids(
for deleted_device_id in self._deleted_devices.get_colliding_device_ids(
added_identifiers or set(),
set(),
config_entry_id=old.config_entry_id,
exclude_device_id=None,
):
del self.deleted_devices[deleted_device_id]
del self._deleted_devices[deleted_device_id]
self.async_schedule_save()
@@ -3884,7 +3900,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
elif not device.has_composite_identifiers:
identifiers = device.identifiers | identifiers
connections = device.connections | connections
colliding = self.deleted_devices.get_colliding_device_ids(
colliding = self._deleted_devices.get_colliding_device_ids(
identifiers,
connections,
config_entry_id=device.config_entry_id,
@@ -3899,7 +3915,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
deleted_device_id,
device.id,
)
del self.deleted_devices[deleted_device_id]
del self._deleted_devices[deleted_device_id]
self.async_schedule_save()
@callback
@@ -4055,7 +4071,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self._async_remove_child_device(child)
device = self._devices.pop(device_id)
config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id)
self.deleted_devices[device_id] = DeletedDeviceEntry(
self._deleted_devices[device_id] = DeletedDeviceEntry(
area_id=device.area_id,
config_entry_id=device.config_entry_id,
config_subentry_id=device.config_subentry_id,
@@ -4089,7 +4105,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
config_entry = self.hass.config_entries.async_get_entry(
child_device.config_entry_id
)
self.deleted_devices[child_device.id] = DeletedDeviceEntry(
self._deleted_devices[child_device.id] = DeletedDeviceEntry(
area_id=child_device.area_id,
config_entry_id=child_device.config_entry_id,
config_subentry_id=child_device.config_subentry_id,
@@ -4266,7 +4282,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self.devices = _DeprecatedDeviceRegistryItemsView(self._devices)
self._child_devices = child_devices
self.child_devices = self._child_devices.values()
self.deleted_devices = deleted_devices
self._deleted_devices = deleted_devices
self._device_data = devices.data
self._child_device_data = child_devices.data
@@ -4297,7 +4313,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
],
"deleted_devices": [
entry.as_storage_fragment
for entry in list(self.deleted_devices.values())
for entry in list(self._deleted_devices.values())
],
}
@@ -4325,7 +4341,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# device from the same integration is orphaned, drop any existing orphan
# it overlaps so the newest one wins deterministically instead of shadowing
# it.
for existing in list(self.deleted_devices.values()):
for existing in list(self._deleted_devices.values()):
if (
existing.config_entry_id is None
and existing.domain == domain
@@ -4334,8 +4350,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
or existing.identifiers & deleted_device.identifiers
)
):
del self.deleted_devices[existing.id]
self.deleted_devices[deleted_device.id] = attr.evolve(
del self._deleted_devices[existing.id]
self._deleted_devices[deleted_device.id] = attr.evolve(
deleted_device,
config_entry_id=None,
config_subentry_id=None,
@@ -4384,7 +4400,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
and pending_move.config_entry_id == config_entry_id
):
self._devices[device.id] = attr.evolve(device, pending_move=None)
for deleted_device in list(self.deleted_devices.values()):
for deleted_device in list(self._deleted_devices.values()):
if deleted_device.config_entry_id != config_entry_id:
continue
self._async_orphan_deleted_device(deleted_device, domain, now_time)
@@ -4419,7 +4435,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
and pending_move.config_subentry_id == config_subentry_id
):
self._devices[device.id] = attr.evolve(device, pending_move=None)
for deleted_device in list(self.deleted_devices.values()):
for deleted_device in list(self._deleted_devices.values()):
if (
deleted_device.config_entry_id != config_entry_id
or deleted_device.config_subentry_id != config_subentry_id
@@ -4435,7 +4451,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
growing without bound.
"""
now_time = time.time()
for deleted_device in list(self.deleted_devices.values()):
for deleted_device in list(self._deleted_devices.values()):
if deleted_device.orphaned_timestamp is None:
continue
@@ -4443,7 +4459,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
deleted_device.orphaned_timestamp + ORPHANED_DEVICE_KEEP_SECONDS
< now_time
):
del self.deleted_devices[deleted_device.id]
del self._deleted_devices[deleted_device.id]
@callback
def async_clear_area_id(self, area_id: str) -> None:
@@ -4452,10 +4468,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self._async_update_device(device.id, area_id=None)
for child_device in self._child_devices.get_devices_for_area_id(area_id):
self._async_update_child_device(child_device.id, area_id=None)
for deleted_device in list(self.deleted_devices.values()):
for deleted_device in list(self._deleted_devices.values()):
if deleted_device.area_id != area_id:
continue
self.deleted_devices[deleted_device.id] = attr.evolve(
self._deleted_devices[deleted_device.id] = attr.evolve(
deleted_device, area_id=None
)
self.async_schedule_save()
@@ -4469,10 +4485,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self._async_update_child_device(
child_device.id, labels=child_device.labels - {label_id}
)
for deleted_device in list(self.deleted_devices.values()):
for deleted_device in list(self._deleted_devices.values()):
if label_id not in deleted_device.labels:
continue
self.deleted_devices[deleted_device.id] = attr.evolve(
self._deleted_devices[deleted_device.id] = attr.evolve(
deleted_device, labels=deleted_device.labels - {label_id}
)
self.async_schedule_save()
+1 -1
View File
@@ -769,7 +769,7 @@ def mock_device_registry(
mock_entries = {}
for key, entry in mock_entries.items():
registry._devices[key] = entry
registry.deleted_devices = dr.DeletedDeviceRegistryItems()
registry._deleted_devices = dr.DeletedDeviceRegistryItems()
hass.data[dr.DATA_REGISTRY] = registry
return registry
+123 -76
View File
@@ -453,7 +453,7 @@ async def test_loading_from_storage(
await dr.async_load(hass)
registry = dr.async_get(hass)
assert len(registry.devices) == 1
assert len(registry.deleted_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)
@@ -462,7 +462,7 @@ async def test_loading_from_storage(
assert loaded_child.disabled_by is dr.DeviceEntryDisabler.DEVICE
assert loaded_child.identifiers == {("test", "strip_outlet_1")}
assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry(
assert registry._deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry(
area_id="12345A",
config_entry_id=mock_config_entry.entry_id,
config_subentry_id=None,
@@ -604,7 +604,7 @@ async def test_migration_from_1_1(
)
assert entry.id == "abcdefghijklm"
deleted_entry = registry.deleted_devices["deletedid"]
deleted_entry = registry._deleted_devices["deletedid"]
assert deleted_entry.disabled_by is UNDEFINED
# Update to trigger a store
@@ -1669,7 +1669,7 @@ async def test_migration_from_1_10(
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
deleted_entry = registry.deleted_devices.get_entry(
deleted_entry = registry._deleted_devices.get_entry(
connections=set(),
identifiers={("serial", "123456ABCDAB")},
)
@@ -1812,7 +1812,7 @@ async def test_migration_from_1_11(
identifiers={("serial", "123456ABCDEF")},
)
assert entry.id == "abcdefghijklm"
deleted_entry = registry.deleted_devices.get_entry(
deleted_entry = registry._deleted_devices.get_entry(
connections=set(),
identifiers={("serial", "123456ABCDAB")},
)
@@ -2901,7 +2901,7 @@ async def test_move_to_config_entry_clears_target_entry_deleted_device(
# 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
assert device_b.id in device_registry._deleted_devices
# Move device_a into entry_b, retaining its identity
device_registry.async_update_device(
@@ -2910,7 +2910,7 @@ async def test_move_to_config_entry_clears_target_entry_deleted_device(
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
assert device_b.id not in device_registry._deleted_devices
async def test_get_or_create_via_device_and_via_device_id_raises_cleanly(
@@ -3001,7 +3001,7 @@ async def test_reregister_restores_orphan(
# 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]
orphan = device_registry._deleted_devices[device.id]
assert orphan.config_entry_id is None
assert orphan.domain == "light"
@@ -3032,7 +3032,7 @@ async def test_orphan_not_restored_for_other_domain(
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"
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
@@ -3042,7 +3042,7 @@ async def test_orphan_not_restored_for_other_domain(
config_entry_id=other_entry.entry_id, identifiers={("light", "1")}
)
assert fresh.id != device.id
assert device.id in device_registry.deleted_devices
assert device.id in device_registry._deleted_devices
async def test_orphaning_replaces_colliding_same_domain_orphan(
@@ -3071,12 +3071,12 @@ async def test_orphaning_replaces_colliding_same_domain_orphan(
)
device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain)
assert device_1.id in device_registry.deleted_devices
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
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")
@@ -3104,7 +3104,7 @@ async def test_orphaned_domain_survives_store_round_trip(
await flush_store(device_registry._store)
await registry2.async_load()
assert registry2.deleted_devices[device.id].domain == "hue"
assert registry2._deleted_devices[device.id].domain == "hue"
async def test_orphan_keeps_domain_when_config_entry_removed(
@@ -3126,7 +3126,7 @@ async def test_orphan_keeps_domain_when_config_entry_removed(
await hass.config_entries.async_remove(entry.entry_id)
orphan = device_registry.deleted_devices[device.id]
orphan = device_registry._deleted_devices[device.id]
assert orphan.config_entry_id is None
assert orphan.domain == "hue"
@@ -3189,7 +3189,7 @@ async def test_domainless_orphan_not_restored(
# 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
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")
@@ -3199,7 +3199,7 @@ async def test_domainless_orphan_not_restored(
)
assert fresh.id != device_1.id
# The un-restored orphan lingers until the periodic purge
assert device_1.id in device_registry.deleted_devices
assert device_1.id in device_registry._deleted_devices
async def test_clear_config_subentry_removes_device_with_pending_move(
@@ -3796,9 +3796,9 @@ async def test_migration_splits_deleted_device_with_multiple_config_entries(
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 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")}
@@ -3883,21 +3883,21 @@ async def test_deleted_device_removing_config_entries(
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
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 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
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
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(
@@ -3990,17 +3990,17 @@ async def test_deleted_device_removing_config_subentries(
device_registry.async_remove_device(entry.id)
device_registry.async_remove_device(entry2.id)
assert len(device_registry.deleted_devices) == 2
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 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
device_registry._deleted_devices[entry2.id].config_entry_id
== config_entry.entry_id
)
@@ -4441,6 +4441,53 @@ async def test_devices_membership_by_entry_supported_by_id_deprecated(
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"),
[
@@ -5097,7 +5144,7 @@ async def test_loading_saving_data(
# 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
assert len(device_registry._deleted_devices) == 1
orig_via = device_registry.async_update_device(
orig_via.id,
@@ -5113,7 +5160,7 @@ async def test_loading_saving_data(
# Ensure same order
assert list(device_registry._devices) == list(registry2._devices)
assert list(device_registry.deleted_devices) == list(registry2.deleted_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")})
@@ -5931,8 +5978,8 @@ async def test_create_reflects_config_entry_disabled_state(
# 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 = 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(
@@ -6710,12 +6757,12 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices(
device_registry.async_clear_config_entry(config_entry.entry_id)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 3
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
assert len(device_registry._deleted_devices) == 3
future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1
@@ -6723,7 +6770,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices(
dr.async_cleanup(hass, device_registry, entity_registry)
assert len(device_registry.devices) == 0
assert len(device_registry.deleted_devices) == 0
assert len(device_registry._deleted_devices) == 0
async def test_cleanup_startup(hass: HomeAssistant) -> None:
@@ -6822,12 +6869,12 @@ async def test_restore_device(
)
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
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
assert len(device_registry._deleted_devices) == 1
# This will create a new device
entry2 = device_registry.async_get_or_create(
@@ -6905,7 +6952,7 @@ async def test_restore_device(
assert entry.id == entry3.id
assert entry.id != entry2.id
assert len(device_registry.devices) == 2
assert len(device_registry.deleted_devices) == 0
assert len(device_registry._deleted_devices) == 0
assert isinstance(entry3.config_entries, set)
assert isinstance(entry3.connections, set)
@@ -6985,7 +7032,7 @@ async def test_restore_device_reflects_reregistered_identity(
identifiers=stored_identifiers,
)
device_registry.async_remove_device(entry.id)
assert len(device_registry.deleted_devices) == 1
assert len(device_registry._deleted_devices) == 1
restored = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
@@ -7033,7 +7080,7 @@ async def test_deleted_device_to_device_entry_uses_reregistered_identity(
identifiers={("bridgeid", "0123")},
)
device_registry.async_remove_device(entry.id)
deleted_device = device_registry.deleted_devices[entry.id]
deleted_device = device_registry._deleted_devices[entry.id]
restored = deleted_device.to_device_entry(
mock_config_entry,
@@ -7090,15 +7137,15 @@ async def test_restore_migrated_device_disabled_by(
)
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
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
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 = device_registry._deleted_devices[entry.id]
device_registry._deleted_devices[entry.id] = attr.evolve(
deleted_entry, disabled_by=UNDEFINED
)
@@ -7148,7 +7195,7 @@ async def test_restore_migrated_device_disabled_by(
assert entry.id == entry3.id
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
assert len(device_registry._deleted_devices) == 0
assert isinstance(entry3.config_entries, set)
assert isinstance(entry3.connections, set)
@@ -7259,20 +7306,20 @@ async def test_restore_disabled_by(
)
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
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
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 = device_registry._deleted_devices[entry.id]
device_registry._deleted_devices[entry.id] = attr.evolve(
deleted_entry, disabled_by=device_disabled_by_deleted
)
@@ -7322,7 +7369,7 @@ async def test_restore_disabled_by(
assert entry.id == entry3.id
assert len(device_registry.devices) == 1
assert len(device_registry.deleted_devices) == 0
assert len(device_registry._deleted_devices) == 0
assert isinstance(entry3.config_entries, set)
assert isinstance(entry3.connections, set)
@@ -8291,10 +8338,10 @@ async def test_device_registry_deleted_device_collision(
manufacturer="manufacturer",
model="model",
)
assert len(device_registry.deleted_devices) == 0
assert len(device_registry._deleted_devices) == 0
device_registry.async_remove_device(device1.id)
assert len(device_registry.deleted_devices) == 1
assert len(device_registry._deleted_devices) == 1
device2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
@@ -8302,13 +8349,13 @@ async def test_device_registry_deleted_device_collision(
manufacturer="manufacturer",
model="model",
)
assert len(device_registry.deleted_devices) == 1
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
assert len(device_registry._deleted_devices) == 0
async def test_update_device_no_connections_or_identifiers(
@@ -8752,7 +8799,7 @@ async def test_legacy_duplicate_fully_stripped_device_removed(
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 "stale" not in device_registry._deleted_devices
assert (
device_registry.async_get_device(identifiers={("test", "shared")}).id
== "device"
@@ -8851,7 +8898,7 @@ async def test_loading_from_storage_with_legacy_duplicates(
)
assert registered.id == "new"
assert registry.async_get("old") is None
assert "old" not in registry.deleted_devices
assert "old" not in registry._deleted_devices
# The reconciled state is persisted
await flush_store(registry._store)
@@ -8882,13 +8929,13 @@ async def test_registration_purges_same_entry_deleted_duplicates(
),
},
)
device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device(
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(
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(
device_registry._deleted_devices["deleted_other_entry"] = _mock_deleted_device(
"deleted_other_entry", other_entry.entry_id, {("test", "shared")}
)
@@ -8897,9 +8944,9 @@ async def test_registration_purges_same_entry_deleted_duplicates(
)
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
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 [
@@ -8915,10 +8962,10 @@ async def test_restore_purges_same_entry_deleted_duplicate(
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_registry = mock_device_registry(hass)
device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_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(
device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device(
"deleted_winner", entry.entry_id, {("test", "shared")}
)
@@ -8927,8 +8974,8 @@ async def test_restore_purges_same_entry_deleted_duplicate(
)
assert restored.id == "deleted_winner"
assert "deleted_winner" not in device_registry.deleted_devices
assert "deleted_shadowed" not in device_registry.deleted_devices
assert "deleted_winner" not in device_registry._deleted_devices
assert "deleted_shadowed" not in device_registry._deleted_devices
assert len(device_registry.devices) == 1
@@ -8945,10 +8992,10 @@ async def test_add_identifier_prunes_shadowed_deleted_duplicates(
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(
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(
device_registry._deleted_devices["deleted_winner"] = _mock_deleted_device(
"deleted_winner", entry.entry_id, {("test", "shared"), ("test", "other")}
)
@@ -8956,8 +9003,8 @@ async def test_add_identifier_prunes_shadowed_deleted_duplicates(
device.id, merge_identifiers={("test", "shared")}
)
assert "deleted_winner" not in device_registry.deleted_devices
assert "deleted_shadowed" not in device_registry.deleted_devices
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(
@@ -10657,8 +10704,8 @@ async def test_remove_parent_cascades_to_children(
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
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] == [
@@ -12404,13 +12451,13 @@ async def test_update_child_identifiers_purges_colliding_deleted_device(
name="Ghost",
)
device_registry.async_remove_device(ghost.id)
assert ghost.id in device_registry.deleted_devices
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
assert ghost.id not in device_registry._deleted_devices
@pytest.mark.usefixtures("hass")