Ensure event entity states are strictly increasing (#177403)

This commit is contained in:
Erik Montnemery
2026-07-28 08:00:33 +02:00
committed by GitHub
parent ae94584904
commit 00c8c451f7
8 changed files with 151 additions and 14 deletions
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+37 -4
View File
@@ -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."""
+86 -1
View File
@@ -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
@@ -61,6 +61,5 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2022-04-19T10:31:02.000+00:00',
})
# ---
@@ -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
)
@@ -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'] }}"},
+10 -5
View File
@@ -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"]