"""Test for Nest events for the Smart Device Management API. These tests fake out the subscriber/devicemanager, and are not using a real pubsub subscriber. """ from __future__ import annotations from collections.abc import Mapping import datetime from typing import Any from unittest.mock import patch from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage import pytest from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow from .common import CreateDevice from tests.common import async_capture_events DOMAIN = "nest" DEVICE_ID = "some-device-id" PLATFORM = "camera" NEST_EVENT = "nest_event" EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." EVENT_KEYS = {"device_id", "type", "timestamp", "zones"} @pytest.fixture def platforms() -> list[str]: """Fixture for platforms to setup.""" return [PLATFORM] @pytest.fixture def device_type() -> str: """Fixture for the type of device under test.""" return "sdm.devices.types.DOORBELL" @pytest.fixture def device_traits() -> list[str]: """Fixture for the present traits of the device under test.""" return ["sdm.devices.traits.DoorbellChime"] @pytest.fixture(autouse=True) def device( device_type: str, device_traits: dict[str, Any], create_device: CreateDevice ) -> None: """Fixture to create a device under test.""" return create_device.create( raw_data={ "name": DEVICE_ID, "type": device_type, "traits": create_device_traits(device_traits), } ) def event_view(d: Mapping[str, Any]) -> Mapping[str, Any]: """View of an event with relevant keys for testing.""" return {key: value for key, value in d.items() if key in EVENT_KEYS} def create_device_traits(event_traits=[]): """Create fake traits for a device.""" result = { "sdm.devices.traits.Info": { "customName": "Front", }, "sdm.devices.traits.CameraLiveStream": { "maxVideoResolution": { "width": 640, "height": 480, }, "videoCodecs": ["H264"], "audioCodecs": ["AAC"], }, } result.update({t: {} for t in event_traits}) return result def create_event(event_type, device_id=DEVICE_ID, timestamp=None): """Create an EventMessage for a single event type.""" events = { event_type: { "eventSessionId": EVENT_SESSION_ID, "eventId": EVENT_ID, }, } return create_events(events=events, device_id=device_id) def create_events(events, device_id=DEVICE_ID, timestamp=None): """Create an EventMessage for events.""" if not timestamp: timestamp = utcnow() return EventMessage( { "eventId": "some-event-id", "timestamp": timestamp.isoformat(timespec="seconds"), "resourceUpdate": { "name": device_id, "events": events, }, }, auth=None, ) @pytest.mark.parametrize( "device_type,device_traits,event_trait,expected_model,expected_type", [ ( "sdm.devices.types.DOORBELL", ["sdm.devices.traits.DoorbellChime"], "sdm.devices.events.DoorbellChime.Chime", "Doorbell", "doorbell_chime", ), ( "sdm.devices.types.CAMERA", ["sdm.devices.traits.CameraMotion"], "sdm.devices.events.CameraMotion.Motion", "Camera", "camera_motion", ), ( "sdm.devices.types.CAMERA", ["sdm.devices.traits.CameraPerson"], "sdm.devices.events.CameraPerson.Person", "Camera", "camera_person", ), ( "sdm.devices.types.CAMERA", ["sdm.devices.traits.CameraSound"], "sdm.devices.events.CameraSound.Sound", "Camera", "camera_sound", ), ], ) async def test_event( hass, auth, setup_platform, subscriber, event_trait, expected_model, expected_type ): """Test a pubsub message for a doorbell event.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None assert entry.unique_id == "some-device-id-camera" assert entry.domain == "camera" device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "Front" assert device.model == expected_model assert device.identifiers == {("nest", DEVICE_ID)} timestamp = utcnow() await subscriber.async_receive_event(create_event(event_trait, timestamp=timestamp)) await hass.async_block_till_done() event_time = timestamp.replace(microsecond=0) assert len(events) == 1 assert event_view(events[0].data) == { "device_id": entry.device_id, "type": expected_type, "timestamp": event_time, } @pytest.mark.parametrize( "device_traits", [ ["sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraPerson"], ], ) async def test_camera_multiple_event(hass, subscriber, setup_platform): """Test a pubsub message for a camera person event.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None event_map = { "sdm.devices.events.CameraMotion.Motion": { "eventSessionId": EVENT_SESSION_ID, "eventId": EVENT_ID, }, "sdm.devices.events.CameraPerson.Person": { "eventSessionId": EVENT_SESSION_ID, "eventId": EVENT_ID, }, } timestamp = utcnow() await subscriber.async_receive_event(create_events(event_map, timestamp=timestamp)) await hass.async_block_till_done() event_time = timestamp.replace(microsecond=0) assert len(events) == 2 assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, } assert event_view(events[1].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": event_time, } async def test_unknown_event(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() await subscriber.async_receive_event(create_event("some-event-id")) await hass.async_block_till_done() assert len(events) == 0 async def test_unknown_device_id(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() await subscriber.async_receive_event( create_event("sdm.devices.events.DoorbellChime.Chime", "invalid-device-id") ) await hass.async_block_till_done() assert len(events) == 0 async def test_event_message_without_device_event(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() timestamp = utcnow() event = EventMessage( { "eventId": "some-event-id", "timestamp": timestamp.isoformat(timespec="seconds"), }, auth=None, ) await subscriber.async_receive_event(event) await hass.async_block_till_done() assert len(events) == 0 @pytest.mark.parametrize( "device_traits", [ ["sdm.devices.traits.CameraClipPreview", "sdm.devices.traits.CameraPerson"], ], ) async def test_doorbell_event_thread(hass, subscriber, setup_platform): """Test a series of pubsub messages in the same thread.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None event_message_data = { "eventId": "some-event-id-ignored", "resourceUpdate": { "name": DEVICE_ID, "events": { "sdm.devices.events.CameraMotion.Motion": { "eventSessionId": EVENT_SESSION_ID, "eventId": "n:1", }, "sdm.devices.events.CameraClipPreview.ClipPreview": { "eventSessionId": EVENT_SESSION_ID, "previewUrl": "image-url-1", }, }, }, "eventThreadId": "CjY5Y3VKaTZwR3o4Y19YbTVfMF...", "resourcegroup": [DEVICE_ID], } # Publish message #1 that starts the event thread timestamp1 = utcnow() message_data_1 = event_message_data.copy() message_data_1.update( { "timestamp": timestamp1.isoformat(timespec="seconds"), "eventThreadState": "STARTED", } ) await subscriber.async_receive_event(EventMessage(message_data_1, auth=None)) # Publish message #2 that sends a no-op update to end the event thread timestamp2 = timestamp1 + datetime.timedelta(seconds=1) message_data_2 = event_message_data.copy() message_data_2.update( { "timestamp": timestamp2.isoformat(timespec="seconds"), "eventThreadState": "ENDED", } ) await subscriber.async_receive_event(EventMessage(message_data_2, auth=None)) await hass.async_block_till_done() # The event is only published once assert len(events) == 1 assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": timestamp1.replace(microsecond=0), } @pytest.mark.parametrize( "device_traits", [ [ "sdm.devices.traits.CameraClipPreview", "sdm.devices.traits.CameraPerson", "sdm.devices.traits.CameraMotion", ], ], ) async def test_doorbell_event_session_update(hass, subscriber, setup_platform): """Test a pubsub message with updates to an existing session.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None # Message #1 has a motion event timestamp1 = utcnow() await subscriber.async_receive_event( create_events( { "sdm.devices.events.CameraMotion.Motion": { "eventSessionId": EVENT_SESSION_ID, "eventId": "n:1", }, "sdm.devices.events.CameraClipPreview.ClipPreview": { "eventSessionId": EVENT_SESSION_ID, "previewUrl": "image-url-1", }, }, timestamp=timestamp1, ) ) # Message #2 has an extra person event timestamp2 = utcnow() await subscriber.async_receive_event( create_events( { "sdm.devices.events.CameraMotion.Motion": { "eventSessionId": EVENT_SESSION_ID, "eventId": "n:1", }, "sdm.devices.events.CameraPerson.Person": { "eventSessionId": EVENT_SESSION_ID, "eventId": "n:2", }, "sdm.devices.events.CameraClipPreview.ClipPreview": { "eventSessionId": EVENT_SESSION_ID, "previewUrl": "image-url-1", }, }, timestamp=timestamp2, ) ) await hass.async_block_till_done() assert len(events) == 2 assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": timestamp1.replace(microsecond=0), } assert event_view(events[1].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": timestamp2.replace(microsecond=0), } async def test_structure_update_event(hass, subscriber, setup_platform): """Test a pubsub message for a new device being added.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() # Entity for first device is registered registry = er.async_get(hass) assert registry.async_get("camera.front") new_device = Device.MakeDevice( { "name": "device-id-2", "type": "sdm.devices.types.CAMERA", "traits": { "sdm.devices.traits.Info": { "customName": "Back", }, "sdm.devices.traits.CameraLiveStream": {}, }, }, auth=None, ) device_manager = await subscriber.async_get_device_manager() device_manager.add_device(new_device) # Entity for new devie has not yet been loaded assert not registry.async_get("camera.back") # Send a message that triggers the device to be loaded message = EventMessage( { "eventId": "some-event-id", "timestamp": utcnow().isoformat(timespec="seconds"), "relationUpdate": { "type": "CREATED", "subject": "enterprise/example/foo", "object": "enterprise/example/devices/some-device-id2", }, }, auth=None, ) with patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( "homeassistant.components.nest.api.GoogleNestSubscriber", return_value=subscriber, ): await subscriber.async_receive_event(message) await hass.async_block_till_done() # No home assistant events published assert not events assert registry.async_get("camera.front") # Currently need a manual reload to detect the new entity assert not registry.async_get("camera.back") @pytest.mark.parametrize( "device_traits", [ ["sdm.devices.traits.CameraMotion"], ], ) async def test_event_zones(hass, subscriber, setup_platform): """Test events published with zone information.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None event_map = { "sdm.devices.events.CameraMotion.Motion": { "eventSessionId": EVENT_SESSION_ID, "eventId": EVENT_ID, "zones": ["Zone 1"], }, } timestamp = utcnow() await subscriber.async_receive_event(create_events(event_map, timestamp=timestamp)) await hass.async_block_till_done() event_time = timestamp.replace(microsecond=0) assert len(events) == 1 assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, "zones": ["Zone 1"], }