From c506188c1304f1ecd2e62c31a60d105fda400555 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 24 Aug 2024 13:17:59 -0700 Subject: [PATCH] Fix nest event entities to only register a single event per session (#124535) --- homeassistant/components/nest/event.py | 7 +- tests/components/nest/test_event.py | 152 +++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py index d2897793270..a6d70fe86d5 100644 --- a/homeassistant/components/nest/event.py +++ b/homeassistant/components/nest/event.py @@ -106,15 +106,20 @@ class NestTraitEventEntity(EventEntity): or not (events := event_message.resource_update_events) ): return + last_nest_event_id = self.state_attributes.get("nest_event_id") for api_event_type, nest_event in events.items(): if api_event_type not in self.entity_description.api_event_types: continue event_type = EVENT_NAME_MAP[api_event_type] + nest_event_id = nest_event.event_token + if last_nest_event_id is not None and last_nest_event_id == nest_event_id: + # This event is a duplicate message in the same thread + return self._trigger_event( event_type, - {"nest_event_id": nest_event.event_token}, + {"nest_event_id": nest_event_id}, ) self.async_write_ha_state() return diff --git a/tests/components/nest/test_event.py b/tests/components/nest/test_event.py index 531252285e0..f45e6c1c6e6 100644 --- a/tests/components/nest/test_event.py +++ b/tests/components/nest/test_event.py @@ -1,7 +1,10 @@ """Test for Nest event platform.""" +import datetime from typing import Any +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from google_nest_sdm.event import EventMessage, EventType from google_nest_sdm.traits import TraitType import pytest @@ -10,13 +13,17 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from .common import DEVICE_ID, CreateDevice -from .conftest import FakeSubscriber, PlatformSetup +from .common import DEVICE_ID, CreateDevice, FakeSubscriber +from .conftest import PlatformSetup EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." ENCODED_EVENT_ID = "WyJDalk1WTNWS2FUWndSM280WTE5WWJUVmZNRi4uLiIsICJGV1dWUVZVZEdOVWxUVTJWNE1HVjJhVE5YVi4uLiJd" +EVENT_SESSION_ID2 = "DjY5Y3VKaTZwR3o4Y19YbTVfMF..." +EVENT_ID2 = "GWWVQVUdGNUlTU2V4MGV2aTNXV..." +ENCODED_EVENT_ID2 = "WyJEalk1WTNWS2FUWndSM280WTE5WWJUVmZNRi4uLiIsICJHV1dWUVZVZEdOVWxUVTJWNE1HVjJhVE5YVi4uLiJd" + @pytest.fixture def platforms() -> list[Platform]: @@ -24,6 +31,14 @@ def platforms() -> list[Platform]: return [Platform.EVENT] +@pytest.fixture(autouse=True) +def enable_prefetch(subscriber: FakeSubscriber) -> None: + """Fixture to enable media fetching for tests to exercise.""" + subscriber.cache_policy.fetch = True + with patch("homeassistant.components.nest.EVENT_MEDIA_CACHE_SIZE", new=5): + yield + + @pytest.fixture def device_type() -> str: """Fixture for the type of device under test.""" @@ -49,6 +64,21 @@ async def device_traits() -> dict[str, Any]: def create_events(events: str) -> EventMessage: + """Create an EventMessage for events.""" + return create_event_messages( + { + event: { + "eventSessionId": EVENT_SESSION_ID, + "eventId": EVENT_ID, + } + for event in events + } + ) + + +def create_event_messages( + events: dict[str, Any], parameters: dict[str, Any] | None = None +) -> EventMessage: """Create an EventMessage for events.""" return EventMessage.create_event( { @@ -56,19 +86,15 @@ def create_events(events: str) -> EventMessage: "timestamp": utcnow().isoformat(timespec="seconds"), "resourceUpdate": { "name": DEVICE_ID, - "events": { - event: { - "eventSessionId": EVENT_SESSION_ID, - "eventId": EVENT_ID, - } - for event in events - }, + "events": events, }, + **(parameters if parameters else {}), }, auth=None, ) +@pytest.mark.freeze_time("2024-08-24T12:00:00Z") @pytest.mark.parametrize( ( "trait_types", @@ -155,7 +181,7 @@ async def test_receive_events( await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state != "unknown" + assert state.state == "2024-08-24T12:00:00.000+00:00" assert state.attributes == { **expected_attributes, "event_type": expected_event_type, @@ -191,3 +217,109 @@ async def test_ignore_unrelated_event( "event_types": ["doorbell_chime"], "friendly_name": "Front Chime", } + + +@pytest.mark.freeze_time("2024-08-24T12:00:00Z") +async def test_event_threads( + hass: HomeAssistant, + subscriber: FakeSubscriber, + setup_platform: PlatformSetup, + create_device: CreateDevice, + freezer: FrozenDateTimeFactory, +) -> None: + """Test multiple events delivered as part of a thread are a single home assistant event.""" + create_device.create( + raw_traits={ + TraitType.DOORBELL_CHIME: {}, + TraitType.CAMERA_CLIP_PREVIEW: {}, + } + ) + await setup_platform() + + state = hass.states.get("event.front_chime") + assert state.state == "unknown" + + # Doorbell event is received + freezer.tick(datetime.timedelta(seconds=2)) + await subscriber.async_receive_event( + create_event_messages( + { + EventType.DOORBELL_CHIME: { + "eventSessionId": EVENT_SESSION_ID, + "eventId": EVENT_ID, + } + }, + parameters={"eventThreadState": "STARTED"}, + ) + ) + await hass.async_block_till_done() + + state = hass.states.get("event.front_chime") + assert state.state == "2024-08-24T12:00:02.000+00:00" + assert state.attributes == { + "device_class": "doorbell", + "event_types": ["doorbell_chime"], + "friendly_name": "Front Chime", + "event_type": "doorbell_chime", + "nest_event_id": ENCODED_EVENT_ID, + } + + # Media arrives in a second message that ends the thread + freezer.tick(datetime.timedelta(seconds=2)) + await subscriber.async_receive_event( + create_event_messages( + { + EventType.DOORBELL_CHIME: { + "eventSessionId": EVENT_SESSION_ID, + "eventId": EVENT_ID, + }, + EventType.CAMERA_CLIP_PREVIEW: { + "eventSessionId": EVENT_SESSION_ID, + "previewUrl": "http://example", + }, + }, + parameters={"eventThreadState": "ENDED"}, + ) + ) + await hass.async_block_till_done() + + state = hass.states.get("event.front_chime") + assert ( + state.state == "2024-08-24T12:00:02.000+00:00" + ) # A second event is not received + assert state.attributes == { + "device_class": "doorbell", + "event_types": ["doorbell_chime"], + "friendly_name": "Front Chime", + "event_type": "doorbell_chime", + "nest_event_id": ENCODED_EVENT_ID, + } + + # An additional doorbell press event happens (with an updated session id) + freezer.tick(datetime.timedelta(seconds=2)) + await subscriber.async_receive_event( + create_event_messages( + { + EventType.DOORBELL_CHIME: { + "eventSessionId": EVENT_SESSION_ID2, + "eventId": EVENT_ID2, + }, + EventType.CAMERA_CLIP_PREVIEW: { + "eventSessionId": EVENT_SESSION_ID2, + "previewUrl": "http://example", + }, + }, + parameters={"eventThreadState": "ENDED"}, + ) + ) + await hass.async_block_till_done() + + state = hass.states.get("event.front_chime") + assert state.state == "2024-08-24T12:00:06.000+00:00" # Third event is received + assert state.attributes == { + "device_class": "doorbell", + "event_types": ["doorbell_chime"], + "friendly_name": "Front Chime", + "event_type": "doorbell_chime", + "nest_event_id": ENCODED_EVENT_ID2, + }