Add created_at/modified_at to entity registry (#122444)

This commit is contained in:
Robert Resch 2024-07-23 13:12:29 +02:00 committed by GitHub
parent 8d14095cb9
commit 0d765a27c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 277 additions and 83 deletions

View File

@ -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,7 +440,8 @@ 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:
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
@ -447,34 +459,34 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
entity.setdefault("supported_features", 0)
entity.setdefault("unit_of_measurement", None)
if old_major_version == 1 and old_minor_version < 3:
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:
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:
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:
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:
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:
if old_minor_version < 8:
# Cleanup after frontend bug which incorrectly updated device_class
# Fixed by frontend PR #13551
for entity in data["entities"]:
@ -483,35 +495,43 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
continue
entity["device_class"] = None
if old_major_version == 1 and old_minor_version < 9:
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:
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:
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:
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:
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:
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
return data
@ -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"],

View File

@ -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,

View File

@ -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})

View File

@ -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",

View File

@ -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:

View File

@ -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: