From 745823dd5520ce0bbbb1a3a42d07ffdf3b1edaca Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Nov 2020 14:34:43 -0800 Subject: [PATCH] Add nest SDM API camera/doorbell events (#42700) * Add nest SDM API camera/doorbell events Events are fired when pubsub messages are received. When messages are received lookup a home assistant device id from the nest device id, so that the home assistant device id can be included in the event payload. * Update homeassistant/components/nest/__init__.py Co-authored-by: Paulus Schoutsen * Update nest code style based on PR feedback Co-authored-by: Paulus Schoutsen --- homeassistant/components/nest/__init__.py | 51 ++++- homeassistant/components/nest/sensor_sdm.py | 2 - tests/components/nest/test_events.py | 237 ++++++++++++++++++++ 3 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 tests/components/nest/test_events.py diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 7f3e9576bf4..7c2564f01bb 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -5,7 +5,14 @@ from datetime import datetime, timedelta import logging import threading -from google_nest_sdm.event import AsyncEventCallback, EventMessage +from google_nest_sdm.event import ( + AsyncEventCallback, + CameraMotionEvent, + CameraPersonEvent, + CameraSoundEvent, + DoorbellChimeEvent, + EventMessage, +) from google_nest_sdm.exceptions import GoogleNestException from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from nest import Nest @@ -54,6 +61,14 @@ _LOGGER = logging.getLogger(__name__) CONF_PROJECT_ID = "project_id" CONF_SUBSCRIBER_ID = "subscriber_id" +NEST_EVENT = "nest_event" +EVENT_TRAIT_MAP = { + DoorbellChimeEvent.NAME: "DoorbellChime", + CameraMotionEvent.NAME: "CameraMotion", + CameraPersonEvent.NAME: "CameraPerson", + CameraSoundEvent.NAME: "CameraSound", +} + # Configuration for the legacy nest API SERVICE_CANCEL_ETA = "cancel_eta" @@ -169,21 +184,35 @@ class SignalUpdateCallback(AsyncEventCallback): async def async_handle_event(self, event_message: EventMessage): """Process an incoming EventMessage.""" - _LOGGER.debug("Update %s @ %s", event_message.event_id, event_message.timestamp) + if not event_message.resource_update_name: + _LOGGER.debug("Ignoring event with no device_id") + return + device_id = event_message.resource_update_name + _LOGGER.debug("Update for %s @ %s", device_id, event_message.timestamp) traits = event_message.resource_update_traits if traits: _LOGGER.debug("Trait update %s", traits.keys()) + # This event triggered an update to a device that changed some + # properties which the DeviceManager should already have received. + # Send a signal to refresh state of all listening devices. + async_dispatcher_send(self._hass, SIGNAL_NEST_UPDATE) events = event_message.resource_update_events - if events: - _LOGGER.debug("Event Update %s", events.keys()) - - if not event_message.resource_update_traits: - # Note: Currently ignoring events like camera motion + if not events: return - # This event triggered an update to a device that changed some - # properties which the DeviceManager should already have received. - # Send a signal to refresh state of all listening devices. - async_dispatcher_send(self._hass, SIGNAL_NEST_UPDATE) + _LOGGER.debug("Event Update %s", events.keys()) + device_registry = await self._hass.helpers.device_registry.async_get_registry() + device_entry = device_registry.async_get_device({(DOMAIN, device_id)}, ()) + if not device_entry: + _LOGGER.debug("Ignoring event for unregistered device '%s'", device_id) + return + for event in events: + if event not in EVENT_TRAIT_MAP: + continue + message = { + "device_id": device_entry.id, + "type": EVENT_TRAIT_MAP[event], + } + self._hass.bus.async_fire(NEST_EVENT, message) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index a0a28756ac2..b2b9500a156 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -45,8 +45,6 @@ async def async_setup_sdm_entry( _LOGGER.warning("Failed to get devices: %s", err) raise PlatformNotReady from err - # Fetch initial data so we have data when entities subscribe. - entities = [] for device in device_manager.devices.values(): if TemperatureTrait.NAME in device.traits: diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py new file mode 100644 index 00000000000..b4b670fefbf --- /dev/null +++ b/tests/components/nest/test_events.py @@ -0,0 +1,237 @@ +"""Test for Nest binary sensor platform for the Smart Device Management API. + +These tests fake out the subscriber/devicemanager, and are not using a real +pubsub subscriber. +""" + +from google_nest_sdm.device import Device +from google_nest_sdm.event import EventMessage + +from homeassistant.util.dt import utcnow + +from .common import async_setup_sdm_platform + +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..." + + +async def async_setup_devices(hass, device_type, traits={}): + """Set up the platform and prerequisites.""" + devices = { + DEVICE_ID: Device.MakeDevice( + { + "name": DEVICE_ID, + "type": device_type, + "traits": traits, + }, + auth=None, + ), + } + return await async_setup_sdm_platform(hass, PLATFORM, devices=devices) + + +def create_device_traits(event_trait): + """Create fake traits for a device.""" + return { + "sdm.devices.traits.Info": { + "customName": "Front", + }, + event_trait: {}, + "sdm.devices.traits.CameraLiveStream": { + "maxVideoResolution": { + "width": 640, + "height": 480, + }, + "videoCodecs": ["H264"], + "audioCodecs": ["AAC"], + }, + } + + +def create_event(event_type, device_id=DEVICE_ID): + """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): + """Create an EventMessage for events.""" + return EventMessage( + { + "eventId": "some-event-id", + "timestamp": utcnow().isoformat(timespec="seconds"), + "resourceUpdate": { + "name": device_id, + "events": events, + }, + }, + auth=None, + ) + + +async def test_doorbell_chime_event(hass): + """Test a pubsub message for a doorbell event.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.DOORBELL", + create_device_traits("sdm.devices.traits.DoorbellChime"), + ) + + registry = await hass.helpers.entity_registry.async_get_registry() + entry = registry.async_get("camera.front") + assert entry is not None + assert entry.unique_id == "some-device-id-camera" + assert entry.original_name == "Front" + assert entry.domain == "camera" + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(entry.device_id) + assert device.name == "Front" + assert device.model == "Doorbell" + assert device.identifiers == {("nest", DEVICE_ID)} + + await subscriber.async_receive_event( + create_event("sdm.devices.events.DoorbellChime.Chime") + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "device_id": entry.device_id, + "type": "DoorbellChime", + } + + +async def test_camera_motion_event(hass): + """Test a pubsub message for a camera motion event.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.CAMERA", + create_device_traits("sdm.devices.traits.CameraMotion"), + ) + registry = await hass.helpers.entity_registry.async_get_registry() + entry = registry.async_get("camera.front") + assert entry is not None + + await subscriber.async_receive_event( + create_event("sdm.devices.events.CameraMotion.Motion") + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "device_id": entry.device_id, + "type": "CameraMotion", + } + + +async def test_camera_sound_event(hass): + """Test a pubsub message for a camera sound event.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.CAMERA", + create_device_traits("sdm.devices.traits.CameraSound"), + ) + registry = await hass.helpers.entity_registry.async_get_registry() + entry = registry.async_get("camera.front") + assert entry is not None + + await subscriber.async_receive_event( + create_event("sdm.devices.events.CameraSound.Sound") + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "device_id": entry.device_id, + "type": "CameraSound", + } + + +async def test_camera_person_event(hass): + """Test a pubsub message for a camera person event.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.DOORBELL", + create_device_traits("sdm.devices.traits.CameraEventImage"), + ) + registry = await hass.helpers.entity_registry.async_get_registry() + entry = registry.async_get("camera.front") + assert entry is not None + + await subscriber.async_receive_event( + create_event("sdm.devices.events.CameraPerson.Person") + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "device_id": entry.device_id, + "type": "CameraPerson", + } + + +async def test_camera_multiple_event(hass): + """Test a pubsub message for a camera person event.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.DOORBELL", + create_device_traits("sdm.devices.traits.CameraEventImage"), + ) + registry = await hass.helpers.entity_registry.async_get_registry() + 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, + }, + } + + await subscriber.async_receive_event(create_events(event_map)) + await hass.async_block_till_done() + + assert len(events) == 2 + assert events[0].data == { + "device_id": entry.device_id, + "type": "CameraMotion", + } + assert events[1].data == { + "device_id": entry.device_id, + "type": "CameraPerson", + } + + +async def test_unknown_event(hass): + """Test a pubsub message for an unknown event type.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.DOORBELL", + create_device_traits("sdm.devices.traits.DoorbellChime"), + ) + await subscriber.async_receive_event(create_event("some-event-id")) + await hass.async_block_till_done() + + assert len(events) == 0