From ba99dc3af9db5191e05987267ea57eb717421d75 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 3 Dec 2021 10:53:05 -0800 Subject: [PATCH] Add Nest Battery Cam event clip support with a Nest MediaSource (#60073) --- homeassistant/components/nest/__init__.py | 65 +- homeassistant/components/nest/events.py | 8 + homeassistant/components/nest/manifest.json | 2 +- homeassistant/components/nest/media_source.py | 259 ++++++++ tests/components/nest/test_media_source.py | 569 ++++++++++++++++++ 5 files changed, 901 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/nest/media_source.py create mode 100644 tests/components/nest/test_media_source.py diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 0933f10e6ce6..af3757d31da6 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -1,7 +1,10 @@ """Support for Nest devices.""" +from __future__ import annotations +from http import HTTPStatus import logging +from aiohttp import web from google_nest_sdm.event import EventMessage from google_nest_sdm.exceptions import ( AuthException, @@ -10,6 +13,9 @@ ) import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.components.http.const import KEY_HASS_USER +from homeassistant.components.http.view import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_BINARY_SENSORS, @@ -20,8 +26,14 @@ CONF_STRUCTURE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + Unauthorized, +) from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType from . import api, config_flow @@ -38,6 +50,7 @@ ) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry +from .media_source import get_media_source_devices _LOGGER = logging.getLogger(__name__) @@ -226,6 +239,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) + hass.http.register_view(NestEventMediaView(hass)) + return True @@ -264,3 +279,51 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ) finally: subscriber.stop_async() + + +class NestEventMediaView(HomeAssistantView): + """Returns media for related to events for a specific device. + + This is primarily used to render media for events for MediaSource. The media type + depends on the specific device e.g. an image, or a movie clip preview. + """ + + url = "/api/nest/event_media/{device_id}/{event_id}" + name = "api:nest:event_media" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize NestEventMediaView.""" + self.hass = hass + + async def get( + self, request: web.Request, device_id: str, event_id: str + ) -> web.StreamResponse: + """Start a GET request.""" + user = request[KEY_HASS_USER] + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + for entry in async_entries_for_device(entity_registry, device_id): + if not user.permissions.check_entity(entry.entity_id, POLICY_READ): + raise Unauthorized(entity_id=entry.entity_id) + + devices = await get_media_source_devices(self.hass) + if not (nest_device := devices.get(device_id)): + return self._json_error( + f"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND + ) + try: + event_media = await nest_device.event_media_manager.get_media(event_id) + except GoogleNestException as err: + raise HomeAssistantError("Unable to fetch media for event") from err + if not event_media: + return self._json_error( + f"No event found for event_id '{event_id}'", HTTPStatus.NOT_FOUND + ) + media = event_media.media + return web.Response( + body=media.contents, content_type=media.event_image_type.content_type + ) + + def _json_error(self, message: str, status: HTTPStatus) -> web.StreamResponse: + """Return a json error message with additional logging.""" + _LOGGER.debug(message) + return self.json_message(message, status) diff --git a/homeassistant/components/nest/events.py b/homeassistant/components/nest/events.py index 6802a98cc405..10983768e17d 100644 --- a/homeassistant/components/nest/events.py +++ b/homeassistant/components/nest/events.py @@ -57,3 +57,11 @@ CameraPersonEvent.NAME: EVENT_CAMERA_PERSON, CameraSoundEvent.NAME: EVENT_CAMERA_SOUND, } + +# Names for event types shown in the media source +MEDIA_SOURCE_EVENT_TITLE_MAP = { + DoorbellChimeEvent.NAME: "Doorbell", + CameraMotionEvent.NAME: "Motion", + CameraPersonEvent.NAME: "Person", + CameraSoundEvent.NAME: "Sound", +} diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 488aeb9d053a..94b2c338528e 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -2,7 +2,7 @@ "domain": "nest", "name": "Nest", "config_flow": true, - "dependencies": ["ffmpeg", "http"], + "dependencies": ["ffmpeg", "http", "media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.0"], "codeowners": ["@allenporter"], diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py new file mode 100644 index 000000000000..e21be20380a7 --- /dev/null +++ b/homeassistant/components/nest/media_source.py @@ -0,0 +1,259 @@ +"""Nest Media Source implementation. + +The Nest MediaSource implementation provides a directory tree of devices and +events and associated media (e.g. an image or clip). Camera device events +publish an event message, received by the subscriber library. Media for an +event, such as camera image or clip, may be fetched from the cloud during a +short time window after the event happens. + +The actual management of associating events to devices, fetching media for +events, caching, and the overall lifetime of recent events are managed outside +of the Nest MediaSource. + +Users may also record clips to local storage, unrelated to this MediaSource. + +For additional background on Nest Camera events see: +https://developers.google.com/nest/device-access/api/camera#handle_camera_events +""" + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Mapping +from dataclasses import dataclass +import logging + +from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait +from google_nest_sdm.device import Device +from google_nest_sdm.event import ImageEventBase + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + MEDIA_TYPE_IMAGE, + MEDIA_TYPE_VIDEO, +) +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.components.nest.const import DATA_SUBSCRIBER, DOMAIN +from homeassistant.components.nest.device_info import NestDeviceInfo +from homeassistant.components.nest.events import MEDIA_SOURCE_EVENT_TITLE_MAP +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +MEDIA_SOURCE_TITLE = "Nest" +DEVICE_TITLE_FORMAT = "{device_name}: Recent Events" +CLIP_TITLE_FORMAT = "{event_name} @ {event_time}" +EVENT_MEDIA_API_URL_FORMAT = "/api/nest/event_media/{device_id}/{event_id}" + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Nest media source.""" + return NestMediaSource(hass) + + +async def get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]: + """Return a mapping of device id to eligible Nest event media devices.""" + subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER] + device_manager = await subscriber.async_get_device_manager() + device_registry = await hass.helpers.device_registry.async_get_registry() + devices = {} + for device in device_manager.devices.values(): + if not ( + CameraEventImageTrait.NAME in device.traits + or CameraClipPreviewTrait.NAME in device.traits + ): + continue + if device_entry := device_registry.async_get_device({(DOMAIN, device.name)}): + devices[device_entry.id] = device + return devices + + +@dataclass +class MediaId: + """Media identifier for a node in the Media Browse tree. + + A MediaId can refer to either a device, or a specific event for a device + that is associated with media (e.g. image or video clip). + """ + + device_id: str + event_id: str | None = None + + @property + def identifier(self) -> str: + """Media identifier represented as a string.""" + if self.event_id: + return f"{self.device_id}/{self.event_id}" + return self.device_id + + +def parse_media_id(identifier: str | None = None) -> MediaId | None: + """Parse the identifier path string into a MediaId.""" + if identifier is None or identifier == "": + return None + parts = identifier.split("/") + if len(parts) > 1: + return MediaId(parts[0], parts[1]) + return MediaId(parts[0]) + + +class NestMediaSource(MediaSource): + """Provide Nest Media Sources for Nest Cameras. + + The media source generates a directory tree of devices and media associated + with events for each device (e.g. motion, person, etc). Each node in the + tree has a unique MediaId. + + The lifecycle for event media is handled outside of NestMediaSource, and + instead it just asks the device for all events it knows about. + """ + + name: str = MEDIA_SOURCE_TITLE + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize NestMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media identifier to a url.""" + media_id: MediaId | None = parse_media_id(item.identifier) + if not media_id: + raise Unresolvable("No identifier specified for MediaSourceItem") + if not media_id.event_id: + raise Unresolvable("Identifier missing an event_id: %s" % item.identifier) + devices = await self.devices() + if not (device := devices.get(media_id.device_id)): + raise Unresolvable( + "Unable to find device with identifier: %s" % item.identifier + ) + events = _get_events(device) + if media_id.event_id not in events: + raise Unresolvable( + "Unable to find event with identifier: %s" % item.identifier + ) + event = events[media_id.event_id] + return PlayMedia( + EVENT_MEDIA_API_URL_FORMAT.format( + device_id=media_id.device_id, event_id=media_id.event_id + ), + event.event_image_type.content_type, + ) + + async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: + """Return media for the specified level of the directory tree. + + The top level is the root that contains devices. Inside each device are + media for events for that device. + """ + media_id: MediaId | None = parse_media_id(item.identifier) + _LOGGER.debug( + "Browsing media for identifier=%s, media_id=%s", item.identifier, media_id + ) + devices = await self.devices() + if media_id is None: + # Browse the root and return child devices + browse_root = _browse_root() + browse_root.children = [] + for device_id, child_device in devices.items(): + browse_root.children.append( + _browse_device(MediaId(device_id), child_device) + ) + return browse_root + + # Browse either a device or events within a device + if not (device := devices.get(media_id.device_id)): + raise BrowseError( + "Unable to find device with identiifer: %s" % item.identifier + ) + if media_id.event_id is None: + # Browse a specific device and return child events + browse_device = _browse_device(media_id, device) + browse_device.children = [] + events = _get_events(device) + for child_event in events.values(): + event_id = MediaId(media_id.device_id, child_event.event_id) + browse_device.children.append( + _browse_event(event_id, device, child_event) + ) + return browse_device + + # Browse a specific event + events = _get_events(device) + if not (event := events.get(media_id.event_id)): + raise BrowseError( + "Unable to find event with identiifer: %s" % item.identifier + ) + return _browse_event(media_id, device, event) + + async def devices(self) -> Mapping[str, Device]: + """Return all event media related devices.""" + return await get_media_source_devices(self.hass) + + +def _get_events(device: Device) -> Mapping[str, ImageEventBase]: + """Return relevant events for the specified device.""" + return OrderedDict({e.event_id: e for e in device.event_media_manager.events}) + + +def _browse_root() -> BrowseMediaSource: + """Return devices in the root.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier="", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_VIDEO, + children_media_class=MEDIA_CLASS_VIDEO, + title=MEDIA_SOURCE_TITLE, + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ) + + +def _browse_device(device_id: MediaId, device: Device) -> BrowseMediaSource: + """Return details for the specified device.""" + device_info = NestDeviceInfo(device) + return BrowseMediaSource( + domain=DOMAIN, + identifier=device_id.identifier, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_VIDEO, + children_media_class=MEDIA_CLASS_VIDEO, + title=DEVICE_TITLE_FORMAT.format(device_name=device_info.device_name), + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ) + + +def _browse_event( + event_id: MediaId, device: Device, event: ImageEventBase +) -> BrowseMediaSource: + """Build a BrowseMediaSource for a specific event.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=event_id.identifier, + media_class=MEDIA_CLASS_IMAGE, + media_content_type=MEDIA_TYPE_IMAGE, + title=CLIP_TITLE_FORMAT.format( + event_name=MEDIA_SOURCE_EVENT_TITLE_MAP.get(event.event_type, "Event"), + event_time=dt_util.as_local(event.timestamp), + ), + can_play=True, + can_expand=False, + thumbnail=None, + children=[], + ) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py new file mode 100644 index 000000000000..4cda781ebebb --- /dev/null +++ b/tests/components/nest/test_media_source.py @@ -0,0 +1,569 @@ +"""Test for Nest Media Source. + +These tests simulate recent camera events received by the subscriber exposed +as media in the media source. +""" + +import datetime +from http import HTTPStatus + +import aiohttp +from google_nest_sdm.device import Device +from google_nest_sdm.event import EventMessage +import pytest + +from homeassistant.components import media_source +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source import const +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.helpers import device_registry as dr +import homeassistant.util.dt as dt_util + +from .common import async_setup_sdm_platform + +DOMAIN = "nest" +DEVICE_ID = "example/api/device/id" +DEVICE_NAME = "Front" +PLATFORM = "camera" +NEST_EVENT = "nest_event" +EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." +CAMERA_DEVICE_TYPE = "sdm.devices.types.CAMERA" +CAMERA_TRAITS = { + "sdm.devices.traits.Info": { + "customName": DEVICE_NAME, + }, + "sdm.devices.traits.CameraImage": {}, + "sdm.devices.traits.CameraEventImage": {}, + "sdm.devices.traits.CameraPerson": {}, + "sdm.devices.traits.CameraMotion": {}, +} +BATTERY_CAMERA_TRAITS = { + "sdm.devices.traits.Info": { + "customName": DEVICE_NAME, + }, + "sdm.devices.traits.CameraClipPreview": {}, + "sdm.devices.traits.CameraLiveStream": {}, + "sdm.devices.traits.CameraPerson": {}, + "sdm.devices.traits.CameraMotion": {}, +} +PERSON_EVENT = "sdm.devices.events.CameraPerson.Person" +MOTION_EVENT = "sdm.devices.events.CameraMotion.Motion" + +TEST_IMAGE_URL = "https://domain/sdm_event_snapshot/dGTZwR3o4Y1..." +GENERATE_IMAGE_URL_RESPONSE = { + "results": { + "url": TEST_IMAGE_URL, + "token": "g.0.eventToken", + }, +} +IMAGE_BYTES_FROM_EVENT = b"test url image bytes" +IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} + + +async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): + """Set up the platform and prerequisites.""" + devices = { + DEVICE_ID: Device.MakeDevice( + { + "name": DEVICE_ID, + "type": device_type, + "traits": traits, + }, + auth=auth, + ), + } + subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) + if events: + for event in events: + await subscriber.async_receive_event(event) + await hass.async_block_till_done() + return subscriber + + +def create_event(event_id, event_type, timestamp=None): + """Create an EventMessage for a single event type.""" + if not timestamp: + timestamp = dt_util.now() + event_data = { + event_type: { + "eventSessionId": EVENT_SESSION_ID, + "eventId": event_id, + }, + } + return create_event_message(event_id, event_data, timestamp) + + +def create_event_message(event_id, event_data, timestamp): + """Create an EventMessage for a single event type.""" + return EventMessage( + { + "eventId": f"{event_id}-{timestamp}", + "timestamp": timestamp.isoformat(timespec="seconds"), + "resourceUpdate": { + "name": DEVICE_ID, + "events": event_data, + }, + }, + auth=None, + ) + + +async def test_no_eligible_devices(hass, auth): + """Test a media source with no eligible camera devices.""" + await async_setup_devices( + hass, + auth, + "sdm.devices.types.THERMOSTAT", + { + "sdm.devices.traits.Temperature": {}, + }, + ) + + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier == "" + assert browse.title == "Nest" + assert not browse.children + + +async def test_supported_device(hass, auth): + """Test a media source with a supported camera.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.title == "Nest" + assert browse.identifier == "" + assert browse.can_expand + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == device.id + assert browse.children[0].title == "Front: Recent Events" + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert len(browse.children) == 0 + + +async def test_camera_event(hass, auth, hass_client): + """Test a media source and image created for an event.""" + event_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp = dt_util.now() + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + event_id, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Media root directory + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.title == "Nest" + assert browse.identifier == "" + assert browse.can_expand + # A device is represented as a child directory + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == device.id + assert browse.children[0].title == "Front: Recent Events" + assert browse.children[0].can_expand + # Expanding the root does not expand the device + assert len(browse.children[0].children) == 0 + + # Browse to the device + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + # The device expands recent events + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == f"{device.id}/{event_id}" + event_timestamp_string = event_timestamp.isoformat(timespec="seconds", sep=" ") + assert browse.children[0].title == f"Person @ {event_timestamp_string}" + assert not browse.children[0].can_expand + assert len(browse.children[0].children) == 0 + + # Browse to the event + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == f"{device.id}/{event_id}" + assert "Person" in browse.title + assert not browse.can_expand + assert not browse.children + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_id}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_id}" + assert media.mime_type == "image/jpeg" + + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + +async def test_event_order(hass, auth): + """Test multiple events are in descending timestamp order.""" + event_id1 = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp1 = dt_util.now() + event_id2 = "GXXWRWVeHNUlUU3V3MGV3bUOYW..." + event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + event_id1, + PERSON_EVENT, + timestamp=event_timestamp1, + ), + create_event( + event_id2, + MOTION_EVENT, + timestamp=event_timestamp2, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + + # Motion event is most recent + assert len(browse.children) == 2 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == f"{device.id}/{event_id2}" + event_timestamp_string = event_timestamp2.isoformat(timespec="seconds", sep=" ") + assert browse.children[0].title == f"Motion @ {event_timestamp_string}" + assert not browse.children[0].can_expand + + # Person event is next + assert browse.children[1].domain == DOMAIN + + assert browse.children[1].identifier == f"{device.id}/{event_id1}" + event_timestamp_string = event_timestamp1.isoformat(timespec="seconds", sep=" ") + assert browse.children[1].title == f"Person @ {event_timestamp_string}" + assert not browse.children[1].can_expand + + +async def test_browse_invalid_device_id(hass, auth): + """Test a media source request for an invalid device id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + with pytest.raises(BrowseError): + await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id" + ) + + with pytest.raises(BrowseError): + await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id/invalid-event-id" + ) + + +async def test_browse_invalid_event_id(hass, auth): + """Test a media source browsing for an invalid event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + + with pytest.raises(BrowseError): + await media_source.async_browse_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + + +async def test_resolve_missing_event_id(hass, auth): + """Test a media source request missing an event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + with pytest.raises(Unresolvable): + await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}", + ) + + +async def test_resolve_invalid_device_id(hass, auth): + """Test resolving media for an invalid event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + with pytest.raises(Unresolvable): + await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + + +async def test_resolve_invalid_event_id(hass, auth): + """Test resolving media for an invalid event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + with pytest.raises(Unresolvable): + await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + + +async def test_camera_event_clip_preview(hass, auth, hass_client): + """Test an event for a battery camera video clip.""" + event_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp = dt_util.now() + event_data = { + "sdm.devices.events.CameraClipPreview.ClipPreview": { + "eventSessionId": EVENT_SESSION_ID, + "previewUrl": "https://127.0.0.1/example", + }, + } + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + BATTERY_CAMERA_TRAITS, + events=[ + create_event_message( + event_id, + event_data, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Browse to the device + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + # The device expands recent events + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + actual_event_id = browse.children[0].identifier + event_timestamp_string = event_timestamp.isoformat(timespec="seconds", sep=" ") + assert browse.children[0].title == f"Event @ {event_timestamp_string}" + assert not browse.children[0].can_expand + assert len(browse.children[0].children) == 0 + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{actual_event_id}" + ) + assert media.url == f"/api/nest/event_media/{actual_event_id}" + assert media.mime_type == "video/mp4" + + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + +async def test_event_media_render_invalid_device_id(hass, auth, hass_client): + """Test event media API called with an invalid device id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + client = await hass_client() + response = await client.get("/api/nest/event_media/invalid-device-id") + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response + ) + + +async def test_event_media_render_invalid_event_id(hass, auth, hass_client): + """Test event media API called with an invalid device id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + client = await hass_client() + response = await client.get("/api/nest/event_media/{device.id}/invalid-event-id") + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response + ) + + +async def test_event_media_failure(hass, auth, hass_client): + """Test event media fetch sees a failure from the server.""" + event_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp = dt_util.now() + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + event_id, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_id}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_id}" + assert media.mime_type == "image/jpeg" + + auth.responses = [ + aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), + ] + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR, ( + "Response not matched: %s" % response + ) + + +async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin_user): + """Test case where user does not have permissions to view media.""" + event_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp = dt_util.now() + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + event_id, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + media_url = f"/api/nest/event_media/{device.id}/{event_id}" + + # Empty policy with no access to the entity + hass_admin_user.mock_policy({}) + + client = await hass_client() + response = await client.get(media_url) + assert response.status == HTTPStatus.UNAUTHORIZED, ( + "Response not matched: %s" % response + )