Restore entity ID and user customizations of deleted entities (#145278)

* Restore entity ID and user customizations of deleted entities

* Clear removed areas, categories and labels from deleted entities

* Correct test

* Fix logic for disabled_by and hidden_by

* Improve test coverage

* Fix sorting

* Always restore disabled_by and hidden_by

* Update mqtt test

* Update pglab tests
This commit is contained in:
Erik Montnemery 2025-06-10 11:47:54 +02:00 committed by GitHub
parent 11d9014be0
commit ce739fd9b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 331 additions and 50 deletions

View File

@ -79,7 +79,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 17 STORAGE_VERSION_MINOR = 18
STORAGE_KEY = "core.entity_registry" STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24 CLEANUP_INTERVAL = 3600 * 24
@ -406,12 +406,23 @@ class DeletedRegistryEntry:
entity_id: str = attr.ib() entity_id: str = attr.ib()
unique_id: str = attr.ib() unique_id: str = attr.ib()
platform: str = attr.ib() platform: str = attr.ib()
aliases: set[str] = attr.ib()
area_id: str | None = attr.ib()
categories: dict[str, str] = attr.ib()
config_entry_id: str | None = attr.ib() config_entry_id: str | None = attr.ib()
config_subentry_id: str | None = attr.ib() config_subentry_id: str | None = attr.ib()
created_at: datetime = attr.ib() created_at: datetime = attr.ib()
device_class: str | None = attr.ib()
disabled_by: RegistryEntryDisabler | None = attr.ib()
domain: str = attr.ib(init=False, repr=False) domain: str = attr.ib(init=False, repr=False)
hidden_by: RegistryEntryHider | None = attr.ib()
icon: str | None = attr.ib()
id: str = attr.ib() id: str = attr.ib()
labels: set[str] = attr.ib()
modified_at: datetime = attr.ib() modified_at: datetime = attr.ib()
name: str | None = attr.ib()
options: ReadOnlyEntityOptionsType = attr.ib(converter=_protect_entity_options)
orphaned_timestamp: float | None = attr.ib() orphaned_timestamp: float | None = attr.ib()
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
@ -427,12 +438,22 @@ class DeletedRegistryEntry:
return json_fragment( return json_fragment(
json_bytes( json_bytes(
{ {
"aliases": list(self.aliases),
"area_id": self.area_id,
"categories": self.categories,
"config_entry_id": self.config_entry_id, "config_entry_id": self.config_entry_id,
"config_subentry_id": self.config_subentry_id, "config_subentry_id": self.config_subentry_id,
"created_at": self.created_at, "created_at": self.created_at,
"device_class": self.device_class,
"disabled_by": self.disabled_by,
"entity_id": self.entity_id, "entity_id": self.entity_id,
"hidden_by": self.hidden_by,
"icon": self.icon,
"id": self.id, "id": self.id,
"labels": list(self.labels),
"modified_at": self.modified_at, "modified_at": self.modified_at,
"name": self.name,
"options": self.options,
"orphaned_timestamp": self.orphaned_timestamp, "orphaned_timestamp": self.orphaned_timestamp,
"platform": self.platform, "platform": self.platform,
"unique_id": self.unique_id, "unique_id": self.unique_id,
@ -556,6 +577,20 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entity in data["entities"]: for entity in data["entities"]:
entity["suggested_object_id"] = None entity["suggested_object_id"] = None
if old_minor_version < 18:
# Version 1.18 adds user customizations to deleted entities
for entity in data["deleted_entities"]:
entity["aliases"] = []
entity["area_id"] = None
entity["categories"] = {}
entity["device_class"] = None
entity["disabled_by"] = None
entity["hidden_by"] = None
entity["icon"] = None
entity["labels"] = []
entity["name"] = None
entity["options"] = {}
if old_major_version > 1: if old_major_version > 1:
raise NotImplementedError raise NotImplementedError
return data return data
@ -916,15 +951,40 @@ class EntityRegistry(BaseRegistry):
entity_registry_id: str | None = None entity_registry_id: str | None = None
created_at = utcnow() created_at = utcnow()
deleted_entity = self.deleted_entities.pop((domain, platform, unique_id), None) deleted_entity = self.deleted_entities.pop((domain, platform, unique_id), None)
options: Mapping[str, Mapping[str, Any]] | None
if deleted_entity is not None: if deleted_entity is not None:
# Restore id aliases = deleted_entity.aliases
entity_registry_id = deleted_entity.id area_id = deleted_entity.area_id
categories = deleted_entity.categories
created_at = deleted_entity.created_at created_at = deleted_entity.created_at
device_class = deleted_entity.device_class
disabled_by = deleted_entity.disabled_by
# Restore entity_id if it's available
if self._entity_id_available(deleted_entity.entity_id):
entity_id = deleted_entity.entity_id
entity_registry_id = deleted_entity.id
hidden_by = deleted_entity.hidden_by
icon = deleted_entity.icon
labels = deleted_entity.labels
name = deleted_entity.name
options = deleted_entity.options
else:
aliases = set()
area_id = None
categories = {}
device_class = None
icon = None
labels = set()
name = None
options = get_initial_options() if get_initial_options else None
entity_id = self.async_generate_entity_id( if not entity_id:
domain, entity_id = self.async_generate_entity_id(
suggested_object_id or calculated_object_id or f"{platform}_{unique_id}", domain,
) suggested_object_id
or calculated_object_id
or f"{platform}_{unique_id}",
)
if ( if (
disabled_by is None disabled_by is None
@ -938,21 +998,26 @@ class EntityRegistry(BaseRegistry):
"""Return None if value is UNDEFINED, otherwise return value.""" """Return None if value is UNDEFINED, otherwise return value."""
return None if value is UNDEFINED else value return None if value is UNDEFINED else value
initial_options = get_initial_options() if get_initial_options else None
entry = RegistryEntry( entry = RegistryEntry(
aliases=aliases,
area_id=area_id,
categories=categories,
capabilities=none_if_undefined(capabilities), capabilities=none_if_undefined(capabilities),
config_entry_id=none_if_undefined(config_entry_id), config_entry_id=none_if_undefined(config_entry_id),
config_subentry_id=none_if_undefined(config_subentry_id), config_subentry_id=none_if_undefined(config_subentry_id),
created_at=created_at, created_at=created_at,
device_class=device_class,
device_id=none_if_undefined(device_id), device_id=none_if_undefined(device_id),
disabled_by=disabled_by, disabled_by=disabled_by,
entity_category=none_if_undefined(entity_category), entity_category=none_if_undefined(entity_category),
entity_id=entity_id, entity_id=entity_id,
hidden_by=hidden_by, hidden_by=hidden_by,
has_entity_name=none_if_undefined(has_entity_name) or False, has_entity_name=none_if_undefined(has_entity_name) or False,
icon=icon,
id=entity_registry_id, id=entity_registry_id,
options=initial_options, labels=labels,
name=name,
options=options,
original_device_class=none_if_undefined(original_device_class), original_device_class=none_if_undefined(original_device_class),
original_icon=none_if_undefined(original_icon), original_icon=none_if_undefined(original_icon),
original_name=none_if_undefined(original_name), original_name=none_if_undefined(original_name),
@ -986,12 +1051,22 @@ class EntityRegistry(BaseRegistry):
# If the entity does not belong to a config entry, mark it as orphaned # If the entity does not belong to a config entry, mark it as orphaned
orphaned_timestamp = None if config_entry_id else time.time() orphaned_timestamp = None if config_entry_id else time.time()
self.deleted_entities[key] = DeletedRegistryEntry( self.deleted_entities[key] = DeletedRegistryEntry(
aliases=entity.aliases,
area_id=entity.area_id,
categories=entity.categories,
config_entry_id=config_entry_id, config_entry_id=config_entry_id,
config_subentry_id=entity.config_subentry_id, config_subentry_id=entity.config_subentry_id,
created_at=entity.created_at, created_at=entity.created_at,
device_class=entity.device_class,
disabled_by=entity.disabled_by,
entity_id=entity_id, entity_id=entity_id,
hidden_by=entity.hidden_by,
icon=entity.icon,
id=entity.id, id=entity.id,
labels=entity.labels,
modified_at=utcnow(), modified_at=utcnow(),
name=entity.name,
options=entity.options,
orphaned_timestamp=orphaned_timestamp, orphaned_timestamp=orphaned_timestamp,
platform=entity.platform, platform=entity.platform,
unique_id=entity.unique_id, unique_id=entity.unique_id,
@ -1420,12 +1495,30 @@ class EntityRegistry(BaseRegistry):
entity["unique_id"], entity["unique_id"],
) )
deleted_entities[key] = DeletedRegistryEntry( deleted_entities[key] = DeletedRegistryEntry(
aliases=set(entity["aliases"]),
area_id=entity["area_id"],
categories=entity["categories"],
config_entry_id=entity["config_entry_id"], config_entry_id=entity["config_entry_id"],
config_subentry_id=entity["config_subentry_id"], config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]), created_at=datetime.fromisoformat(entity["created_at"]),
device_class=entity["device_class"],
disabled_by=(
RegistryEntryDisabler(entity["disabled_by"])
if entity["disabled_by"]
else None
),
entity_id=entity["entity_id"], entity_id=entity["entity_id"],
hidden_by=(
RegistryEntryHider(entity["hidden_by"])
if entity["hidden_by"]
else None
),
icon=entity["icon"],
id=entity["id"], id=entity["id"],
labels=set(entity["labels"]),
modified_at=datetime.fromisoformat(entity["modified_at"]), modified_at=datetime.fromisoformat(entity["modified_at"]),
name=entity["name"],
options=entity["options"],
orphaned_timestamp=entity["orphaned_timestamp"], orphaned_timestamp=entity["orphaned_timestamp"],
platform=entity["platform"], platform=entity["platform"],
unique_id=entity["unique_id"], unique_id=entity["unique_id"],
@ -1455,12 +1548,29 @@ class EntityRegistry(BaseRegistry):
categories = entry.categories.copy() categories = entry.categories.copy()
del categories[scope] del categories[scope]
self.async_update_entity(entity_id, categories=categories) self.async_update_entity(entity_id, categories=categories)
for key, deleted_entity in list(self.deleted_entities.items()):
if (
existing_category_id := deleted_entity.categories.get(scope)
) and category_id == existing_category_id:
categories = deleted_entity.categories.copy()
del categories[scope]
self.deleted_entities[key] = attr.evolve(
deleted_entity, categories=categories
)
self.async_schedule_save()
@callback @callback
def async_clear_label_id(self, label_id: str) -> None: def async_clear_label_id(self, label_id: str) -> None:
"""Clear label from registry entries.""" """Clear label from registry entries."""
for entry in self.entities.get_entries_for_label(label_id): for entry in self.entities.get_entries_for_label(label_id):
self.async_update_entity(entry.entity_id, labels=entry.labels - {label_id}) self.async_update_entity(entry.entity_id, labels=entry.labels - {label_id})
for key, deleted_entity in list(self.deleted_entities.items()):
if label_id not in deleted_entity.labels:
continue
self.deleted_entities[key] = attr.evolve(
deleted_entity, labels=deleted_entity.labels - {label_id}
)
self.async_schedule_save()
@callback @callback
def async_clear_config_entry(self, config_entry_id: str) -> None: def async_clear_config_entry(self, config_entry_id: str) -> None:
@ -1525,6 +1635,11 @@ class EntityRegistry(BaseRegistry):
"""Clear area id from registry entries.""" """Clear area id from registry entries."""
for entry in self.entities.get_entries_for_area_id(area_id): for entry in self.entities.get_entries_for_area_id(area_id):
self.async_update_entity(entry.entity_id, area_id=None) self.async_update_entity(entry.entity_id, area_id=None)
for key, deleted_entity in list(self.deleted_entities.items()):
if deleted_entity.area_id != area_id:
continue
self.deleted_entities[key] = attr.evolve(deleted_entity, area_id=None)
self.async_schedule_save()
@callback @callback

View File

@ -1680,6 +1680,7 @@ async def test_rapid_rediscover_unique(
"homeassistant/binary_sensor/bla/config", "homeassistant/binary_sensor/bla/config",
'{ "name": "Beer", "state_topic": "test-topic", "unique_id": "even_uniquer" }', '{ "name": "Beer", "state_topic": "test-topic", "unique_id": "even_uniquer" }',
) )
# Removal, immediately followed by rediscover
async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "") async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
async_fire_mqtt_message( async_fire_mqtt_message(
hass, hass,
@ -1691,8 +1692,10 @@ async def test_rapid_rediscover_unique(
assert len(hass.states.async_entity_ids("binary_sensor")) == 2 assert len(hass.states.async_entity_ids("binary_sensor")) == 2
state = hass.states.get("binary_sensor.ale") state = hass.states.get("binary_sensor.ale")
assert state is not None assert state is not None
state = hass.states.get("binary_sensor.milk") state = hass.states.get("binary_sensor.beer")
assert state is not None assert state is not None
state = hass.states.get("binary_sensor.milk")
assert state is None
assert len(events) == 4 assert len(events) == 4
# Add the entity # Add the entity
@ -1702,7 +1705,7 @@ async def test_rapid_rediscover_unique(
assert events[2].data["entity_id"] == "binary_sensor.beer" assert events[2].data["entity_id"] == "binary_sensor.beer"
assert events[2].data["new_state"] is None assert events[2].data["new_state"] is None
# Add the entity # Add the entity
assert events[3].data["entity_id"] == "binary_sensor.milk" assert events[3].data["entity_id"] == "binary_sensor.beer"
assert events[3].data["old_state"] is None assert events[3].data["old_state"] is None

View File

@ -166,12 +166,16 @@ async def test_discovery_update(
await send_discovery_message(hass, payload) await send_discovery_message(hass, payload)
# be sure that old relay are been removed # entity id from the old relay configuration should be reused
for i in range(8): for i in range(8):
assert not hass.states.get(f"switch.first_test_relay_{i}") state = hass.states.get(f"switch.first_test_relay_{i}")
assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE)
for i in range(8):
assert not hass.states.get(f"switch.second_test_relay_{i}")
# check new relay # check new relay
for i in range(16): for i in range(8, 16):
state = hass.states.get(f"switch.second_test_relay_{i}") state = hass.states.get(f"switch.second_test_relay_{i}")
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE) assert not state.attributes.get(ATTR_ASSUMED_STATE)

View File

@ -583,23 +583,43 @@ async def test_load_bad_data(
], ],
"deleted_entities": [ "deleted_entities": [
{ {
"aliases": [],
"area_id": None,
"categories": {},
"config_entry_id": None, "config_entry_id": None,
"config_subentry_id": None, "config_subentry_id": None,
"created_at": "2024-02-14T12:00:00.900075+00:00", "created_at": "2024-02-14T12:00:00.900075+00:00",
"device_class": None,
"disabled_by": None,
"entity_id": "test.test3", "entity_id": "test.test3",
"hidden_by": None,
"icon": None,
"id": "00003", "id": "00003",
"labels": [],
"modified_at": "2024-02-14T12:00:00.900075+00:00", "modified_at": "2024-02-14T12:00:00.900075+00:00",
"name": None,
"options": None,
"orphaned_timestamp": None, "orphaned_timestamp": None,
"platform": "super_platform", "platform": "super_platform",
"unique_id": 234, # Should not load "unique_id": 234, # Should not load
}, },
{ {
"aliases": [],
"area_id": None,
"categories": {},
"config_entry_id": None, "config_entry_id": None,
"config_subentry_id": None, "config_subentry_id": None,
"created_at": "2024-02-14T12:00:00.900075+00:00", "created_at": "2024-02-14T12:00:00.900075+00:00",
"device_class": None,
"disabled_by": None,
"entity_id": "test.test4", "entity_id": "test.test4",
"hidden_by": None,
"icon": None,
"id": "00004", "id": "00004",
"labels": [],
"modified_at": "2024-02-14T12:00:00.900075+00:00", "modified_at": "2024-02-14T12:00:00.900075+00:00",
"name": None,
"options": None,
"orphaned_timestamp": None, "orphaned_timestamp": None,
"platform": "super_platform", "platform": "super_platform",
"unique_id": ["also", "not", "valid"], # Should not load "unique_id": ["also", "not", "valid"], # Should not load
@ -870,6 +890,33 @@ async def test_removing_area_id(entity_registry: er.EntityRegistry) -> None:
assert entry_w_area != entry_wo_area assert entry_w_area != entry_wo_area
async def test_removing_area_id_deleted_entity(
entity_registry: er.EntityRegistry,
) -> None:
"""Make sure we can clear area id."""
entry1 = entity_registry.async_get_or_create("light", "hue", "5678")
entry2 = entity_registry.async_get_or_create("light", "hue", "1234")
entry1_w_area = entity_registry.async_update_entity(
entry1.entity_id, area_id="12345A"
)
entry2_w_area = entity_registry.async_update_entity(
entry2.entity_id, area_id="12345B"
)
entity_registry.async_remove(entry1.entity_id)
entity_registry.async_remove(entry2.entity_id)
entity_registry.async_clear_area_id("12345A")
entry1_restored = entity_registry.async_get_or_create("light", "hue", "5678")
entry2_restored = entity_registry.async_get_or_create("light", "hue", "1234")
assert not entry1_restored.area_id
assert entry2_restored.area_id == "12345B"
assert entry1_w_area != entry1_restored
assert entry2_w_area != entry2_restored
@pytest.mark.parametrize("load_registries", [False]) @pytest.mark.parametrize("load_registries", [False])
async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None:
"""Test migration from version 1.1.""" """Test migration from version 1.1."""
@ -1119,12 +1166,22 @@ async def test_migration_1_11(
], ],
"deleted_entities": [ "deleted_entities": [
{ {
"aliases": [],
"area_id": None,
"categories": {},
"config_entry_id": None, "config_entry_id": None,
"config_subentry_id": None, "config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00", "created_at": "1970-01-01T00:00:00+00:00",
"device_class": None,
"disabled_by": None,
"entity_id": "test.deleted_entity", "entity_id": "test.deleted_entity",
"hidden_by": None,
"icon": None,
"id": "23456", "id": "23456",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00", "modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"options": {},
"orphaned_timestamp": None, "orphaned_timestamp": None,
"platform": "super_duper_platform", "platform": "super_duper_platform",
"unique_id": "very_very_unique", "unique_id": "very_very_unique",
@ -2453,7 +2510,7 @@ async def test_restore_entity(
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Make sure entity registry id is stable.""" """Make sure entity registry id is stable and user configurations are restored."""
update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED)
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain="light", domain="light",
@ -2511,6 +2568,13 @@ async def test_restore_entity(
config_entry=config_entry, config_entry=config_entry,
config_subentry_id="mock-subentry-id-1-1", config_subentry_id="mock-subentry-id-1-1",
) )
entry3 = entity_registry.async_get_or_create(
"light",
"hue",
"abcd",
disabled_by=er.RegistryEntryDisabler.INTEGRATION,
hidden_by=er.RegistryEntryHider.INTEGRATION,
)
# Apply user customizations # Apply user customizations
entry1 = entity_registry.async_update_entity( entry1 = entity_registry.async_update_entity(
@ -2532,8 +2596,9 @@ async def test_restore_entity(
entity_registry.async_remove(entry1.entity_id) entity_registry.async_remove(entry1.entity_id)
entity_registry.async_remove(entry2.entity_id) entity_registry.async_remove(entry2.entity_id)
entity_registry.async_remove(entry3.entity_id)
assert len(entity_registry.entities) == 0 assert len(entity_registry.entities) == 0
assert len(entity_registry.deleted_entities) == 2 assert len(entity_registry.deleted_entities) == 3
# Re-add entities, integration has changed # Re-add entities, integration has changed
entry1_restored = entity_registry.async_get_or_create( entry1_restored = entity_registry.async_get_or_create(
@ -2557,32 +2622,46 @@ async def test_restore_entity(
translation_key="translation_key_2", translation_key="translation_key_2",
unit_of_measurement="unit_2", unit_of_measurement="unit_2",
) )
entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678") # Add back the second entity without config entry and with different
# disabled_by and hidden_by settings
entry2_restored = entity_registry.async_get_or_create(
"light",
"hue",
"5678",
disabled_by=er.RegistryEntryDisabler.INTEGRATION,
hidden_by=er.RegistryEntryHider.INTEGRATION,
)
# Add back the third entity with different disabled_by and hidden_by settings
entry3_restored = entity_registry.async_get_or_create("light", "hue", "abcd")
assert len(entity_registry.entities) == 2 assert len(entity_registry.entities) == 3
assert len(entity_registry.deleted_entities) == 0 assert len(entity_registry.deleted_entities) == 0
assert entry1 != entry1_restored assert entry1 != entry1_restored
# entity_id and user customizations are not restored. new integration options are # entity_id and user customizations are restored. new integration options are
# respected. # respected.
assert entry1_restored == er.RegistryEntry( assert entry1_restored == er.RegistryEntry(
entity_id="light.suggested_2", entity_id="light.custom_1",
unique_id="1234", unique_id="1234",
platform="hue", platform="hue",
aliases={"alias1", "alias2"},
area_id="12345A",
categories={"scope1": "id", "scope2": "id"},
capabilities={"key2": "value2"}, capabilities={"key2": "value2"},
config_entry_id=config_entry.entry_id, config_entry_id=config_entry.entry_id,
config_subentry_id="mock-subentry-id-1-2", config_subentry_id="mock-subentry-id-1-2",
created_at=utcnow(), created_at=utcnow(),
device_class=None, device_class="device_class_user",
device_id=device_entry_2.id, device_id=device_entry_2.id,
disabled_by=er.RegistryEntryDisabler.INTEGRATION, disabled_by=er.RegistryEntryDisabler.USER,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
has_entity_name=False, has_entity_name=False,
hidden_by=None, hidden_by=er.RegistryEntryHider.USER,
icon=None, icon="icon_user",
id=entry1.id, id=entry1.id,
labels={"label1", "label2"},
modified_at=utcnow(), modified_at=utcnow(),
name=None, name="Test Friendly Name",
options={"test_domain": {"key2": "value2"}}, options={"options_domain": {"key": "value"}, "test_domain": {"key1": "value1"}},
original_device_class="device_class_2", original_device_class="device_class_2",
original_icon="original_icon_2", original_icon="original_icon_2",
original_name="original_name_2", original_name="original_name_2",
@ -2594,14 +2673,21 @@ async def test_restore_entity(
assert entry2 != entry2_restored assert entry2 != entry2_restored
# Config entry and subentry are not restored # Config entry and subentry are not restored
assert ( assert (
attr.evolve(entry2, config_entry_id=None, config_subentry_id=None) attr.evolve(
entry2,
config_entry_id=None,
config_subentry_id=None,
disabled_by=None,
hidden_by=None,
)
== entry2_restored == entry2_restored
) )
assert entry3 == entry3_restored
# Remove two of the entities again, then bump time # Remove two of the entities again, then bump time
entity_registry.async_remove(entry1_restored.entity_id) entity_registry.async_remove(entry1_restored.entity_id)
entity_registry.async_remove(entry2.entity_id) entity_registry.async_remove(entry2.entity_id)
assert len(entity_registry.entities) == 0 assert len(entity_registry.entities) == 1
assert len(entity_registry.deleted_entities) == 2 assert len(entity_registry.deleted_entities) == 2
freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1))
async_fire_time_changed(hass) async_fire_time_changed(hass)
@ -2612,14 +2698,14 @@ async def test_restore_entity(
"light", "hue", "1234", config_entry=config_entry "light", "hue", "1234", config_entry=config_entry
) )
entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678") entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678")
assert len(entity_registry.entities) == 2 assert len(entity_registry.entities) == 3
assert len(entity_registry.deleted_entities) == 0 assert len(entity_registry.deleted_entities) == 0
assert entry1.id == entry1_restored.id assert entry1.id == entry1_restored.id
assert entry2.id != entry2_restored.id assert entry2.id != entry2_restored.id
# Remove the first entity, then its config entry, finally bump time # Remove the first entity, then its config entry, finally bump time
entity_registry.async_remove(entry1_restored.entity_id) entity_registry.async_remove(entry1_restored.entity_id)
assert len(entity_registry.entities) == 1 assert len(entity_registry.entities) == 2
assert len(entity_registry.deleted_entities) == 1 assert len(entity_registry.deleted_entities) == 1
entity_registry.async_clear_config_entry(config_entry.entry_id) entity_registry.async_clear_config_entry(config_entry.entry_id)
freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1))
@ -2630,39 +2716,36 @@ async def test_restore_entity(
entry1_restored = entity_registry.async_get_or_create( entry1_restored = entity_registry.async_get_or_create(
"light", "hue", "1234", config_entry=config_entry "light", "hue", "1234", config_entry=config_entry
) )
assert len(entity_registry.entities) == 2 assert len(entity_registry.entities) == 3
assert len(entity_registry.deleted_entities) == 0 assert len(entity_registry.deleted_entities) == 0
assert entry1.id != entry1_restored.id assert entry1.id != entry1_restored.id
# Check the events # Check the events
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(update_events) == 14 assert len(update_events) == 17
assert update_events[0].data == { assert update_events[0].data == {
"action": "create", "action": "create",
"entity_id": "light.suggested_1", "entity_id": "light.suggested_1",
} }
assert update_events[1].data == {"action": "create", "entity_id": "light.hue_5678"} assert update_events[1].data == {"action": "create", "entity_id": "light.hue_5678"}
assert update_events[2].data["action"] == "update" assert update_events[2].data == {"action": "create", "entity_id": "light.hue_abcd"}
assert update_events[3].data["action"] == "update" assert update_events[3].data["action"] == "update"
assert update_events[4].data == {"action": "remove", "entity_id": "light.custom_1"} assert update_events[4].data["action"] == "update"
assert update_events[5].data == {"action": "remove", "entity_id": "light.hue_5678"} assert update_events[5].data == {"action": "remove", "entity_id": "light.custom_1"}
assert update_events[6].data == {"action": "remove", "entity_id": "light.hue_5678"}
assert update_events[7].data == {"action": "remove", "entity_id": "light.hue_abcd"}
# Restore entities the 1st time # Restore entities the 1st time
assert update_events[6].data == { assert update_events[8].data == {"action": "create", "entity_id": "light.custom_1"}
"action": "create", assert update_events[9].data == {"action": "create", "entity_id": "light.hue_5678"}
"entity_id": "light.suggested_2", assert update_events[10].data == {"action": "create", "entity_id": "light.hue_abcd"}
} assert update_events[11].data == {"action": "remove", "entity_id": "light.custom_1"}
assert update_events[7].data == {"action": "create", "entity_id": "light.hue_5678"} assert update_events[12].data == {"action": "remove", "entity_id": "light.hue_5678"}
assert update_events[8].data == {
"action": "remove",
"entity_id": "light.suggested_2",
}
assert update_events[9].data == {"action": "remove", "entity_id": "light.hue_5678"}
# Restore entities the 2nd time # Restore entities the 2nd time
assert update_events[10].data == {"action": "create", "entity_id": "light.hue_1234"} assert update_events[13].data == {"action": "create", "entity_id": "light.custom_1"}
assert update_events[11].data == {"action": "create", "entity_id": "light.hue_5678"} assert update_events[14].data == {"action": "create", "entity_id": "light.hue_5678"}
assert update_events[12].data == {"action": "remove", "entity_id": "light.hue_1234"} assert update_events[15].data == {"action": "remove", "entity_id": "light.custom_1"}
# Restore entities the 3rd time # Restore entities the 3rd time
assert update_events[13].data == {"action": "create", "entity_id": "light.hue_1234"} assert update_events[16].data == {"action": "create", "entity_id": "light.hue_1234"}
async def test_async_migrate_entry_delete_self( async def test_async_migrate_entry_delete_self(
@ -2763,6 +2846,49 @@ async def test_removing_labels(entity_registry: er.EntityRegistry) -> None:
assert not entry_cleared_label2.labels assert not entry_cleared_label2.labels
async def test_removing_labels_deleted_entity(
entity_registry: er.EntityRegistry,
) -> None:
"""Make sure we can clear labels."""
entry1 = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="5678"
)
entry1 = entity_registry.async_update_entity(
entry1.entity_id, labels={"label1", "label2"}
)
entry2 = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="1234"
)
entry2 = entity_registry.async_update_entity(entry2.entity_id, labels={"label3"})
entity_registry.async_remove(entry1.entity_id)
entity_registry.async_remove(entry2.entity_id)
entity_registry.async_clear_label_id("label1")
entry1_cleared_label1 = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="5678"
)
entity_registry.async_remove(entry1.entity_id)
entity_registry.async_clear_label_id("label2")
entry1_cleared_label2 = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="5678"
)
entry2_restored = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="1234"
)
assert entry1_cleared_label1
assert entry1_cleared_label2
assert entry1 != entry1_cleared_label1
assert entry1 != entry1_cleared_label2
assert entry1_cleared_label1 != entry1_cleared_label2
assert entry1.labels == {"label1", "label2"}
assert entry1_cleared_label1.labels == {"label2"}
assert not entry1_cleared_label2.labels
assert entry2 != entry2_restored
assert entry2_restored.labels == {"label3"}
async def test_entries_for_label(entity_registry: er.EntityRegistry) -> None: async def test_entries_for_label(entity_registry: er.EntityRegistry) -> None:
"""Test getting entity entries by label.""" """Test getting entity entries by label."""
entity_registry.async_get_or_create( entity_registry.async_get_or_create(
@ -2830,6 +2956,39 @@ async def test_removing_categories(entity_registry: er.EntityRegistry) -> None:
assert not entry_cleared_scope2.categories assert not entry_cleared_scope2.categories
async def test_removing_categories_deleted_entity(
entity_registry: er.EntityRegistry,
) -> None:
"""Make sure we can clear categories."""
entry = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="5678"
)
entry = entity_registry.async_update_entity(
entry.entity_id, categories={"scope1": "id", "scope2": "id"}
)
entity_registry.async_remove(entry.entity_id)
entity_registry.async_clear_category_id("scope1", "id")
entry_cleared_scope1 = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="5678"
)
entity_registry.async_remove(entry.entity_id)
entity_registry.async_clear_category_id("scope2", "id")
entry_cleared_scope2 = entity_registry.async_get_or_create(
domain="light", platform="hue", unique_id="5678"
)
assert entry_cleared_scope1
assert entry_cleared_scope2
assert entry != entry_cleared_scope1
assert entry != entry_cleared_scope2
assert entry_cleared_scope1 != entry_cleared_scope2
assert entry.categories == {"scope1": "id", "scope2": "id"}
assert entry_cleared_scope1.categories == {"scope2": "id"}
assert not entry_cleared_scope2.categories
async def test_entries_for_category(entity_registry: er.EntityRegistry) -> None: async def test_entries_for_category(entity_registry: er.EntityRegistry) -> None:
"""Test getting entity entries by category.""" """Test getting entity entries by category."""
entity_registry.async_get_or_create( entity_registry.async_get_or_create(