From 00c8c451f771e891fce02d83fc7689009f84c84f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 08:00:33 +0200 Subject: [PATCH] Ensure event entity states are strictly increasing (#177403) --- homeassistant/components/event/__init__.py | 8 +- tests/components/esphome/test_event.py | 2 +- tests/components/event/test_init.py | 41 ++++++++- tests/components/event/test_trigger.py | 87 ++++++++++++++++++- .../folder_watcher/snapshots/test_event.ambr | 1 - tests/components/folder_watcher/test_event.py | 7 ++ tests/components/template/test_config_flow.py | 4 +- tests/components/template/test_event.py | 15 ++-- 8 files changed, 151 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 247601ae4f20..b06ce4e1bcc2 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -165,7 +165,13 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) """Process a new event.""" if event_type not in self.event_types: raise ValueError(f"Invalid event type {event_type} for {self.entity_id}") - self.__last_event_triggered = dt_util.utcnow() + triggered = dt_util.utcnow() + # Force the timestamp to strictly increase so multiple events fired + # within the same millisecond stay distinct state changes, which state + # triggers such as event.received rely on to fire once per event. + if (last := self.__last_event_triggered) is not None: + triggered = max(triggered, last + timedelta(milliseconds=1)) + self.__last_event_triggered = triggered self.__last_event_type = event_type self.__last_event_attributes = event_attributes diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index 3cff3184bf16..de3ccf284176 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -52,5 +52,5 @@ async def test_generic_event_entity( # Event entity should be available immediately without waiting for data state = hass.states.get("event.test_my_event") - assert state.state == "2024-04-24T00:00:00.000+00:00" + assert state.state == "2024-04-24T00:00:00.001+00:00" assert state.attributes["event_type"] == "type1" diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 5aa6ca849ff6..75bc06b5f7e4 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -1,6 +1,7 @@ """The tests for the event integration.""" from collections.abc import Generator +from datetime import timedelta from typing import Any from freezegun import freeze_time @@ -80,12 +81,14 @@ async def test_event() -> None: assert event.state_attributes == {ATTR_EVENT_TYPE: "long_press"} assert not event.extra_state_attributes - # Test triggering an event, with extra attribute data - now = dt_util.utcnow() - with freeze_time(now): + # Test triggering an event, with extra attribute data. Use a later time so + # the assertion is not affected by the strictly-increasing timestamp + # guarantee (which only kicks in within the same millisecond). + later = now + timedelta(seconds=1) + with freeze_time(later): event._trigger_event("short_press", {"hello": "world"}) - assert event.state == now.isoformat(timespec="milliseconds") + assert event.state == later.isoformat(timespec="milliseconds") assert event.state_attributes == { ATTR_EVENT_TYPE: "short_press", "hello": "world", @@ -98,6 +101,36 @@ async def test_event() -> None: event._trigger_event("unknown_event") +async def test_trigger_event_strictly_increasing_timestamp() -> None: + """Test events within the same millisecond get strictly increasing states. + + The event state is a millisecond timestamp; state triggers such as + event.received detect a new event by the state value changing. Multiple + events fired within one millisecond (e.g. several items handled in a single + coordinator poll) must therefore not collapse onto one timestamp. + """ + event = EventEntity() + event.entity_id = "event.test" + event._attr_event_types = ["ping"] + + states: list[str | None] = [] + with freeze_time("2026-01-01T00:00:00+00:00"): + for _ in range(3): + event._trigger_event("ping") + states.append(event.state) + + assert states == [ + "2026-01-01T00:00:00.000+00:00", + "2026-01-01T00:00:00.001+00:00", + "2026-01-01T00:00:00.002+00:00", + ] + + # A later real event keeps its true timestamp rather than an artificial bump. + with freeze_time("2026-01-01T00:00:05+00:00"): + event._trigger_event("ping") + assert event.state == "2026-01-01T00:00:05.000+00:00" + + @pytest.mark.usefixtures("enable_custom_integrations", "mock_event_platform") async def test_restore_state(hass: HomeAssistant) -> None: """Test we restore state integration.""" diff --git a/tests/components/event/test_trigger.py b/tests/components/event/test_trigger.py index 19aff06d8b1e..01c79408115f 100644 --- a/tests/components/event/test_trigger.py +++ b/tests/components/event/test_trigger.py @@ -2,12 +2,16 @@ from typing import Any +from freezegun import freeze_time import pytest +from homeassistant.components.event import DOMAIN, EventEntity from homeassistant.components.event.const import ATTR_EVENT_TYPE -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from tests.common import MockEntity, setup_test_component_platform from tests.components.common import ( TriggerStateDescription, arm_trigger, @@ -18,6 +22,26 @@ from tests.components.common import ( ) +class _MockEventEntity(MockEntity, EventEntity): + """Mock event entity that exposes its event types.""" + + @property + def event_types(self) -> list[str]: + """Return the supported event types.""" + return self._handle("event_types") + + +async def _setup_event_entity(hass: HomeAssistant) -> EventEntity: + """Set up a single event entity and return the instance.""" + entity = _MockEventEntity( + name="Torrent", unique_id="torrent", event_types=["downloaded"] + ) + setup_test_component_platform(hass, DOMAIN, [entity]) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "test"}}) + await hass.async_block_till_done() + return entity + + @pytest.fixture async def target_events(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple event entities associated with different targets.""" @@ -298,3 +322,64 @@ async def test_event_state_trigger( await hass.async_block_till_done() assert len(calls) == (entities_in_target - 1) * state["count"] calls.clear() + + +async def test_multiple_events_in_one_millisecond_each_fire( + hass: HomeAssistant, +) -> None: + """Test each event fires the trigger even when they share a wall-clock time. + + Reproduces the scenario where an integration emits several events + synchronously in a single update (e.g. multiple downloads completing in one + poll). All events share the same millisecond timestamp, but each must still + fire event.received once. + """ + entity = await _setup_event_entity(hass) + calls: list[str] = [] + await arm_trigger( + hass, + "event.received", + {"event_type": ["downloaded"]}, + {"entity_id": entity.entity_id}, + calls, + ) + + with freeze_time("2026-01-01T00:00:00+00:00"): + for torrent_id in (1, 2, 3): + entity._trigger_event("downloaded", {"id": torrent_id}) + entity.async_write_ha_state() + await hass.async_block_till_done() + + assert len(calls) == 3 + + +async def test_attribute_only_change_does_not_fire(hass: HomeAssistant) -> None: + """Test a cosmetic state re-write (e.g. rename) does not fire the trigger. + + A rename re-writes the state in place with the same timestamp but a new + friendly_name; this must not be mistaken for a new event. + """ + entity = await _setup_event_entity(hass) + with freeze_time("2026-01-01T00:00:00+00:00"): + entity._trigger_event("downloaded", {"id": 1}) + entity.async_write_ha_state() + await hass.async_block_till_done() + + calls: list[str] = [] + await arm_trigger( + hass, + "event.received", + {"event_type": ["downloaded"]}, + {"entity_id": entity.entity_id}, + calls, + ) + + current = hass.states.get(entity.entity_id) + hass.states.async_set( + entity.entity_id, + current.state, + {**current.attributes, ATTR_FRIENDLY_NAME: "Renamed"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 diff --git a/tests/components/folder_watcher/snapshots/test_event.ambr b/tests/components/folder_watcher/snapshots/test_event.ambr index db62cb6ebd28..bb076d489a42 100644 --- a/tests/components/folder_watcher/snapshots/test_event.ambr +++ b/tests/components/folder_watcher/snapshots/test_event.ambr @@ -61,6 +61,5 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2022-04-19T10:31:02.000+00:00', }) # --- diff --git a/tests/components/folder_watcher/test_event.py b/tests/components/folder_watcher/test_event.py index 71f9094f59f5..6f4d1f278bc8 100644 --- a/tests/components/folder_watcher/test_event.py +++ b/tests/components/folder_watcher/test_event.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -40,6 +41,11 @@ async def test_event_entity( "path", "dest_folder", "dest_path", + # The state is a strictly-increasing event timestamp; its exact + # value depends on how many filesystem notifications the real + # watchdog backend emits (varies by platform), so it is asserted + # dynamically below instead of being pinned in the snapshot. + "state", } return prop in exclude_attrs @@ -48,6 +54,7 @@ async def test_event_entity( name=f"{entity_entry.unique_id}-entry", exclude=limit_attrs ) assert (state := hass.states.get(entity_entry.entity_id)) + assert dt_util.parse_datetime(state.state) is not None assert state == snapshot( name=f"{entity_entry.unique_id}-state", exclude=limit_attrs ) diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index bbcdf7cfab25..5024ad9ec833 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -655,7 +655,9 @@ async def test_config_flow_device( "event", {"event_type": "{{ states('event.one') }}"}, {"event_type": "{{ states('event.two') }}"}, - ["2024-07-09T00:00:00.000+00:00", "2024-07-09T00:00:00.000+00:00"], + # The reloaded entity restores the first timestamp, so the second + # event is bumped by 1ms to stay a distinct state change. + ["2024-07-09T00:00:00.000+00:00", "2024-07-09T00:00:00.001+00:00"], {"one": "single", "two": "double"}, {"event_types": "{{ ['single', 'double'] }}"}, {"event_types": "{{ ['single', 'double'] }}"}, diff --git a/tests/components/template/test_event.py b/tests/components/template/test_event.py index b198e89b979c..e3713fdd24b5 100644 --- a/tests/components/template/test_event.py +++ b/tests/components/template/test_event.py @@ -267,19 +267,22 @@ async def test_event_type_template_updates( await async_trigger(hass, TEST_STATE_ENTITY_ID, "single") state = hass.states.get(TEST_EVENT.entity_id) - assert state.state == TEST_FROZEN_STATE + single_triggered = state.state assert state.attributes["event_type"] == "single" await async_trigger(hass, TEST_STATE_ENTITY_ID, "double") state = hass.states.get(TEST_EVENT.entity_id) - assert state.state == TEST_FROZEN_STATE + # Each event advances the timestamp; events within the same millisecond are + # bumped so every one stays a distinct state change. + double_triggered = state.state + assert double_triggered > single_triggered assert state.attributes["event_type"] == "double" await async_trigger(hass, TEST_STATE_ENTITY_ID, "hold") state = hass.states.get(TEST_EVENT.entity_id) - assert state.state == TEST_FROZEN_STATE + assert state.state > double_triggered assert state.attributes["event_type"] == "hold" @@ -413,7 +416,7 @@ async def test_event_types_template_updates(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(TEST_EVENT.entity_id) - assert state.state == TEST_FROZEN_STATE + first_triggered = state.state assert state.attributes["event_type"] == "single" assert state.attributes["event_types"] == ["single", "double", "hold"] @@ -423,7 +426,9 @@ async def test_event_types_template_updates(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(TEST_EVENT.entity_id) - assert state.state == TEST_FROZEN_STATE + # A second event fired, so its timestamp strictly increases (events within + # the same millisecond are bumped so each stays a distinct state change). + assert state.state > first_triggered assert state.attributes["event_type"] == "double" assert state.attributes["event_types"] == ["double", "hold"]