mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Ensure event entity states are strictly increasing (#177403)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'] }}"},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user