From 789c0a24dd558207b712ddf10a919d9353853e40 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Jan 2022 20:54:49 -0800 Subject: [PATCH] Improve nest media player clip/image and event handling for multiple events in a short time range (#63149) --- homeassistant/components/nest/__init__.py | 24 +- homeassistant/components/nest/media_source.py | 151 +++-- tests/components/nest/common.py | 3 - tests/components/nest/test_camera_sdm.py | 4 + tests/components/nest/test_events.py | 51 +- tests/components/nest/test_media_source.py | 620 +++++++++++++----- 6 files changed, 594 insertions(+), 259 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index e1c97c5fc38..98f3a5efe51 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -11,6 +11,7 @@ from google_nest_sdm.exceptions import ( ApiException, AuthException, ConfigurationException, + DecodeException, SubscriberException, ) import voluptuous as vol @@ -208,7 +209,7 @@ class SignalUpdateCallback: "device_id": device_entry.id, "type": event_type, "timestamp": event_message.timestamp, - "nest_event_id": image_event.event_session_id, + "nest_event_id": image_event.event_token, } self._hass.bus.async_fire(NEST_EVENT, message) @@ -310,7 +311,7 @@ class NestEventMediaView(HomeAssistantView): depends on the specific device e.g. an image, or a movie clip preview. """ - url = "/api/nest/event_media/{device_id}/{event_id}" + url = "/api/nest/event_media/{device_id}/{event_token}" name = "api:nest:event_media" def __init__(self, hass: HomeAssistant) -> None: @@ -318,7 +319,7 @@ class NestEventMediaView(HomeAssistantView): self.hass = hass async def get( - self, request: web.Request, device_id: str, event_id: str + self, request: web.Request, device_id: str, event_token: str ) -> web.StreamResponse: """Start a GET request.""" user = request[KEY_HASS_USER] @@ -333,17 +334,20 @@ class NestEventMediaView(HomeAssistantView): f"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND ) try: - event_media = await nest_device.event_media_manager.get_media(event_id) + media = await nest_device.event_media_manager.get_media_from_token( + event_token + ) + except DecodeException as err: + raise HomeAssistantError( + "Even token was invalid: %s" % event_token + ) from err except ApiException as err: raise HomeAssistantError("Unable to fetch media for event") from err - if not event_media: + if not media: return self._json_error( - f"No event found for event_id '{event_id}'", HTTPStatus.NOT_FOUND + f"No event found for event_id '{event_token}'", HTTPStatus.NOT_FOUND ) - media = event_media.media - return web.Response( - body=media.contents, content_type=media.event_image_type.content_type - ) + return web.Response(body=media.contents, content_type=media.content_type) def _json_error(self, message: str, status: HTTPStatus) -> web.StreamResponse: """Return a json error message with additional logging.""" diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 889c1a4a2db..ded045b8cfa 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -26,7 +26,11 @@ import os from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait from google_nest_sdm.device import Device from google_nest_sdm.event import EventImageType, ImageEventBase -from google_nest_sdm.event_media import EventMediaStore +from google_nest_sdm.event_media import ( + ClipPreviewSession, + EventMediaStore, + ImageSession, +) from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.components.media_player.const import ( @@ -48,7 +52,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.storage import Store from homeassistant.helpers.template import DATE_STR_FORMAT -from homeassistant.util import dt as dt_util, raise_if_invalid_filename +from homeassistant.util import dt as dt_util from .const import DATA_SUBSCRIBER, DOMAIN from .device_info import NestDeviceInfo @@ -59,7 +63,7 @@ _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}" +EVENT_MEDIA_API_URL_FORMAT = "/api/nest/event_media/{device_id}/{event_token}" STORAGE_KEY = "nest.event_media" STORAGE_VERSION = 1 @@ -146,21 +150,30 @@ class NestEventMediaStore(EventMediaStore): def get_media_key(self, device_id: str, event: ImageEventBase) -> str: """Return the filename to use for a new event.""" - # Convert a nest device id to a home assistant device id - device_id_str = ( + if event.event_image_type != EventImageType.IMAGE: + raise ValueError("No longer used for video clips") + return self.get_image_media_key(device_id, event) + + def _map_device_id(self, device_id: str) -> str: + return ( self._devices.get(device_id, f"{device_id}-unknown_device") if self._devices else "unknown_device" ) - event_id_str = event.event_session_id - try: - raise_if_invalid_filename(event_id_str) - except ValueError: - event_id_str = "" + + def get_image_media_key(self, device_id: str, event: ImageEventBase) -> str: + """Return the filename for image media for an event.""" + device_id_str = self._map_device_id(device_id) time_str = str(int(event.timestamp.timestamp())) event_type_str = EVENT_NAME_MAP.get(event.event_type, "event") - suffix = "jpg" if event.event_image_type == EventImageType.IMAGE else "mp4" - return f"{device_id_str}/{time_str}-{event_id_str}-{event_type_str}.{suffix}" + return f"{device_id_str}/{time_str}-{event_type_str}.jpg" + + def get_clip_preview_media_key(self, device_id: str, event: ImageEventBase) -> str: + """Return the filename for clip preview media for an event session.""" + device_id_str = self._map_device_id(device_id) + time_str = str(int(event.timestamp.timestamp())) + event_type_str = EVENT_NAME_MAP.get(event.event_type, "event") + return f"{device_id_str}/{time_str}-{event_type_str}.mp4" def get_media_filename(self, media_key: str) -> str: """Return the filename in storage for a media key.""" @@ -265,13 +278,13 @@ class MediaId: """ device_id: str - event_id: str | None = None + event_token: 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}" + if self.event_token: + return f"{self.device_id}/{self.event_token}" return self.device_id @@ -308,24 +321,25 @@ class NestMediaSource(MediaSource): 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) + if not media_id.event_token: + raise Unresolvable( + "Identifier missing an event_token: %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 = await _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] + # Infer content type from the device, since it only supports one + # snapshot type (either jpg or mp4 clip) + content_type = EventImageType.IMAGE.content_type + if CameraClipPreviewTrait.NAME in device.traits: + content_type = EventImageType.CLIP_PREVIEW.content_type return PlayMedia( EVENT_MEDIA_API_URL_FORMAT.format( - device_id=media_id.device_id, event_id=media_id.event_id + device_id=media_id.device_id, event_token=media_id.event_token ), - event.event_image_type.content_type, + content_type, ) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: @@ -354,35 +368,67 @@ class NestMediaSource(MediaSource): raise BrowseError( "Unable to find device with identiifer: %s" % item.identifier ) - if media_id.event_id is None: + # Clip previews are a session with multiple possible event types (e.g. + # person, motion, etc) and a single mp4 + if CameraClipPreviewTrait.NAME in device.traits: + clips: dict[ + str, ClipPreviewSession + ] = await _async_get_clip_preview_sessions(device) + if media_id.event_token is None: + # Browse a specific device and return child events + browse_device = _browse_device(media_id, device) + browse_device.children = [] + for clip in clips.values(): + event_id = MediaId(media_id.device_id, clip.event_token) + browse_device.children.append( + _browse_clip_preview(event_id, device, clip) + ) + return browse_device + + # Browse a specific event + if not (single_clip := clips.get(media_id.event_token)): + raise BrowseError( + "Unable to find event with identiifer: %s" % item.identifier + ) + return _browse_clip_preview(media_id, device, single_clip) + + # Image events are 1:1 of media to event + images: dict[str, ImageSession] = await _async_get_image_sessions(device) + if media_id.event_token is None: # Browse a specific device and return child events browse_device = _browse_device(media_id, device) browse_device.children = [] - events = await _get_events(device) - for child_event in events.values(): - event_id = MediaId(media_id.device_id, child_event.event_session_id) + for image in images.values(): + event_id = MediaId(media_id.device_id, image.event_token) browse_device.children.append( - _browse_event(event_id, device, child_event) + _browse_image_event(event_id, device, image) ) return browse_device # Browse a specific event - events = await _get_events(device) - if not (event := events.get(media_id.event_id)): + if not (single_image := images.get(media_id.event_token)): raise BrowseError( "Unable to find event with identiifer: %s" % item.identifier ) - return _browse_event(media_id, device, event) + return _browse_image_event(media_id, device, single_image) async def devices(self) -> Mapping[str, Device]: """Return all event media related devices.""" return await get_media_source_devices(self.hass) -async def _get_events(device: Device) -> Mapping[str, ImageEventBase]: - """Return relevant events for the specified device.""" - events = await device.event_media_manager.async_events() - return {e.event_session_id: e for e in events} +async def _async_get_clip_preview_sessions( + device: Device, +) -> dict[str, ClipPreviewSession]: + """Return clip preview sessions for the device.""" + events = await device.event_media_manager.async_clip_preview_sessions() + return {e.event_token: e for e in events} + + +async def _async_get_image_sessions(device: Device) -> dict[str, ImageSession]: + """Return image events for the device.""" + events = await device.event_media_manager.async_image_sessions() + return {e.event_token: e for e in events} def _browse_root() -> BrowseMediaSource: @@ -418,10 +464,33 @@ def _browse_device(device_id: MediaId, device: Device) -> BrowseMediaSource: ) -def _browse_event( - event_id: MediaId, device: Device, event: ImageEventBase +def _browse_clip_preview( + event_id: MediaId, device: Device, event: ClipPreviewSession ) -> BrowseMediaSource: - """Build a BrowseMediaSource for a specific event.""" + """Build a BrowseMediaSource for a specific clip preview event.""" + types = [] + for event_type in event.event_types: + types.append(MEDIA_SOURCE_EVENT_TITLE_MAP.get(event_type, "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=", ".join(types), + event_time=dt_util.as_local(event.timestamp).strftime(DATE_STR_FORMAT), + ), + can_play=True, + can_expand=False, + thumbnail=None, + children=[], + ) + + +def _browse_image_event( + event_id: MediaId, device: Device, event: ImageSession +) -> BrowseMediaSource: + """Build a BrowseMediaSource for a specific image event.""" return BrowseMediaSource( domain=DOMAIN, identifier=event_id.identifier, @@ -431,7 +500,7 @@ def _browse_event( event_name=MEDIA_SOURCE_EVENT_TITLE_MAP.get(event.event_type, "Event"), event_time=dt_util.as_local(event.timestamp).strftime(DATE_STR_FORMAT), ), - can_play=(event.event_image_type == EventImageType.CLIP_PREVIEW), + can_play=False, can_expand=False, thumbnail=None, children=[], diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 0f1d47c687e..d725761c9d7 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -117,7 +117,4 @@ async def async_setup_sdm_platform( ): assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() - # Disabled to reduce setup burden, and enabled manually by tests that - # need to exercise this - subscriber.cache_policy.fetch = False return subscriber diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index 0b4304d31b2..a4539cf9f81 100644 --- a/tests/components/nest/test_camera_sdm.py +++ b/tests/components/nest/test_camera_sdm.py @@ -406,6 +406,8 @@ async def test_camera_removed(hass, auth): DEVICE_TRAITS, auth=auth, ) + # Simplify test setup + subscriber.cache_policy.fetch = False assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") @@ -631,6 +633,8 @@ async def test_event_image_expired(hass, auth): async def test_multiple_event_images(hass, auth): """Test fallback for an event event image that has been cleaned up on expiration.""" subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) + # Simplify test setup + subscriber.cache_policy.fetch = False assert len(hass.states.async_all()) == 1 assert hass.states.get("camera.my_camera") diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index d5c6c744284..ee286242a8c 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -4,7 +4,11 @@ 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 @@ -24,8 +28,15 @@ NEST_EVENT = "nest_event" EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." +EVENT_KEYS = {"device_id", "type", "timestamp"} -async def async_setup_devices(hass, device_type, 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} + + +async def async_setup_devices(hass, device_type, traits={}, auth=None): """Set up the platform and prerequisites.""" devices = { DEVICE_ID: Device.MakeDevice( @@ -34,7 +45,7 @@ async def async_setup_devices(hass, device_type, traits={}): "type": device_type, "traits": traits, }, - auth=None, + auth=auth, ), } return await async_setup_sdm_platform(hass, PLATFORM, devices=devices) @@ -87,13 +98,14 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): ) -async def test_doorbell_chime_event(hass): +async def test_doorbell_chime_event(hass, auth): """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"]), + auth, ) registry = er.async_get(hass) @@ -117,11 +129,10 @@ async def test_doorbell_chime_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "doorbell_chime", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -145,11 +156,10 @@ async def test_camera_motion_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -173,11 +183,10 @@ async def test_camera_sound_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_sound", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -201,11 +210,10 @@ async def test_camera_person_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -240,17 +248,15 @@ async def test_camera_multiple_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 2 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } - assert events[1].data == { + assert event_view(events[1].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -306,7 +312,7 @@ async def test_event_message_without_device_event(hass): assert len(events) == 0 -async def test_doorbell_event_thread(hass): +async def test_doorbell_event_thread(hass, auth): """Test a series of pubsub messages in the same thread.""" events = async_capture_events(hass, NEST_EVENT) subscriber = await async_setup_devices( @@ -318,6 +324,7 @@ async def test_doorbell_event_thread(hass): "sdm.devices.traits.CameraPerson", ] ), + auth, ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -367,15 +374,14 @@ async def test_doorbell_event_thread(hass): # The event is only published once assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": timestamp1.replace(microsecond=0), - "nest_event_id": EVENT_SESSION_ID, } -async def test_doorbell_event_session_update(hass): +async def test_doorbell_event_session_update(hass, auth): """Test a pubsub message with updates to an existing session.""" events = async_capture_events(hass, NEST_EVENT) subscriber = await async_setup_devices( @@ -388,6 +394,7 @@ async def test_doorbell_event_session_update(hass): "sdm.devices.traits.CameraMotion", ] ), + auth, ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -435,17 +442,15 @@ async def test_doorbell_event_session_update(hass): await hass.async_block_till_done() assert len(events) == 2 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": timestamp1.replace(microsecond=0), - "nest_event_id": EVENT_SESSION_ID, } - assert events[1].data == { + assert event_view(events[1].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": timestamp2.replace(microsecond=0), - "nest_event_id": EVENT_SESSION_ID, } diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 1ddce0c7818..82d8c68267e 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -31,6 +31,8 @@ from .common import ( create_config_entry, ) +from tests.common import async_capture_events + DOMAIN = "nest" DEVICE_ID = "example/api/device/id" DEVICE_NAME = "Front" @@ -70,6 +72,7 @@ GENERATE_IMAGE_URL_RESPONSE = { } IMAGE_BYTES_FROM_EVENT = b"test url image bytes" IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} +NEST_EVENT = "nest_event" async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): @@ -85,10 +88,8 @@ async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): ), } 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() + # Enable feature for fetching media + subscriber.cache_policy.fetch = True return subscriber @@ -223,20 +224,8 @@ async def test_integration_unloaded(hass, auth): async def test_camera_event(hass, auth, hass_client): """Test a media source and image created for an event.""" - event_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS ) assert len(hass.states.async_all()) == 1 @@ -248,6 +237,31 @@ async def test_camera_event(hass, auth, hass_client): assert device assert device.name == DEVICE_NAME + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + # Set up fake media, and publish image events + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + assert len(received_events) == 1 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_person" + event_identifier = received_event.data["nest_event_id"] + # Media root directory browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") assert browse.title == "Nest" @@ -273,7 +287,7 @@ async def test_camera_event(hass, auth, hass_client): # 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_SESSION_ID}" + assert browse.children[0].identifier == f"{device.id}/{event_identifier}" event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Person @ {event_timestamp_string}" assert not browse.children[0].can_expand @@ -281,10 +295,10 @@ async def test_camera_event(hass, auth, hass_client): # Browse to the event browse = await media_source.async_browse_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" ) assert browse.domain == DOMAIN - assert browse.identifier == f"{device.id}/{EVENT_SESSION_ID}" + assert browse.identifier == f"{device.id}/{event_identifier}" assert "Person" in browse.title assert not browse.can_expand assert not browse.children @@ -292,16 +306,11 @@ async def test_camera_event(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" 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 @@ -311,30 +320,39 @@ async def test_camera_event(hass, auth, hass_client): async def test_event_order(hass, auth): """Test multiple events are in descending timestamp order.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS + ) + + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] event_session_id1 = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." event_timestamp1 = dt_util.now() + await subscriber.async_receive_event( + create_event( + event_session_id1, + EVENT_ID + "1", + PERSON_EVENT, + timestamp=event_timestamp1, + ) + ) + await hass.async_block_till_done() + event_session_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_session_id1, - EVENT_ID + "1", - PERSON_EVENT, - timestamp=event_timestamp1, - ), - create_event( - event_session_id2, - EVENT_ID + "2", - MOTION_EVENT, - timestamp=event_timestamp2, - ), - ], + await subscriber.async_receive_event( + create_event( + event_session_id2, + EVENT_ID + "2", + MOTION_EVENT, + timestamp=event_timestamp2, + ), ) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -356,7 +374,6 @@ async def test_event_order(hass, auth): # 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_session_id2}" event_timestamp_string = event_timestamp2.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert not browse.children[0].can_expand @@ -364,14 +381,219 @@ async def test_event_order(hass, auth): # Person event is next assert browse.children[1].domain == DOMAIN - - assert browse.children[1].identifier == f"{device.id}/{event_session_id1}" event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT) assert browse.children[1].title == f"Person @ {event_timestamp_string}" assert not browse.children[1].can_expand assert not browse.children[1].can_play +async def test_multiple_image_events_in_session(hass, auth, hass_client): + """Test multiple events published within the same event session.""" + event_session_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp1 = dt_util.now() + event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) + subscriber = 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 + + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT + b"-1"), + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT + b"-2"), + ] + await subscriber.async_receive_event( + # First camera sees motion then it recognizes a person + create_event( + event_session_id, + EVENT_ID + "1", + MOTION_EVENT, + timestamp=event_timestamp1, + ) + ) + await hass.async_block_till_done() + await subscriber.async_receive_event( + create_event( + event_session_id, + EVENT_ID + "2", + PERSON_EVENT, + timestamp=event_timestamp2, + ), + ) + await hass.async_block_till_done() + + assert len(received_events) == 2 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier1 = received_event.data["nest_event_id"] + received_event = received_events[1] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_person" + event_identifier2 = received_event.data["nest_event_id"] + + 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 + + # Person event is most recent + assert len(browse.children) == 2 + event = browse.children[0] + assert event.domain == DOMAIN + assert event.identifier == f"{device.id}/{event_identifier2}" + event_timestamp_string = event_timestamp2.strftime(DATE_STR_FORMAT) + assert event.title == f"Person @ {event_timestamp_string}" + assert not event.can_expand + assert not event.can_play + + # Motion event is next + event = browse.children[1] + assert event.domain == DOMAIN + assert event.identifier == f"{device.id}/{event_identifier1}" + event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT) + assert event.title == f"Motion @ {event_timestamp_string}" + assert not event.can_expand + assert not event.can_play + + # Resolve the most recent event + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier2}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier2}" + assert media.mime_type == "image/jpeg" + + 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 + b"-2" + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" + assert media.mime_type == "image/jpeg" + + 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 + b"-1" + + +async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): + """Test multiple events published within the same event session.""" + event_timestamp1 = dt_util.now() + event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_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 + + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + # Publish two events: First motion, then a person is recognized. Both + # events share a single clip. + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp1, + ) + ) + await hass.async_block_till_done() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(PERSON_EVENT), + timestamp=event_timestamp2, + ) + ) + await hass.async_block_till_done() + + assert len(received_events) == 2 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier1 = received_event.data["nest_event_id"] + received_event = received_events[1] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_person" + event_identifier2 = received_event.data["nest_event_id"] + + 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 two distinct events are combined in a single clip preview + assert len(browse.children) == 1 + event = browse.children[0] + assert event.domain == DOMAIN + event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT) + assert event.identifier == f"{device.id}/{event_identifier2}" + assert event.title == f"Motion, Person @ {event_timestamp_string}" + assert not event.can_expand + assert event.can_play + + # Resolve media for each event that was published and they will resolve + # to the same clip preview media clip object. + # Resolve media for the first event + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" + assert media.mime_type == "video/mp4" + + 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 + + # Resolve media for the second event + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" + assert media.mime_type == "video/mp4" + + 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_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) @@ -451,29 +673,39 @@ async def test_resolve_invalid_event_id(hass, auth): 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...", - ) + # Assume any event ID can be resolved to a media url. Fetching the actual media may fail + # if the ID is not valid. Content type is inferred based on the capabilities of the device. + media = await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + assert ( + media.url == f"/api/nest/event_media/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW..." + ) + assert media.mime_type == "image/jpeg" async def test_camera_event_clip_preview(hass, auth, hass_client): """Test an event for a battery camera video clip.""" - event_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - BATTERY_CAMERA_TRAITS, - events=[ - create_event_message( - create_battery_event_data(MOTION_EVENT), - timestamp=event_timestamp, - ), - ], + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS ) + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") assert camera is not None @@ -483,6 +715,13 @@ async def test_camera_event_clip_preview(hass, auth, hass_client): assert device assert device.name == DEVICE_NAME + # Verify events are published correctly + assert len(received_events) == 1 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier = received_event.data["nest_event_id"] + # Browse to the device browse = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" @@ -494,24 +733,34 @@ async def test_camera_event_clip_preview(hass, auth, hass_client): # The device expands recent events assert len(browse.children) == 1 assert browse.children[0].domain == DOMAIN - actual_event_id = browse.children[0].identifier + assert browse.children[0].identifier == f"{device.id}/{event_identifier}" event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert not browse.children[0].can_expand assert len(browse.children[0].children) == 0 assert browse.children[0].can_play + # Verify received event and media ids match + assert browse.children[0].identifier == f"{device.id}/{event_identifier}" + + # Browse to the event + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + ) + assert browse.domain == DOMAIN + event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) + assert browse.title == f"Motion @ {event_timestamp_string}" + assert not browse.can_expand + assert len(browse.children) == 0 + assert browse.can_play + # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{actual_event_id}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{actual_event_id}" + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" 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 @@ -548,22 +797,24 @@ async def test_event_media_render_invalid_event_id(hass, auth, hass_client): async def test_event_media_failure(hass, auth, hass_client): """Test event media fetch sees a failure from the server.""" - event_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS ) + auth.responses = [ + aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") assert camera is not None @@ -580,10 +831,6 @@ async def test_event_media_failure(hass, auth, hass_client): assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_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, ( @@ -593,21 +840,7 @@ async def test_event_media_failure(hass, auth, hass_client): 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_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], - ) + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -618,7 +851,7 @@ async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin assert device assert device.name == DEVICE_NAME - media_url = f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + media_url = f"/api/nest/event_media/{device.id}/some-event-id" # Empty policy with no access to the entity hass_admin_user.mock_policy({}) @@ -673,6 +906,10 @@ async def test_multiple_devices(hass, auth, hass_client): # Send events for device #1 for i in range(0, 5): + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] await subscriber.async_receive_event( create_event( f"event-session-id-{i}", @@ -681,6 +918,7 @@ async def test_multiple_devices(hass, auth, hass_client): device_id=device_id1, ) ) + await hass.async_block_till_done() browse = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" @@ -693,11 +931,16 @@ async def test_multiple_devices(hass, auth, hass_client): # Send events for device #2 for i in range(0, 3): + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] await subscriber.async_receive_event( create_event( f"other-id-{i}", f"event-id{i}", PERSON_EVENT, device_id=device_id2 ) ) + await hass.async_block_till_done() browse = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" @@ -761,6 +1004,7 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): create_battery_event_data(MOTION_EVENT), timestamp=event_timestamp ) ) + await hass.async_block_till_done() # Browse to event browse = await media_source.async_browse_media( @@ -768,16 +1012,16 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): ) assert len(browse.children) == 1 assert browse.children[0].domain == DOMAIN - assert browse.children[0].identifier == f"{device.id}/{EVENT_SESSION_ID}" event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert not browse.children[0].can_expand assert browse.children[0].can_play + event_identifier = browse.children[0].identifier media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" # Fetch event media @@ -801,8 +1045,6 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): subscriber = FakeSubscriber() device_manager = await subscriber.async_get_device_manager() device_manager.add_device(nest_device) - # Fetch media for events when published - subscriber.cache_policy.fetch = True with patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" @@ -824,16 +1066,16 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): ) assert len(browse.children) == 1 assert browse.children[0].domain == DOMAIN - assert browse.children[0].identifier == f"{device.id}/{EVENT_SESSION_ID}" event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert not browse.children[0].can_expand assert browse.children[0].can_play + event_identifier = browse.children[0].identifier media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" # Verify media exists @@ -843,20 +1085,63 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): assert contents == IMAGE_BYTES_FROM_EVENT -async def test_media_store_filesystem_error(hass, auth, hass_client): - """Test a filesystem error read/writing event media.""" +async def test_media_store_save_filesystem_error(hass, auth, hass_client): + """Test a filesystem error writing event media.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS + ) + + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] event_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - BATTERY_CAMERA_TRAITS, - events=[ + # The client fetches the media from the server, but has a failure when + # persisting the media to disk. + client = await hass_client() + with patch("homeassistant.components.nest.media_source.open", side_effect=OSError): + await subscriber.async_receive_event( create_event_message( create_battery_event_data(MOTION_EVENT), timestamp=event_timestamp, - ), - ], + ) + ) + await hass.async_block_till_done() + + 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 len(browse.children) == 1 + event = browse.children[0] + + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{event.identifier}" + ) + assert media.url == f"/api/nest/event_media/{event.identifier}" + assert media.mime_type == "video/mp4" + + # We fail to retrieve the media from the server since the origin filesystem op failed + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response + ) + + +async def test_media_store_load_filesystem_error(hass, auth, hass_client): + """Test a filesystem error reading event media.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS ) assert len(hass.states.async_all()) == 1 @@ -868,64 +1153,38 @@ async def test_media_store_filesystem_error(hass, auth, hass_client): assert device assert device.name == DEVICE_NAME + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + auth.responses = [ aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() - # The client fetches the media from the server, but has a failure when - # persisting the media to disk. + assert len(received_events) == 1 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier = received_event.data["nest_event_id"] + + client = await hass_client() + + # Fetch the media from the server, and simluate a failure reading from disk client = await hass_client() with patch("homeassistant.components.nest.media_source.open", side_effect=OSError): response = await client.get( - f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + f"/api/nest/event_media/{device.id}/{event_identifier}" ) - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT - await hass.async_block_till_done() - - # Fetch the media again, and since the object does not exist in the cache it - # needs to be fetched again. The server returns an error to prove that it was - # not a cache read. A second attempt succeeds. - auth.responses = [ - aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), - aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), - ] - # First attempt, server fails when fetching - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR, ( - "Response not matched: %s" % response - ) - - # Second attempt, server responds success - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT - - # Third attempt reads from the disk cache with no server fetch - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT - - auth.responses = [ - aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), - ] - # Exercise a failure reading from the disk cache. Re-populate from server and write to disk ok - with patch("homeassistant.components.nest.media_source.open", side_effect=OSError): - response = await client.get( - f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response ) - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT - await hass.async_block_till_done() - - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT async def test_camera_event_media_eviction(hass, auth, hass_client): @@ -940,9 +1199,6 @@ async def test_camera_event_media_eviction(hass, auth, hass_client): BATTERY_CAMERA_TRAITS, ) - # Media fetched as soon as it is published - subscriber.cache_policy.fetch = True - device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) assert device @@ -1003,13 +1259,13 @@ async def test_camera_event_media_eviction(hass, auth, hass_client): hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" ) assert len(browse.children) == 5 + child_events = iter(browse.children) # Verify all other content is still persisted correctly client = await hass_client() - for i in range(3, 8): - response = await client.get( - f"/api/nest/event_media/{device.id}/event-session-{i}" - ) + for i in reversed(range(3, 8)): + child_event = next(child_events) + response = await client.get(f"/api/nest/event_media/{child_event.identifier}") assert response.status == HTTPStatus.OK, "Response not matched: %s" % response contents = await response.read() assert contents == f"image-bytes-{i}".encode()