diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index e3f8df136c5..b8fb3ae0219 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Mapping +from datetime import datetime from enum import StrEnum from functools import cached_property, lru_cache, partial import logging @@ -23,6 +24,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue +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 @@ -94,6 +96,7 @@ class DeviceInfo(TypedDict, total=False): configuration_url: str | URL | None connections: set[tuple[str, str]] + created_at: str default_manufacturer: str default_model: str default_name: str @@ -102,6 +105,7 @@ class DeviceInfo(TypedDict, total=False): manufacturer: str | None model: str | None model_id: str | None + modified_at: str name: str | None serial_number: str | None suggested_area: str | None @@ -281,6 +285,7 @@ class DeviceEntry: config_entries: set[str] = attr.ib(converter=set, factory=set) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set) + created_at: datetime = attr.ib(factory=utcnow) disabled_by: DeviceEntryDisabler | None = attr.ib(default=None) entry_type: DeviceEntryType | None = attr.ib(default=None) hw_version: str | None = attr.ib(default=None) @@ -290,6 +295,7 @@ class DeviceEntry: manufacturer: str | None = attr.ib(default=None) model: str | None = attr.ib(default=None) model_id: str | None = attr.ib(default=None) + modified_at: datetime = attr.ib(factory=utcnow) name_by_user: str | None = attr.ib(default=None) name: str | None = attr.ib(default=None) primary_config_entry: str | None = attr.ib(default=None) @@ -316,6 +322,7 @@ class DeviceEntry: "configuration_url": self.configuration_url, "config_entries": list(self.config_entries), "connections": list(self.connections), + "created_at": self.created_at.timestamp(), "disabled_by": self.disabled_by, "entry_type": self.entry_type, "hw_version": self.hw_version, @@ -325,6 +332,7 @@ class DeviceEntry: "manufacturer": self.manufacturer, "model": self.model, "model_id": self.model_id, + "modified_at": self.modified_at.timestamp(), "name_by_user": self.name_by_user, "name": self.name, "primary_config_entry": self.primary_config_entry, @@ -359,6 +367,7 @@ class DeviceEntry: "config_entries": list(self.config_entries), "configuration_url": self.configuration_url, "connections": list(self.connections), + "created_at": self.created_at.isoformat(), "disabled_by": self.disabled_by, "entry_type": self.entry_type, "hw_version": self.hw_version, @@ -368,6 +377,7 @@ class DeviceEntry: "manufacturer": self.manufacturer, "model": self.model, "model_id": self.model_id, + "modified_at": self.modified_at.isoformat(), "name_by_user": self.name_by_user, "name": self.name, "primary_config_entry": self.primary_config_entry, @@ -388,6 +398,8 @@ class DeletedDeviceEntry: identifiers: set[tuple[str, str]] = attr.ib() id: str = attr.ib() orphaned_timestamp: float | None = attr.ib() + created_at: datetime = attr.ib(factory=utcnow) + modified_at: datetime = attr.ib(factory=utcnow) def to_device_entry( self, @@ -400,6 +412,7 @@ class DeletedDeviceEntry: # type ignores: likely https://github.com/python/mypy/issues/8625 config_entries={config_entry_id}, # type: ignore[arg-type] connections=self.connections & connections, # type: ignore[arg-type] + created_at=self.created_at, identifiers=self.identifiers & identifiers, # type: ignore[arg-type] id=self.id, is_new=True, @@ -413,9 +426,11 @@ class DeletedDeviceEntry: { "config_entries": list(self.config_entries), "connections": list(self.connections), + "created_at": self.created_at.isoformat(), "identifiers": list(self.identifiers), "id": self.id, "orphaned_timestamp": self.orphaned_timestamp, + "modified_at": self.modified_at.isoformat(), } ) ) @@ -490,8 +505,12 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): device.setdefault("primary_config_entry", None) if old_minor_version < 7: # Introduced in 2024.8 + created_at = utc_from_timestamp(0).isoformat() for device in old_data["devices"]: device.setdefault("model_id", None) + device["created_at"] = device["modified_at"] = created_at + for device in old_data["deleted_devices"]: + device["created_at"] = device["modified_at"] = created_at if old_major_version > 1: raise NotImplementedError @@ -688,6 +707,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): config_entry_id: str, configuration_url: str | URL | None | UndefinedType = UNDEFINED, connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED, + created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored default_manufacturer: str | None | UndefinedType = UNDEFINED, default_model: str | None | UndefinedType = UNDEFINED, default_name: str | None | UndefinedType = UNDEFINED, @@ -699,6 +719,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): manufacturer: str | None | UndefinedType = UNDEFINED, model: str | None | UndefinedType = UNDEFINED, model_id: str | None | UndefinedType = UNDEFINED, + modified_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored name: str | None | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, @@ -1035,6 +1056,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if not new_values: return old + if not RUNTIME_ONLY_ATTRS.issuperset(new_values): + # Change modified_at if we are changing something that we store + new_values["modified_at"] = utcnow() + self.hass.verify_event_loop_thread("device_registry.async_update_device") new = attr.evolve(old, **new_values) self.devices[device_id] = new @@ -1114,6 +1139,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.deleted_devices[device_id] = DeletedDeviceEntry( config_entries=device.config_entries, connections=device.connections, + created_at=device.created_at, identifiers=device.identifiers, id=device.id, orphaned_timestamp=None, @@ -1149,6 +1175,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): tuple(conn) # type: ignore[misc] for conn in device["connections"] }, + created_at=datetime.fromisoformat(device["created_at"]), disabled_by=( DeviceEntryDisabler(device["disabled_by"]) if device["disabled_by"] @@ -1169,6 +1196,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): manufacturer=device["manufacturer"], model=device["model"], model_id=device["model_id"], + modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], name=device["name"], primary_config_entry=device["primary_config_entry"], @@ -1181,8 +1209,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): deleted_devices[device["id"]] = DeletedDeviceEntry( config_entries=set(device["config_entries"]), connections={tuple(conn) for conn in device["connections"]}, + created_at=datetime.fromisoformat(device["created_at"]), identifiers={tuple(iden) for iden in device["identifiers"]}, id=device["id"], + modified_at=datetime.fromisoformat(device["modified_at"]), orphaned_timestamp=device["orphaned_timestamp"], ) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 6e82cc8ee25..aab898f5fd6 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -1,5 +1,8 @@ """Test device_registry API.""" +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory import pytest from pytest_unordered import unordered @@ -7,6 +10,7 @@ from homeassistant.components.config import device_registry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, MockModule, mock_integration from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -26,6 +30,7 @@ async def client_fixture( return await hass_ws_client(hass) +@pytest.mark.usefixtures("freezer") async def test_list_devices( hass: HomeAssistant, client: MockHAClientWebSocket, @@ -61,6 +66,7 @@ async def test_list_devices( "config_entries": [entry.entry_id], "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], + "created_at": utcnow().timestamp(), "disabled_by": None, "entry_type": None, "hw_version": None, @@ -69,6 +75,7 @@ async def test_list_devices( "manufacturer": "manufacturer", "model": "model", "model_id": None, + "modified_at": utcnow().timestamp(), "name_by_user": None, "name": None, "primary_config_entry": entry.entry_id, @@ -81,6 +88,7 @@ async def test_list_devices( "config_entries": [entry.entry_id], "configuration_url": None, "connections": [], + "created_at": utcnow().timestamp(), "disabled_by": None, "entry_type": dr.DeviceEntryType.SERVICE, "hw_version": None, @@ -89,6 +97,7 @@ async def test_list_devices( "manufacturer": "manufacturer", "model": "model", "model_id": None, + "modified_at": utcnow().timestamp(), "name_by_user": None, "name": None, "primary_config_entry": entry.entry_id, @@ -113,6 +122,7 @@ async def test_list_devices( "config_entries": [entry.entry_id], "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], + "created_at": utcnow().timestamp(), "disabled_by": None, "entry_type": None, "hw_version": None, @@ -122,6 +132,7 @@ async def test_list_devices( "manufacturer": "manufacturer", "model": "model", "model_id": None, + "modified_at": utcnow().timestamp(), "name_by_user": None, "name": None, "primary_config_entry": entry.entry_id, @@ -151,12 +162,15 @@ async def test_update_device( hass: HomeAssistant, client: MockHAClientWebSocket, device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, payload_key: str, payload_value: str | dr.DeviceEntryDisabler | None, ) -> None: """Test update entry.""" entry = MockConfigEntry(title=None) entry.add_to_hass(hass) + created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") + freezer.move_to(created_at) device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, @@ -167,6 +181,9 @@ async def test_update_device( assert not getattr(device, payload_key) + modified_at = datetime.fromisoformat("2024-07-16T13:45:00.900075+00:00") + freezer.move_to(modified_at) + await client.send_json_auto_id( { "type": "config/device_registry/update", @@ -186,6 +203,12 @@ async def test_update_device( assert msg["result"][payload_key] == payload_value assert getattr(device, payload_key) == payload_value + for key, value in ( + ("created_at", created_at), + ("modified_at", modified_at if payload_value is not None else created_at), + ): + assert msg["result"][key] == value.timestamp() + assert getattr(device, key) == value assert isinstance(device.disabled_by, (dr.DeviceEntryDisabler, type(None))) @@ -194,10 +217,13 @@ async def test_update_device_labels( hass: HomeAssistant, client: MockHAClientWebSocket, device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test update entry labels.""" entry = MockConfigEntry(title=None) entry.add_to_hass(hass) + created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") + freezer.move_to(created_at) device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, @@ -207,6 +233,8 @@ async def test_update_device_labels( ) assert not device.labels + modified_at = datetime.fromisoformat("2024-07-16T13:45:00.900075+00:00") + freezer.move_to(modified_at) await client.send_json_auto_id( { @@ -227,6 +255,12 @@ async def test_update_device_labels( assert msg["result"]["labels"] == unordered(["label1", "label2"]) assert device.labels == {"label1", "label2"} + for key, value in ( + ("created_at", created_at), + ("modified_at", modified_at), + ): + assert msg["result"][key] == value.timestamp() + assert getattr(device, key) == value async def test_remove_config_entry_from_device( diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index c6494f7743f..186ee5c46f3 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -26,6 +26,8 @@ TO_EXCLUDE = { "last_updated", "last_changed", "last_reported", + "created_at", + "modified_at", } diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index f3952298326..02e57734b3a 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -293,6 +293,8 @@ async def test_snapshots( device_dict = asdict(device) device_dict.pop("id", None) device_dict.pop("via_device_id", None) + device_dict.pop("created_at", None) + device_dict.pop("modified_at", None) devices.append({"device": device_dict, "entities": entities}) assert snapshot == devices diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 569da219ef1..ffbc78ac463 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2,11 +2,13 @@ from collections.abc import Iterable from contextlib import AbstractContextManager, nullcontext +from datetime import datetime from functools import partial import time from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from yarl import URL @@ -19,6 +21,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) +from homeassistant.util.dt import utcnow from tests.common import ( MockConfigEntry, @@ -177,12 +180,15 @@ async def test_multiple_config_entries( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") async def test_loading_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any], mock_config_entry: MockConfigEntry, ) -> None: """Test loading stored devices on start.""" + created_at = "2024-01-01T00:00:00+00:00" + modified_at = "2024-02-01T00:00:00+00:00" hass_storage[dr.STORAGE_KEY] = { "version": dr.STORAGE_VERSION_MAJOR, "minor_version": dr.STORAGE_VERSION_MINOR, @@ -193,6 +199,7 @@ async def test_loading_from_storage( "config_entries": [mock_config_entry.entry_id], "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], + "created_at": created_at, "disabled_by": dr.DeviceEntryDisabler.USER, "entry_type": dr.DeviceEntryType.SERVICE, "hw_version": "hw_version", @@ -202,6 +209,7 @@ async def test_loading_from_storage( "manufacturer": "manufacturer", "model": "model", "model_id": "model_id", + "modified_at": modified_at, "name_by_user": "Test Friendly Name", "name": "name", "primary_config_entry": mock_config_entry.entry_id, @@ -214,8 +222,10 @@ async def test_loading_from_storage( { "config_entries": [mock_config_entry.entry_id], "connections": [["Zigbee", "23.45.67.89.01"]], + "created_at": created_at, "id": "bcdefghijklmn", "identifiers": [["serial", "3456ABCDEF12"]], + "modified_at": modified_at, "orphaned_timestamp": None, } ], @@ -227,6 +237,16 @@ async def test_loading_from_storage( assert len(registry.devices) == 1 assert len(registry.deleted_devices) == 1 + assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( + config_entries={mock_config_entry.entry_id}, + connections={("Zigbee", "23.45.67.89.01")}, + created_at=datetime.fromisoformat(created_at), + id="bcdefghijklmn", + identifiers={("serial", "3456ABCDEF12")}, + modified_at=datetime.fromisoformat(modified_at), + orphaned_timestamp=None, + ) + entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, @@ -239,6 +259,7 @@ async def test_loading_from_storage( config_entries={mock_config_entry.entry_id}, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, + created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, entry_type=dr.DeviceEntryType.SERVICE, hw_version="hw_version", @@ -248,6 +269,7 @@ async def test_loading_from_storage( manufacturer="manufacturer", model="model", model_id="model_id", + modified_at=datetime.fromisoformat(modified_at), name_by_user="Test Friendly Name", name="name", primary_config_entry=mock_config_entry.entry_id, @@ -270,10 +292,12 @@ async def test_loading_from_storage( assert entry == dr.DeviceEntry( config_entries={mock_config_entry.entry_id}, connections={("Zigbee", "23.45.67.89.01")}, + created_at=datetime.fromisoformat(created_at), id="bcdefghijklmn", identifiers={("serial", "3456ABCDEF12")}, manufacturer="manufacturer", model="model", + modified_at=utcnow(), primary_config_entry=mock_config_entry.entry_id, ) assert entry.id == "bcdefghijklmn" @@ -283,6 +307,7 @@ async def test_loading_from_storage( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") async def test_migration_1_1_to_1_7( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -367,6 +392,7 @@ async def test_migration_1_1_to_1_7( "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": "service", "hw_version": None, @@ -376,6 +402,7 @@ async def test_migration_1_1_to_1_7( "manufacturer": "manufacturer", "model": "model", "model_id": None, + "modified_at": utcnow().isoformat(), "name": "name", "name_by_user": None, "primary_config_entry": mock_config_entry.entry_id, @@ -388,6 +415,7 @@ async def test_migration_1_1_to_1_7( "config_entries": [None], "configuration_url": None, "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": None, "hw_version": None, @@ -397,6 +425,7 @@ async def test_migration_1_1_to_1_7( "manufacturer": None, "model": None, "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, "primary_config_entry": None, @@ -409,8 +438,10 @@ async def test_migration_1_1_to_1_7( { "config_entries": ["123456"], "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", "id": "deletedid", "identifiers": [["serial", "123456ABCDFF"]], + "modified_at": "1970-01-01T00:00:00+00:00", "orphaned_timestamp": None, } ], @@ -419,6 +450,7 @@ async def test_migration_1_1_to_1_7( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") async def test_migration_1_2_to_1_7( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -442,6 +474,7 @@ async def test_migration_1_2_to_1_7( "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", + "modified_at": utcnow().isoformat(), "name": "name", "name_by_user": None, "sw_version": "version", @@ -458,6 +491,7 @@ async def test_migration_1_2_to_1_7( "identifiers": [["serial", "mock-id-invalid-entry"]], "manufacturer": None, "model": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, "sw_version": None, @@ -502,6 +536,7 @@ async def test_migration_1_2_to_1_7( "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": "service", "hw_version": None, @@ -511,6 +546,7 @@ async def test_migration_1_2_to_1_7( "manufacturer": "manufacturer", "model": "model", "model_id": None, + "modified_at": utcnow().isoformat(), "name": "name", "name_by_user": None, "primary_config_entry": mock_config_entry.entry_id, @@ -523,6 +559,7 @@ async def test_migration_1_2_to_1_7( "config_entries": [None], "configuration_url": None, "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": None, "hw_version": None, @@ -532,6 +569,7 @@ async def test_migration_1_2_to_1_7( "manufacturer": None, "model": None, "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, "primary_config_entry": None, @@ -546,6 +584,7 @@ async def test_migration_1_2_to_1_7( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") async def test_migration_1_3_to_1_7( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -631,6 +670,7 @@ async def test_migration_1_3_to_1_7( "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": "service", "hw_version": "hw_version", @@ -640,6 +680,7 @@ async def test_migration_1_3_to_1_7( "manufacturer": "manufacturer", "model": "model", "model_id": None, + "modified_at": utcnow().isoformat(), "name": "name", "name_by_user": None, "primary_config_entry": mock_config_entry.entry_id, @@ -652,6 +693,7 @@ async def test_migration_1_3_to_1_7( "config_entries": [None], "configuration_url": None, "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": None, "hw_version": None, @@ -661,6 +703,7 @@ async def test_migration_1_3_to_1_7( "manufacturer": None, "model": None, "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name": None, "name_by_user": None, "primary_config_entry": None, @@ -675,6 +718,7 @@ async def test_migration_1_3_to_1_7( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") async def test_migration_1_4_to_1_7( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -762,6 +806,7 @@ async def test_migration_1_4_to_1_7( "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": "service", "hw_version": "hw_version", @@ -771,6 +816,7 @@ async def test_migration_1_4_to_1_7( "manufacturer": "manufacturer", "model": "model", "model_id": None, + "modified_at": utcnow().isoformat(), "name": "name", "name_by_user": None, "primary_config_entry": mock_config_entry.entry_id, @@ -783,6 +829,7 @@ async def test_migration_1_4_to_1_7( "config_entries": [None], "configuration_url": None, "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": None, "hw_version": None, @@ -792,6 +839,7 @@ async def test_migration_1_4_to_1_7( "manufacturer": None, "model": None, "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, "primary_config_entry": None, @@ -806,6 +854,7 @@ async def test_migration_1_4_to_1_7( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") async def test_migration_1_5_to_1_7( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -895,6 +944,7 @@ async def test_migration_1_5_to_1_7( "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": "service", "hw_version": "hw_version", @@ -905,6 +955,7 @@ async def test_migration_1_5_to_1_7( "model": "model", "name": "name", "model_id": None, + "modified_at": utcnow().isoformat(), "name_by_user": None, "primary_config_entry": mock_config_entry.entry_id, "serial_number": None, @@ -916,6 +967,7 @@ async def test_migration_1_5_to_1_7( "config_entries": [None], "configuration_url": None, "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": None, "hw_version": None, @@ -925,6 +977,7 @@ async def test_migration_1_5_to_1_7( "manufacturer": None, "model": None, "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, "primary_config_entry": None, @@ -939,6 +992,7 @@ async def test_migration_1_5_to_1_7( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") async def test_migration_1_6_to_1_7( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -1030,6 +1084,7 @@ async def test_migration_1_6_to_1_7( "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": "service", "hw_version": "hw_version", @@ -1040,6 +1095,7 @@ async def test_migration_1_6_to_1_7( "model": "model", "name": "name", "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "primary_config_entry": mock_config_entry.entry_id, "serial_number": None, @@ -1051,6 +1107,7 @@ async def test_migration_1_6_to_1_7( "config_entries": [None], "configuration_url": None, "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": None, "hw_version": None, @@ -1060,6 +1117,7 @@ async def test_migration_1_6_to_1_7( "manufacturer": None, "model": None, "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, "primary_config_entry": None, @@ -1546,8 +1604,11 @@ async def test_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Verify that we can update some attributes of a device.""" + created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") + freezer.move_to(created_at) update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, @@ -1559,7 +1620,11 @@ async def test_update( assert not entry.area_id assert not entry.labels assert not entry.name_by_user + assert entry.created_at == created_at + assert entry.modified_at == created_at + modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00") + freezer.move_to(modified_at) with patch.object(device_registry, "async_schedule_save") as mock_save: updated_entry = device_registry.async_update_device( entry.id, @@ -1589,6 +1654,7 @@ async def test_update( config_entries={mock_config_entry.entry_id}, configuration_url="https://example.com/config", connections={("mac", "65:43:21:fe:dc:ba")}, + created_at=created_at, disabled_by=dr.DeviceEntryDisabler.USER, entry_type=dr.DeviceEntryType.SERVICE, hw_version="hw_version", @@ -1598,6 +1664,7 @@ async def test_update( manufacturer="Test Producer", model="Test Model", model_id="Test Model Name", + modified_at=modified_at, name_by_user="Test Friendly Name", name="name", serial_number="serial_no", @@ -2616,6 +2683,7 @@ async def test_loading_invalid_configuration_url_from_storage( "config_entries": ["1234"], "configuration_url": "invalid", "connections": [], + "created_at": "2024-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": dr.DeviceEntryType.SERVICE, "hw_version": None, @@ -2625,6 +2693,7 @@ async def test_loading_invalid_configuration_url_from_storage( "manufacturer": None, "model": None, "model_id": None, + "modified_at": "2024-02-01T00:00:00+00:00", "name_by_user": None, "name": None, "primary_config_entry": "1234", diff --git a/tests/syrupy.py b/tests/syrupy.py index 9dc8e50e5f1..80d955f0de1 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -155,7 +155,16 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serialized["via_device_id"] = ANY if serialized["primary_config_entry"] is not None: serialized["primary_config_entry"] = ANY - return serialized + return cls._remove_created_and_modified_at(serialized) + + @classmethod + def _remove_created_and_modified_at( + cls, data: SerializableData + ) -> SerializableData: + """Remove created_at and modified_at from the data.""" + data.pop("created_at", None) + data.pop("modified_at", None) + return data @classmethod def _serializable_entity_registry_entry(