From 0d765a27c915d9273a4297259d619b74bbe74880 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 23 Jul 2024 13:12:29 +0200 Subject: [PATCH] Add created_at/modified_at to entity registry (#122444) --- homeassistant/helpers/entity_registry.py | 172 ++++++++++-------- .../components/config/test_entity_registry.py | 102 ++++++++++- .../homekit_controller/test_init.py | 2 + tests/helpers/test_entity_platform.py | 3 + tests/helpers/test_entity_registry.py | 79 +++++++- tests/syrupy.py | 2 +- 6 files changed, 277 insertions(+), 83 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index dabe2e61917..a0bc63786d8 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -48,6 +48,7 @@ from homeassistant.core import ( from homeassistant.exceptions import MaxLengthExceeded from homeassistant.loader import async_suggest_report_issue from homeassistant.util import slugify, uuid as uuid_util +from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data @@ -74,7 +75,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 14 +STORAGE_VERSION_MINOR = 15 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -174,6 +175,7 @@ class RegistryEntry: categories: dict[str, str] = attr.ib(factory=dict) capabilities: Mapping[str, Any] | None = attr.ib(default=None) config_entry_id: str | None = attr.ib(default=None) + created_at: datetime = attr.ib(factory=utcnow) device_class: str | None = attr.ib(default=None) device_id: str | None = attr.ib(default=None) domain: str = attr.ib(init=False, repr=False) @@ -187,6 +189,7 @@ class RegistryEntry: ) has_entity_name: bool = attr.ib(default=False) labels: set[str] = attr.ib(factory=set) + modified_at: datetime = attr.ib(factory=utcnow) name: str | None = attr.ib(default=None) options: ReadOnlyEntityOptionsType = attr.ib( default=None, converter=_protect_entity_options @@ -271,6 +274,7 @@ class RegistryEntry: "area_id": self.area_id, "categories": self.categories, "config_entry_id": self.config_entry_id, + "created_at": self.created_at.timestamp(), "device_id": self.device_id, "disabled_by": self.disabled_by, "entity_category": self.entity_category, @@ -280,6 +284,7 @@ class RegistryEntry: "icon": self.icon, "id": self.id, "labels": list(self.labels), + "modified_at": self.modified_at.timestamp(), "name": self.name, "options": self.options, "original_name": self.original_name, @@ -330,6 +335,7 @@ class RegistryEntry: "categories": self.categories, "capabilities": self.capabilities, "config_entry_id": self.config_entry_id, + "created_at": self.created_at.isoformat(), "device_class": self.device_class, "device_id": self.device_id, "disabled_by": self.disabled_by, @@ -340,6 +346,7 @@ class RegistryEntry: "id": self.id, "has_entity_name": self.has_entity_name, "labels": list(self.labels), + "modified_at": self.modified_at.isoformat(), "name": self.name, "options": self.options, "original_device_class": self.original_device_class, @@ -395,6 +402,8 @@ class DeletedRegistryEntry: domain: str = attr.ib(init=False, repr=False) id: str = attr.ib() orphaned_timestamp: float | None = attr.ib() + created_at: datetime = attr.ib(factory=utcnow) + modified_at: datetime = attr.ib(factory=utcnow) @domain.default def _domain_default(self) -> str: @@ -408,8 +417,10 @@ class DeletedRegistryEntry: json_bytes( { "config_entry_id": self.config_entry_id, + "created_at": self.created_at.isoformat(), "entity_id": self.entity_id, "id": self.id, + "modified_at": self.modified_at.isoformat(), "orphaned_timestamp": self.orphaned_timestamp, "platform": self.platform, "unique_id": self.unique_id, @@ -429,88 +440,97 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): ) -> dict: """Migrate to the new version.""" data = old_data - if old_major_version == 1 and old_minor_version < 2: - # Version 1.2 implements migration and freezes the available keys - for entity in data["entities"]: - # Populate keys which were introduced before version 1.2 - entity.setdefault("area_id", None) - entity.setdefault("capabilities", {}) - entity.setdefault("config_entry_id", None) - entity.setdefault("device_class", None) - entity.setdefault("device_id", None) - entity.setdefault("disabled_by", None) - entity.setdefault("entity_category", None) - entity.setdefault("icon", None) - entity.setdefault("name", None) - entity.setdefault("original_icon", None) - entity.setdefault("original_name", None) - entity.setdefault("supported_features", 0) - entity.setdefault("unit_of_measurement", None) + if old_major_version == 1: + if old_minor_version < 2: + # Version 1.2 implements migration and freezes the available keys + for entity in data["entities"]: + # Populate keys which were introduced before version 1.2 + entity.setdefault("area_id", None) + entity.setdefault("capabilities", {}) + entity.setdefault("config_entry_id", None) + entity.setdefault("device_class", None) + entity.setdefault("device_id", None) + entity.setdefault("disabled_by", None) + entity.setdefault("entity_category", None) + entity.setdefault("icon", None) + entity.setdefault("name", None) + entity.setdefault("original_icon", None) + entity.setdefault("original_name", None) + entity.setdefault("supported_features", 0) + entity.setdefault("unit_of_measurement", None) - if old_major_version == 1 and old_minor_version < 3: - # Version 1.3 adds original_device_class - for entity in data["entities"]: - # Move device_class to original_device_class - entity["original_device_class"] = entity["device_class"] - entity["device_class"] = None + if old_minor_version < 3: + # Version 1.3 adds original_device_class + for entity in data["entities"]: + # Move device_class to original_device_class + entity["original_device_class"] = entity["device_class"] + entity["device_class"] = None - if old_major_version == 1 and old_minor_version < 4: - # Version 1.4 adds id - for entity in data["entities"]: - entity["id"] = uuid_util.random_uuid_hex() + if old_minor_version < 4: + # Version 1.4 adds id + for entity in data["entities"]: + entity["id"] = uuid_util.random_uuid_hex() - if old_major_version == 1 and old_minor_version < 5: - # Version 1.5 adds entity options - for entity in data["entities"]: - entity["options"] = {} + if old_minor_version < 5: + # Version 1.5 adds entity options + for entity in data["entities"]: + entity["options"] = {} - if old_major_version == 1 and old_minor_version < 6: - # Version 1.6 adds hidden_by - for entity in data["entities"]: - entity["hidden_by"] = None + if old_minor_version < 6: + # Version 1.6 adds hidden_by + for entity in data["entities"]: + entity["hidden_by"] = None - if old_major_version == 1 and old_minor_version < 7: - # Version 1.7 adds has_entity_name - for entity in data["entities"]: - entity["has_entity_name"] = False + if old_minor_version < 7: + # Version 1.7 adds has_entity_name + for entity in data["entities"]: + entity["has_entity_name"] = False - if old_major_version == 1 and old_minor_version < 8: - # Cleanup after frontend bug which incorrectly updated device_class - # Fixed by frontend PR #13551 - for entity in data["entities"]: - domain = split_entity_id(entity["entity_id"])[0] - if domain in [Platform.BINARY_SENSOR, Platform.COVER]: - continue - entity["device_class"] = None + if old_minor_version < 8: + # Cleanup after frontend bug which incorrectly updated device_class + # Fixed by frontend PR #13551 + for entity in data["entities"]: + domain = split_entity_id(entity["entity_id"])[0] + if domain in [Platform.BINARY_SENSOR, Platform.COVER]: + continue + entity["device_class"] = None - if old_major_version == 1 and old_minor_version < 9: - # Version 1.9 adds translation_key - for entity in data["entities"]: - entity["translation_key"] = None + if old_minor_version < 9: + # Version 1.9 adds translation_key + for entity in data["entities"]: + entity["translation_key"] = None - if old_major_version == 1 and old_minor_version < 10: - # Version 1.10 adds aliases - for entity in data["entities"]: - entity["aliases"] = [] + if old_minor_version < 10: + # Version 1.10 adds aliases + for entity in data["entities"]: + entity["aliases"] = [] - if old_major_version == 1 and old_minor_version < 11: - # Version 1.11 adds deleted_entities - data["deleted_entities"] = data.get("deleted_entities", []) + if old_minor_version < 11: + # Version 1.11 adds deleted_entities + data["deleted_entities"] = data.get("deleted_entities", []) - if old_major_version == 1 and old_minor_version < 12: - # Version 1.12 adds previous_unique_id - for entity in data["entities"]: - entity["previous_unique_id"] = None + if old_minor_version < 12: + # Version 1.12 adds previous_unique_id + for entity in data["entities"]: + entity["previous_unique_id"] = None - if old_major_version == 1 and old_minor_version < 13: - # Version 1.13 adds labels - for entity in data["entities"]: - entity["labels"] = [] + if old_minor_version < 13: + # Version 1.13 adds labels + for entity in data["entities"]: + entity["labels"] = [] - if old_major_version == 1 and old_minor_version < 14: - # Version 1.14 adds categories - for entity in data["entities"]: - entity["categories"] = {} + if old_minor_version < 14: + # Version 1.14 adds categories + for entity in data["entities"]: + entity["categories"] = {} + + if old_minor_version < 15: + # Version 1.15 adds created_at and modified_at + created_at = utc_from_timestamp(0).isoformat() + for entity in data["entities"]: + entity["created_at"] = entity["modified_at"] = created_at + for entity in data["deleted_entities"]: + entity["created_at"] = entity["modified_at"] = created_at if old_major_version > 1: raise NotImplementedError @@ -837,10 +857,12 @@ class EntityRegistry(BaseRegistry): ) entity_registry_id: str | None = None + created_at = utcnow() deleted_entity = self.deleted_entities.pop((domain, platform, unique_id), None) if deleted_entity is not None: # Restore id entity_registry_id = deleted_entity.id + created_at = deleted_entity.created_at entity_id = self.async_generate_entity_id( domain, @@ -865,6 +887,7 @@ class EntityRegistry(BaseRegistry): entry = RegistryEntry( capabilities=none_if_undefined(capabilities), config_entry_id=none_if_undefined(config_entry_id), + created_at=created_at, device_id=none_if_undefined(device_id), disabled_by=disabled_by, entity_category=none_if_undefined(entity_category), @@ -906,6 +929,7 @@ class EntityRegistry(BaseRegistry): orphaned_timestamp = None if config_entry_id else time.time() self.deleted_entities[key] = DeletedRegistryEntry( config_entry_id=config_entry_id, + created_at=entity.created_at, entity_id=entity_id, id=entity.id, orphaned_timestamp=orphaned_timestamp, @@ -1093,6 +1117,8 @@ class EntityRegistry(BaseRegistry): if not new_values: return old + new_values["modified_at"] = utcnow() + self.hass.verify_event_loop_thread("entity_registry.async_update_entity") new = self.entities[entity_id] = attr.evolve(old, **new_values) @@ -1260,6 +1286,7 @@ class EntityRegistry(BaseRegistry): categories=entity["categories"], capabilities=entity["capabilities"], config_entry_id=entity["config_entry_id"], + created_at=entity["created_at"], device_class=entity["device_class"], device_id=entity["device_id"], disabled_by=RegistryEntryDisabler(entity["disabled_by"]) @@ -1276,6 +1303,7 @@ class EntityRegistry(BaseRegistry): id=entity["id"], has_entity_name=entity["has_entity_name"], labels=set(entity["labels"]), + modified_at=entity["modified_at"], name=entity["name"], options=entity["options"], original_device_class=entity["original_device_class"], @@ -1307,8 +1335,10 @@ class EntityRegistry(BaseRegistry): ) deleted_entities[key] = DeletedRegistryEntry( config_entry_id=entity["config_entry_id"], + created_at=entity["created_at"], entity_id=entity["entity_id"], id=entity["id"], + modified_at=entity["modified_at"], orphaned_timestamp=entity["orphaned_timestamp"], platform=entity["platform"], unique_id=entity["unique_id"], diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 813ec654abb..60657d4a77b 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -1,5 +1,8 @@ """Test entity_registry API.""" +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory import pytest from pytest_unordered import unordered @@ -13,6 +16,7 @@ from homeassistant.helpers.entity_registry import ( RegistryEntryDisabler, RegistryEntryHider, ) +from homeassistant.util.dt import utcnow from tests.common import ( ANY, @@ -33,6 +37,7 @@ async def client( return await hass_ws_client(hass) +@pytest.mark.usefixtures("freezer") async def test_list_entities( hass: HomeAssistant, client: MockHAClientWebSocket ) -> None: @@ -62,6 +67,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, "entity_category": None, @@ -71,6 +77,7 @@ async def test_list_entities( "icon": None, "id": ANY, "labels": [], + "modified_at": utcnow().timestamp(), "name": "Hello World", "options": {}, "original_name": None, @@ -82,6 +89,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, "entity_category": None, @@ -91,6 +99,7 @@ async def test_list_entities( "icon": None, "id": ANY, "labels": [], + "modified_at": utcnow().timestamp(), "name": None, "options": {}, "original_name": None, @@ -129,6 +138,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, "entity_category": None, @@ -138,6 +148,7 @@ async def test_list_entities( "icon": None, "id": ANY, "labels": [], + "modified_at": utcnow().timestamp(), "name": "Hello World", "options": {}, "original_name": None, @@ -325,6 +336,8 @@ async def test_list_entities_for_display( async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> None: """Test get entry.""" + name_created_at = datetime(1994, 2, 14, 12, 0, 0) + no_name_created_at = datetime(2024, 2, 14, 12, 0, 1) mock_registry( hass, { @@ -333,11 +346,15 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> unique_id="1234", platform="test_platform", name="Hello World", + created_at=name_created_at, + modified_at=name_created_at, ), "test_domain.no_name": RegistryEntry( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", + created_at=no_name_created_at, + modified_at=no_name_created_at, ), }, ) @@ -353,6 +370,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": name_created_at.timestamp(), "device_class": None, "device_id": None, "disabled_by": None, @@ -363,6 +381,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "icon": None, "id": ANY, "labels": [], + "modified_at": name_created_at.timestamp(), "name": "Hello World", "options": {}, "original_device_class": None, @@ -387,6 +406,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": no_name_created_at.timestamp(), "device_class": None, "device_id": None, "disabled_by": None, @@ -397,6 +417,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "icon": None, "id": ANY, "labels": [], + "modified_at": no_name_created_at.timestamp(), "name": None, "options": {}, "original_device_class": None, @@ -410,6 +431,8 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) -> None: """Test get entry.""" + name_created_at = datetime(1994, 2, 14, 12, 0, 0) + no_name_created_at = datetime(2024, 2, 14, 12, 0, 1) mock_registry( hass, { @@ -418,11 +441,15 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) unique_id="1234", platform="test_platform", name="Hello World", + created_at=name_created_at, + modified_at=name_created_at, ), "test_domain.no_name": RegistryEntry( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", + created_at=no_name_created_at, + modified_at=no_name_created_at, ), }, ) @@ -446,6 +473,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": name_created_at.timestamp(), "device_class": None, "device_id": None, "disabled_by": None, @@ -456,6 +484,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "icon": None, "id": ANY, "labels": [], + "modified_at": name_created_at.timestamp(), "name": "Hello World", "options": {}, "original_device_class": None, @@ -471,6 +500,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": no_name_created_at.timestamp(), "device_class": None, "device_id": None, "disabled_by": None, @@ -481,6 +511,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "icon": None, "id": ANY, "labels": [], + "modified_at": no_name_created_at.timestamp(), "name": None, "options": {}, "original_device_class": None, @@ -495,9 +526,11 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) async def test_update_entity( - hass: HomeAssistant, client: MockHAClientWebSocket + hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory ) -> None: """Test updating entity.""" + created = datetime.fromisoformat("2024-02-14T12:00:00.900075+00:00") + freezer.move_to(created) registry = mock_registry( hass, { @@ -520,6 +553,9 @@ async def test_update_entity( assert state.name == "before update" assert state.attributes[ATTR_ICON] == "icon:before update" + modified = datetime.fromisoformat("2024-07-17T13:30:00.900075+00:00") + freezer.move_to(modified) + # Update area, categories, device_class, hidden_by, icon, labels & name await client.send_json_auto_id( { @@ -544,6 +580,7 @@ async def test_update_entity( "area_id": "mock-area-id", "capabilities": None, "categories": {"scope1": "id", "scope2": "id"}, + "created_at": created.timestamp(), "config_entry_id": None, "device_class": "custom_device_class", "device_id": None, @@ -555,6 +592,7 @@ async def test_update_entity( "icon": "icon:after update", "id": ANY, "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), "name": "after update", "options": {}, "original_device_class": None, @@ -570,6 +608,9 @@ async def test_update_entity( assert state.name == "after update" assert state.attributes[ATTR_ICON] == "icon:after update" + modified = datetime.fromisoformat("2024-07-20T00:00:00.900075+00:00") + freezer.move_to(modified) + # Update hidden_by to illegal value await client.send_json_auto_id( { @@ -597,9 +638,13 @@ async def test_update_entity( assert msg["success"] assert hass.states.get("test_domain.world") is None - assert ( - registry.entities["test_domain.world"].disabled_by is RegistryEntryDisabler.USER - ) + entry = registry.entities["test_domain.world"] + assert entry.disabled_by is RegistryEntryDisabler.USER + assert entry.created_at == created + assert entry.modified_at == modified + + modified = datetime.fromisoformat("2024-07-21T00:00:00.900075+00:00") + freezer.move_to(modified) # Update disabled_by to None await client.send_json_auto_id( @@ -619,6 +664,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id"}, "config_entry_id": None, + "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, "disabled_by": None, @@ -629,6 +675,7 @@ async def test_update_entity( "icon": "icon:after update", "id": ANY, "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), "name": "after update", "options": {}, "original_device_class": None, @@ -641,6 +688,9 @@ async def test_update_entity( "require_restart": True, } + modified = datetime.fromisoformat("2024-07-22T00:00:00.900075+00:00") + freezer.move_to(modified) + # Update entity option await client.send_json_auto_id( { @@ -660,6 +710,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id"}, "config_entry_id": None, + "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, "disabled_by": None, @@ -670,6 +721,7 @@ async def test_update_entity( "icon": "icon:after update", "id": ANY, "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), "name": "after update", "options": {"sensor": {"unit_of_measurement": "beard_second"}}, "original_device_class": None, @@ -681,6 +733,9 @@ async def test_update_entity( }, } + modified = datetime.fromisoformat("2024-07-23T00:00:00.900075+00:00") + freezer.move_to(modified) + # Add a category to the entity await client.send_json_auto_id( { @@ -700,6 +755,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id", "scope3": "id"}, "config_entry_id": None, + "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, "disabled_by": None, @@ -710,6 +766,7 @@ async def test_update_entity( "icon": "icon:after update", "id": ANY, "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), "name": "after update", "options": {"sensor": {"unit_of_measurement": "beard_second"}}, "original_device_class": None, @@ -721,6 +778,9 @@ async def test_update_entity( }, } + modified = datetime.fromisoformat("2024-07-24T00:00:00.900075+00:00") + freezer.move_to(modified) + # Move the entity to a different category await client.send_json_auto_id( { @@ -740,6 +800,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id", "scope3": "other_id"}, "config_entry_id": None, + "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, "disabled_by": None, @@ -750,6 +811,7 @@ async def test_update_entity( "icon": "icon:after update", "id": ANY, "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), "name": "after update", "options": {"sensor": {"unit_of_measurement": "beard_second"}}, "original_device_class": None, @@ -761,6 +823,9 @@ async def test_update_entity( }, } + modified = datetime.fromisoformat("2024-07-23T10:00:00.900075+00:00") + freezer.move_to(modified) + # Move the entity to a different category await client.send_json_auto_id( { @@ -780,6 +845,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope3": "other_id"}, "config_entry_id": None, + "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, "disabled_by": None, @@ -790,6 +856,7 @@ async def test_update_entity( "icon": "icon:after update", "id": ANY, "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), "name": "after update", "options": {"sensor": {"unit_of_measurement": "beard_second"}}, "original_device_class": None, @@ -803,9 +870,11 @@ async def test_update_entity( async def test_update_entity_require_restart( - hass: HomeAssistant, client: MockHAClientWebSocket + hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory ) -> None: """Test updating entity.""" + created = datetime.fromisoformat("2024-02-14T12:00:00+00:00") + freezer.move_to(created) entity_id = "test_domain.test_platform_1234" config_entry = MockConfigEntry(domain="test_platform") config_entry.add_to_hass(hass) @@ -817,6 +886,9 @@ async def test_update_entity_require_restart( state = hass.states.get(entity_id) assert state is not None + modified = datetime.fromisoformat("2024-07-20T13:30:00+00:00") + freezer.move_to(modified) + # UPDATE DISABLED_BY TO NONE await client.send_json_auto_id( { @@ -835,6 +907,7 @@ async def test_update_entity_require_restart( "capabilities": None, "categories": {}, "config_entry_id": config_entry.entry_id, + "created_at": created.timestamp(), "device_class": None, "device_id": None, "disabled_by": None, @@ -845,6 +918,7 @@ async def test_update_entity_require_restart( "icon": None, "id": ANY, "labels": [], + "modified_at": created.timestamp(), "name": None, "options": {}, "original_device_class": None, @@ -909,9 +983,11 @@ async def test_enable_entity_disabled_device( async def test_update_entity_no_changes( - hass: HomeAssistant, client: MockHAClientWebSocket + hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory ) -> None: """Test update entity with no changes.""" + created = datetime.fromisoformat("2024-02-14T12:00:00.900075+00:00") + freezer.move_to(created) mock_registry( hass, { @@ -932,6 +1008,9 @@ async def test_update_entity_no_changes( assert state is not None assert state.name == "name of entity" + modified = datetime.fromisoformat("2024-07-20T13:30:00.900075+00:00") + freezer.move_to(modified) + await client.send_json_auto_id( { "type": "config/entity_registry/update", @@ -949,6 +1028,7 @@ async def test_update_entity_no_changes( "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": created.timestamp(), "device_class": None, "device_id": None, "disabled_by": None, @@ -959,6 +1039,7 @@ async def test_update_entity_no_changes( "icon": None, "id": ANY, "labels": [], + "modified_at": created.timestamp(), "name": "name of entity", "options": {}, "original_device_class": None, @@ -1002,9 +1083,11 @@ async def test_update_nonexisting_entity(client: MockHAClientWebSocket) -> None: async def test_update_entity_id( - hass: HomeAssistant, client: MockHAClientWebSocket + hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory ) -> None: """Test update entity id.""" + created = datetime.fromisoformat("2024-02-14T12:00:00.900075+00:00") + freezer.move_to(created) mock_registry( hass, { @@ -1022,6 +1105,9 @@ async def test_update_entity_id( assert hass.states.get("test_domain.world") is not None + modified = datetime.fromisoformat("2024-07-20T13:30:00.900075+00:00") + freezer.move_to(modified) + await client.send_json_auto_id( { "type": "config/entity_registry/update", @@ -1039,6 +1125,7 @@ async def test_update_entity_id( "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": created.timestamp(), "device_class": None, "device_id": None, "disabled_by": None, @@ -1049,6 +1136,7 @@ async def test_update_entity_id( "icon": None, "id": ANY, "labels": [], + "modified_at": modified.timestamp(), "name": None, "options": {}, "original_device_class": None, diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 02e57734b3a..c443e56b3a4 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -287,6 +287,8 @@ async def test_snapshots( entry = asdict(entity_entry) entry.pop("id", None) entry.pop("device_id", None) + entry.pop("created_at", None) + entry.pop("modified_at", None) entities.append({"entry": entry, "state": state_dict}) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index ff08eb5de04..75a41945a91 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1422,6 +1422,7 @@ async def test_entity_hidden_by_integration( assert entry_hidden.hidden_by is er.RegistryEntryHider.INTEGRATION +@pytest.mark.usefixtures("freezer") async def test_entity_info_added_to_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -1450,11 +1451,13 @@ async def test_entity_info_added_to_entity_registry( "default", "test_domain", capabilities={"max": 100}, + created_at=dt_util.utcnow(), device_class=None, entity_category=EntityCategory.CONFIG, has_entity_name=True, icon=None, id=ANY, + modified_at=dt_util.utcnow(), name=None, original_device_class="mock-device-class", original_icon="nice:icon", diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 4dc8d79be3f..afcd0d0ed2e 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,6 +1,6 @@ """Tests for the Entity Registry.""" -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial from typing import Any from unittest.mock import patch @@ -21,6 +21,7 @@ from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import ( + ANY, MockConfigEntry, async_capture_events, async_fire_time_changed, @@ -69,9 +70,14 @@ def test_get_or_create_suggested_object_id(entity_registry: er.EntityRegistry) - assert entry.entity_id == "light.beer" -def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: +def test_get_or_create_updates_data( + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: """Test that we update data in get_or_create.""" orig_config_entry = MockConfigEntry(domain="light") + created = datetime.fromisoformat("2024-02-14T12:00:00.0+00:00") + freezer.move_to(created) orig_entry = entity_registry.async_get_or_create( "light", @@ -100,6 +106,7 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: "hue", capabilities={"max": 100}, config_entry_id=orig_config_entry.entry_id, + created_at=created, device_class=None, device_id="mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, @@ -108,6 +115,7 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: hidden_by=er.RegistryEntryHider.INTEGRATION, icon=None, id=orig_entry.id, + modified_at=created, name=None, original_device_class="mock-device-class", original_icon="initial-original_icon", @@ -118,6 +126,8 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: ) new_config_entry = MockConfigEntry(domain="light") + modified = created + timedelta(minutes=5) + freezer.move_to(modified) new_entry = entity_registry.async_get_or_create( "light", @@ -146,6 +156,7 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: area_id=None, capabilities={"new-max": 150}, config_entry_id=new_config_entry.entry_id, + created_at=created, device_class=None, device_id="new-mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, # Should not be updated @@ -154,6 +165,7 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: hidden_by=er.RegistryEntryHider.INTEGRATION, # Should not be updated icon=None, id=orig_entry.id, + modified_at=modified, name=None, original_device_class="new-mock-device-class", original_icon="updated-original_icon", @@ -164,6 +176,8 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: ) assert set(entity_registry.async_device_ids()) == {"new-mock-dev-id"} + modified = created + timedelta(minutes=5) + freezer.move_to(modified) new_entry = entity_registry.async_get_or_create( "light", @@ -192,6 +206,7 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: area_id=None, capabilities=None, config_entry_id=None, + created_at=created, device_class=None, device_id=None, disabled_by=er.RegistryEntryDisabler.HASS, # Should not be updated @@ -200,6 +215,7 @@ def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: hidden_by=er.RegistryEntryHider.INTEGRATION, # Should not be updated icon=None, id=orig_entry.id, + modified_at=modified, name=None, original_device_class=None, original_icon=None, @@ -309,8 +325,12 @@ async def test_loading_saving_data( assert orig_entry1 == new_entry1 assert orig_entry2 == new_entry2 - assert orig_entry3 == new_entry3 - assert orig_entry4 == new_entry4 + + # By converting a deleted device to a active device, the modified_at will be updated + assert orig_entry3.modified_at < new_entry3.modified_at + assert attr.evolve(orig_entry3, modified_at=new_entry3.modified_at) == new_entry3 + assert orig_entry4.modified_at < new_entry4.modified_at + assert attr.evolve(orig_entry4, modified_at=new_entry4.modified_at) == new_entry4 assert new_entry2.area_id == "mock-area-id" assert new_entry2.categories == {"scope", "id"} @@ -453,6 +473,7 @@ async def test_load_bad_data( "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "device_id": None, "disabled_by": None, @@ -463,6 +484,7 @@ async def test_load_bad_data( "icon": None, "id": "00001", "labels": [], + "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, "options": None, "original_device_class": None, @@ -481,6 +503,7 @@ async def test_load_bad_data( "capabilities": None, "categories": {}, "config_entry_id": None, + "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "device_id": None, "disabled_by": None, @@ -491,6 +514,7 @@ async def test_load_bad_data( "icon": None, "id": "00002", "labels": [], + "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, "options": None, "original_device_class": None, @@ -507,16 +531,20 @@ async def test_load_bad_data( "deleted_entities": [ { "config_entry_id": None, + "created_at": "2024-02-14T12:00:00.900075+00:00", "entity_id": "test.test3", "id": "00003", + "modified_at": "2024-02-14T12:00:00.900075+00:00", "orphaned_timestamp": None, "platform": "super_platform", "unique_id": 234, # Should not load }, { "config_entry_id": None, + "created_at": "2024-02-14T12:00:00.900075+00:00", "entity_id": "test.test4", "id": "00004", + "modified_at": "2024-02-14T12:00:00.900075+00:00", "orphaned_timestamp": None, "platform": "super_platform", "unique_id": ["also", "not", "valid"], # Should not load @@ -695,6 +723,49 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) assert entry.device_class is None assert entry.original_device_class == "best_class" + # Check we store migrated data + await flush_store(registry._store) + assert hass_storage[er.STORAGE_KEY] == { + "version": er.STORAGE_VERSION_MAJOR, + "minor_version": er.STORAGE_VERSION_MINOR, + "key": er.STORAGE_KEY, + "data": { + "entities": [ + { + "aliases": [], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": None, + "disabled_by": None, + "entity_category": None, + "entity_id": "test.entity", + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "id": ANY, + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "original_device_class": "best_class", + "original_icon": None, + "original_name": None, + "platform": "super_platform", + "previous_unique_id": None, + "supported_features": 0, + "translation_key": None, + "unique_id": "very_unique", + "unit_of_measurement": None, + "device_class": None, + } + ], + "deleted_entities": [], + }, + } + @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_7(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: diff --git a/tests/syrupy.py b/tests/syrupy.py index 80d955f0de1..09e18428015 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -181,7 +181,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): } ) serialized.pop("categories") - return serialized + return cls._remove_created_and_modified_at(serialized) @classmethod def _serializable_flow_result(cls, data: FlowResult) -> SerializableData: