Improve nest media player clip/image and event handling for multiple events in a short time range (#63149)

This commit is contained in:
Allen Porter 2022-01-11 20:54:49 -08:00 committed by GitHub
parent 42706f780c
commit 789c0a24dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 594 additions and 259 deletions

View file

@ -11,6 +11,7 @@ from google_nest_sdm.exceptions import (
ApiException, ApiException,
AuthException, AuthException,
ConfigurationException, ConfigurationException,
DecodeException,
SubscriberException, SubscriberException,
) )
import voluptuous as vol import voluptuous as vol
@ -208,7 +209,7 @@ class SignalUpdateCallback:
"device_id": device_entry.id, "device_id": device_entry.id,
"type": event_type, "type": event_type,
"timestamp": event_message.timestamp, "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) 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. 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" name = "api:nest:event_media"
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
@ -318,7 +319,7 @@ class NestEventMediaView(HomeAssistantView):
self.hass = hass self.hass = hass
async def get( 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: ) -> web.StreamResponse:
"""Start a GET request.""" """Start a GET request."""
user = request[KEY_HASS_USER] user = request[KEY_HASS_USER]
@ -333,17 +334,20 @@ class NestEventMediaView(HomeAssistantView):
f"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND f"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND
) )
try: 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: except ApiException as err:
raise HomeAssistantError("Unable to fetch media for event") from err raise HomeAssistantError("Unable to fetch media for event") from err
if not event_media: if not media:
return self._json_error( 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.content_type)
return web.Response(
body=media.contents, content_type=media.event_image_type.content_type
)
def _json_error(self, message: str, status: HTTPStatus) -> web.StreamResponse: def _json_error(self, message: str, status: HTTPStatus) -> web.StreamResponse:
"""Return a json error message with additional logging.""" """Return a json error message with additional logging."""

View file

@ -26,7 +26,11 @@ import os
from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait
from google_nest_sdm.device import Device from google_nest_sdm.device import Device
from google_nest_sdm.event import EventImageType, ImageEventBase 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 google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
from homeassistant.components.media_player.const import ( 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 import device_registry as dr
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.template import DATE_STR_FORMAT 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 .const import DATA_SUBSCRIBER, DOMAIN
from .device_info import NestDeviceInfo from .device_info import NestDeviceInfo
@ -59,7 +63,7 @@ _LOGGER = logging.getLogger(__name__)
MEDIA_SOURCE_TITLE = "Nest" MEDIA_SOURCE_TITLE = "Nest"
DEVICE_TITLE_FORMAT = "{device_name}: Recent Events" DEVICE_TITLE_FORMAT = "{device_name}: Recent Events"
CLIP_TITLE_FORMAT = "{event_name} @ {event_time}" 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_KEY = "nest.event_media"
STORAGE_VERSION = 1 STORAGE_VERSION = 1
@ -146,21 +150,30 @@ class NestEventMediaStore(EventMediaStore):
def get_media_key(self, device_id: str, event: ImageEventBase) -> str: def get_media_key(self, device_id: str, event: ImageEventBase) -> str:
"""Return the filename to use for a new event.""" """Return the filename to use for a new event."""
# Convert a nest device id to a home assistant device id if event.event_image_type != EventImageType.IMAGE:
device_id_str = ( 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") self._devices.get(device_id, f"{device_id}-unknown_device")
if self._devices if self._devices
else "unknown_device" else "unknown_device"
) )
event_id_str = event.event_session_id
try: def get_image_media_key(self, device_id: str, event: ImageEventBase) -> str:
raise_if_invalid_filename(event_id_str) """Return the filename for image media for an event."""
except ValueError: device_id_str = self._map_device_id(device_id)
event_id_str = ""
time_str = str(int(event.timestamp.timestamp())) time_str = str(int(event.timestamp.timestamp()))
event_type_str = EVENT_NAME_MAP.get(event.event_type, "event") 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_type_str}.jpg"
return f"{device_id_str}/{time_str}-{event_id_str}-{event_type_str}.{suffix}"
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: def get_media_filename(self, media_key: str) -> str:
"""Return the filename in storage for a media key.""" """Return the filename in storage for a media key."""
@ -265,13 +278,13 @@ class MediaId:
""" """
device_id: str device_id: str
event_id: str | None = None event_token: str | None = None
@property @property
def identifier(self) -> str: def identifier(self) -> str:
"""Media identifier represented as a string.""" """Media identifier represented as a string."""
if self.event_id: if self.event_token:
return f"{self.device_id}/{self.event_id}" return f"{self.device_id}/{self.event_token}"
return self.device_id return self.device_id
@ -308,24 +321,25 @@ class NestMediaSource(MediaSource):
media_id: MediaId | None = parse_media_id(item.identifier) media_id: MediaId | None = parse_media_id(item.identifier)
if not media_id: if not media_id:
raise Unresolvable("No identifier specified for MediaSourceItem") raise Unresolvable("No identifier specified for MediaSourceItem")
if not media_id.event_id: if not media_id.event_token:
raise Unresolvable("Identifier missing an event_id: %s" % item.identifier) raise Unresolvable(
"Identifier missing an event_token: %s" % item.identifier
)
devices = await self.devices() devices = await self.devices()
if not (device := devices.get(media_id.device_id)): if not (device := devices.get(media_id.device_id)):
raise Unresolvable( raise Unresolvable(
"Unable to find device with identifier: %s" % item.identifier "Unable to find device with identifier: %s" % item.identifier
) )
events = await _get_events(device) # Infer content type from the device, since it only supports one
if media_id.event_id not in events: # snapshot type (either jpg or mp4 clip)
raise Unresolvable( content_type = EventImageType.IMAGE.content_type
"Unable to find event with identifier: %s" % item.identifier if CameraClipPreviewTrait.NAME in device.traits:
) content_type = EventImageType.CLIP_PREVIEW.content_type
event = events[media_id.event_id]
return PlayMedia( return PlayMedia(
EVENT_MEDIA_API_URL_FORMAT.format( 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: async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
@ -354,35 +368,67 @@ class NestMediaSource(MediaSource):
raise BrowseError( raise BrowseError(
"Unable to find device with identiifer: %s" % item.identifier "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 a specific device and return child events
browse_device = _browse_device(media_id, device) browse_device = _browse_device(media_id, device)
browse_device.children = [] browse_device.children = []
events = await _get_events(device) for image in images.values():
for child_event in events.values(): event_id = MediaId(media_id.device_id, image.event_token)
event_id = MediaId(media_id.device_id, child_event.event_session_id)
browse_device.children.append( browse_device.children.append(
_browse_event(event_id, device, child_event) _browse_image_event(event_id, device, image)
) )
return browse_device return browse_device
# Browse a specific event # Browse a specific event
events = await _get_events(device) if not (single_image := images.get(media_id.event_token)):
if not (event := events.get(media_id.event_id)):
raise BrowseError( raise BrowseError(
"Unable to find event with identiifer: %s" % item.identifier "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]: async def devices(self) -> Mapping[str, Device]:
"""Return all event media related devices.""" """Return all event media related devices."""
return await get_media_source_devices(self.hass) return await get_media_source_devices(self.hass)
async def _get_events(device: Device) -> Mapping[str, ImageEventBase]: async def _async_get_clip_preview_sessions(
"""Return relevant events for the specified device.""" device: Device,
events = await device.event_media_manager.async_events() ) -> dict[str, ClipPreviewSession]:
return {e.event_session_id: e for e in events} """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: def _browse_root() -> BrowseMediaSource:
@ -418,10 +464,33 @@ def _browse_device(device_id: MediaId, device: Device) -> BrowseMediaSource:
) )
def _browse_event( def _browse_clip_preview(
event_id: MediaId, device: Device, event: ImageEventBase event_id: MediaId, device: Device, event: ClipPreviewSession
) -> BrowseMediaSource: ) -> 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( return BrowseMediaSource(
domain=DOMAIN, domain=DOMAIN,
identifier=event_id.identifier, identifier=event_id.identifier,
@ -431,7 +500,7 @@ def _browse_event(
event_name=MEDIA_SOURCE_EVENT_TITLE_MAP.get(event.event_type, "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), 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, can_expand=False,
thumbnail=None, thumbnail=None,
children=[], children=[],

View file

@ -117,7 +117,4 @@ async def async_setup_sdm_platform(
): ):
assert await async_setup_component(hass, DOMAIN, CONFIG) assert await async_setup_component(hass, DOMAIN, CONFIG)
await hass.async_block_till_done() 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 return subscriber

View file

@ -406,6 +406,8 @@ async def test_camera_removed(hass, auth):
DEVICE_TRAITS, DEVICE_TRAITS,
auth=auth, auth=auth,
) )
# Simplify test setup
subscriber.cache_policy.fetch = False
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
cam = hass.states.get("camera.my_camera") 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): async def test_multiple_event_images(hass, auth):
"""Test fallback for an event event image that has been cleaned up on expiration.""" """Test fallback for an event event image that has been cleaned up on expiration."""
subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) 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 len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera") assert hass.states.get("camera.my_camera")

View file

@ -4,7 +4,11 @@ These tests fake out the subscriber/devicemanager, and are not using a real
pubsub subscriber. pubsub subscriber.
""" """
from __future__ import annotations
from collections.abc import Mapping
import datetime import datetime
from typing import Any
from unittest.mock import patch from unittest.mock import patch
from google_nest_sdm.device import Device from google_nest_sdm.device import Device
@ -24,8 +28,15 @@ NEST_EVENT = "nest_event"
EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..."
EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." 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.""" """Set up the platform and prerequisites."""
devices = { devices = {
DEVICE_ID: Device.MakeDevice( DEVICE_ID: Device.MakeDevice(
@ -34,7 +45,7 @@ async def async_setup_devices(hass, device_type, traits={}):
"type": device_type, "type": device_type,
"traits": traits, "traits": traits,
}, },
auth=None, auth=auth,
), ),
} }
return await async_setup_sdm_platform(hass, PLATFORM, devices=devices) 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.""" """Test a pubsub message for a doorbell event."""
events = async_capture_events(hass, NEST_EVENT) events = async_capture_events(hass, NEST_EVENT)
subscriber = await async_setup_devices( subscriber = await async_setup_devices(
hass, hass,
"sdm.devices.types.DOORBELL", "sdm.devices.types.DOORBELL",
create_device_traits(["sdm.devices.traits.DoorbellChime"]), create_device_traits(["sdm.devices.traits.DoorbellChime"]),
auth,
) )
registry = er.async_get(hass) registry = er.async_get(hass)
@ -117,11 +129,10 @@ async def test_doorbell_chime_event(hass):
event_time = timestamp.replace(microsecond=0) event_time = timestamp.replace(microsecond=0)
assert len(events) == 1 assert len(events) == 1
assert events[0].data == { assert event_view(events[0].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "doorbell_chime", "type": "doorbell_chime",
"timestamp": event_time, "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) event_time = timestamp.replace(microsecond=0)
assert len(events) == 1 assert len(events) == 1
assert events[0].data == { assert event_view(events[0].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "camera_motion", "type": "camera_motion",
"timestamp": event_time, "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) event_time = timestamp.replace(microsecond=0)
assert len(events) == 1 assert len(events) == 1
assert events[0].data == { assert event_view(events[0].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "camera_sound", "type": "camera_sound",
"timestamp": event_time, "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) event_time = timestamp.replace(microsecond=0)
assert len(events) == 1 assert len(events) == 1
assert events[0].data == { assert event_view(events[0].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "camera_person", "type": "camera_person",
"timestamp": event_time, "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) event_time = timestamp.replace(microsecond=0)
assert len(events) == 2 assert len(events) == 2
assert events[0].data == { assert event_view(events[0].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "camera_motion", "type": "camera_motion",
"timestamp": event_time, "timestamp": event_time,
"nest_event_id": EVENT_SESSION_ID,
} }
assert events[1].data == { assert event_view(events[1].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "camera_person", "type": "camera_person",
"timestamp": event_time, "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 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.""" """Test a series of pubsub messages in the same thread."""
events = async_capture_events(hass, NEST_EVENT) events = async_capture_events(hass, NEST_EVENT)
subscriber = await async_setup_devices( subscriber = await async_setup_devices(
@ -318,6 +324,7 @@ async def test_doorbell_event_thread(hass):
"sdm.devices.traits.CameraPerson", "sdm.devices.traits.CameraPerson",
] ]
), ),
auth,
) )
registry = er.async_get(hass) registry = er.async_get(hass)
entry = registry.async_get("camera.front") entry = registry.async_get("camera.front")
@ -367,15 +374,14 @@ async def test_doorbell_event_thread(hass):
# The event is only published once # The event is only published once
assert len(events) == 1 assert len(events) == 1
assert events[0].data == { assert event_view(events[0].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "camera_motion", "type": "camera_motion",
"timestamp": timestamp1.replace(microsecond=0), "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.""" """Test a pubsub message with updates to an existing session."""
events = async_capture_events(hass, NEST_EVENT) events = async_capture_events(hass, NEST_EVENT)
subscriber = await async_setup_devices( subscriber = await async_setup_devices(
@ -388,6 +394,7 @@ async def test_doorbell_event_session_update(hass):
"sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraMotion",
] ]
), ),
auth,
) )
registry = er.async_get(hass) registry = er.async_get(hass)
entry = registry.async_get("camera.front") entry = registry.async_get("camera.front")
@ -435,17 +442,15 @@ async def test_doorbell_event_session_update(hass):
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(events) == 2 assert len(events) == 2
assert events[0].data == { assert event_view(events[0].data) == {
"device_id": entry.device_id, "device_id": entry.device_id,
"type": "camera_motion", "type": "camera_motion",
"timestamp": timestamp1.replace(microsecond=0), "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, "device_id": entry.device_id,
"type": "camera_person", "type": "camera_person",
"timestamp": timestamp2.replace(microsecond=0), "timestamp": timestamp2.replace(microsecond=0),
"nest_event_id": EVENT_SESSION_ID,
} }

View file

@ -31,6 +31,8 @@ from .common import (
create_config_entry, create_config_entry,
) )
from tests.common import async_capture_events
DOMAIN = "nest" DOMAIN = "nest"
DEVICE_ID = "example/api/device/id" DEVICE_ID = "example/api/device/id"
DEVICE_NAME = "Front" DEVICE_NAME = "Front"
@ -70,6 +72,7 @@ GENERATE_IMAGE_URL_RESPONSE = {
} }
IMAGE_BYTES_FROM_EVENT = b"test url image bytes" IMAGE_BYTES_FROM_EVENT = b"test url image bytes"
IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"}
NEST_EVENT = "nest_event"
async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): 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) subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices)
if events: # Enable feature for fetching media
for event in events: subscriber.cache_policy.fetch = True
await subscriber.async_receive_event(event)
await hass.async_block_till_done()
return subscriber return subscriber
@ -223,20 +224,8 @@ async def test_integration_unloaded(hass, auth):
async def test_camera_event(hass, auth, hass_client): async def test_camera_event(hass, auth, hass_client):
"""Test a media source and image created for an event.""" """Test a media source and image created for an event."""
event_timestamp = dt_util.now() subscriber = await async_setup_devices(
await async_setup_devices( hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS
hass,
auth,
CAMERA_DEVICE_TYPE,
CAMERA_TRAITS,
events=[
create_event(
EVENT_SESSION_ID,
EVENT_ID,
PERSON_EVENT,
timestamp=event_timestamp,
),
],
) )
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
@ -248,6 +237,31 @@ async def test_camera_event(hass, auth, hass_client):
assert device assert device
assert device.name == DEVICE_NAME 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 # Media root directory
browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}")
assert browse.title == "Nest" assert browse.title == "Nest"
@ -273,7 +287,7 @@ async def test_camera_event(hass, auth, hass_client):
# The device expands recent events # The device expands recent events
assert len(browse.children) == 1 assert len(browse.children) == 1
assert browse.children[0].domain == DOMAIN 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) event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT)
assert browse.children[0].title == f"Person @ {event_timestamp_string}" assert browse.children[0].title == f"Person @ {event_timestamp_string}"
assert not browse.children[0].can_expand 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 to the event
browse = await media_source.async_browse_media( 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.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 "Person" in browse.title
assert not browse.can_expand assert not browse.can_expand
assert not browse.children assert not browse.children
@ -292,16 +306,11 @@ async def test_camera_event(hass, auth, hass_client):
# Resolving the event links to the media # Resolving the event links to the media
media = await media_source.async_resolve_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" 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() client = await hass_client()
response = await client.get(media.url) response = await client.get(media.url)
assert response.status == HTTPStatus.OK, "Response not matched: %s" % response 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): async def test_event_order(hass, auth):
"""Test multiple events are in descending timestamp order.""" """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_session_id1 = "FWWVQVUdGNUlTU2V4MGV2aTNXV..."
event_timestamp1 = dt_util.now() 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_session_id2 = "GXXWRWVeHNUlUU3V3MGV3bUOYW..."
event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5)
await async_setup_devices( await subscriber.async_receive_event(
hass, create_event(
auth, event_session_id2,
CAMERA_DEVICE_TYPE, EVENT_ID + "2",
CAMERA_TRAITS, MOTION_EVENT,
events=[ timestamp=event_timestamp2,
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 hass.async_block_till_done()
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
camera = hass.states.get("camera.front") camera = hass.states.get("camera.front")
@ -356,7 +374,6 @@ async def test_event_order(hass, auth):
# Motion event is most recent # Motion event is most recent
assert len(browse.children) == 2 assert len(browse.children) == 2
assert browse.children[0].domain == DOMAIN 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) event_timestamp_string = event_timestamp2.strftime(DATE_STR_FORMAT)
assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert browse.children[0].title == f"Motion @ {event_timestamp_string}"
assert not browse.children[0].can_expand assert not browse.children[0].can_expand
@ -364,14 +381,219 @@ async def test_event_order(hass, auth):
# Person event is next # Person event is next
assert browse.children[1].domain == DOMAIN 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) event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT)
assert browse.children[1].title == f"Person @ {event_timestamp_string}" assert browse.children[1].title == f"Person @ {event_timestamp_string}"
assert not browse.children[1].can_expand assert not browse.children[1].can_expand
assert not browse.children[1].can_play 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): async def test_browse_invalid_device_id(hass, auth):
"""Test a media source request for an invalid device id.""" """Test a media source request for an invalid device id."""
await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) 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
assert device.name == DEVICE_NAME assert device.name == DEVICE_NAME
with pytest.raises(Unresolvable): # Assume any event ID can be resolved to a media url. Fetching the actual media may fail
await media_source.async_resolve_media( # if the ID is not valid. Content type is inferred based on the capabilities of the device.
hass, media = await media_source.async_resolve_media(
f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", 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): async def test_camera_event_clip_preview(hass, auth, hass_client):
"""Test an event for a battery camera video clip.""" """Test an event for a battery camera video clip."""
event_timestamp = dt_util.now() subscriber = await async_setup_devices(
await async_setup_devices( hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS
hass,
auth,
CAMERA_DEVICE_TYPE,
BATTERY_CAMERA_TRAITS,
events=[
create_event_message(
create_battery_event_data(MOTION_EVENT),
timestamp=event_timestamp,
),
],
) )
# 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 assert len(hass.states.async_all()) == 1
camera = hass.states.get("camera.front") camera = hass.states.get("camera.front")
assert camera is not None assert camera is not None
@ -483,6 +715,13 @@ async def test_camera_event_clip_preview(hass, auth, hass_client):
assert device assert device
assert device.name == DEVICE_NAME 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 to the device
browse = await media_source.async_browse_media( browse = await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" 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 # The device expands recent events
assert len(browse.children) == 1 assert len(browse.children) == 1
assert browse.children[0].domain == DOMAIN 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) event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT)
assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert browse.children[0].title == f"Motion @ {event_timestamp_string}"
assert not browse.children[0].can_expand assert not browse.children[0].can_expand
assert len(browse.children[0].children) == 0 assert len(browse.children[0].children) == 0
assert browse.children[0].can_play 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 # Resolving the event links to the media
media = await media_source.async_resolve_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" assert media.mime_type == "video/mp4"
auth.responses = [
aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT),
]
client = await hass_client() client = await hass_client()
response = await client.get(media.url) response = await client.get(media.url)
assert response.status == HTTPStatus.OK, "Response not matched: %s" % response 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): async def test_event_media_failure(hass, auth, hass_client):
"""Test event media fetch sees a failure from the server.""" """Test event media fetch sees a failure from the server."""
event_timestamp = dt_util.now() subscriber = await async_setup_devices(
await async_setup_devices( hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS
hass,
auth,
CAMERA_DEVICE_TYPE,
CAMERA_TRAITS,
events=[
create_event(
EVENT_SESSION_ID,
EVENT_ID,
PERSON_EVENT,
timestamp=event_timestamp,
),
],
) )
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 assert len(hass.states.async_all()) == 1
camera = hass.states.get("camera.front") camera = hass.states.get("camera.front")
assert camera is not None 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.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}"
assert media.mime_type == "image/jpeg" assert media.mime_type == "image/jpeg"
auth.responses = [
aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR),
]
client = await hass_client() client = await hass_client()
response = await client.get(media.url) response = await client.get(media.url)
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR, ( 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): async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin_user):
"""Test case where user does not have permissions to view media.""" """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)
await async_setup_devices(
hass,
auth,
CAMERA_DEVICE_TYPE,
CAMERA_TRAITS,
events=[
create_event(
EVENT_SESSION_ID,
EVENT_ID,
PERSON_EVENT,
timestamp=event_timestamp,
),
],
)
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
camera = hass.states.get("camera.front") 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
assert device.name == DEVICE_NAME 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 # Empty policy with no access to the entity
hass_admin_user.mock_policy({}) hass_admin_user.mock_policy({})
@ -673,6 +906,10 @@ async def test_multiple_devices(hass, auth, hass_client):
# Send events for device #1 # Send events for device #1
for i in range(0, 5): 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( await subscriber.async_receive_event(
create_event( create_event(
f"event-session-id-{i}", f"event-session-id-{i}",
@ -681,6 +918,7 @@ async def test_multiple_devices(hass, auth, hass_client):
device_id=device_id1, device_id=device_id1,
) )
) )
await hass.async_block_till_done()
browse = await media_source.async_browse_media( browse = await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" 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 # Send events for device #2
for i in range(0, 3): 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( await subscriber.async_receive_event(
create_event( create_event(
f"other-id-{i}", f"event-id{i}", PERSON_EVENT, device_id=device_id2 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( browse = await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" 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 create_battery_event_data(MOTION_EVENT), timestamp=event_timestamp
) )
) )
await hass.async_block_till_done()
# Browse to event # Browse to event
browse = await media_source.async_browse_media( 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 len(browse.children) == 1
assert browse.children[0].domain == DOMAIN 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) event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT)
assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert browse.children[0].title == f"Motion @ {event_timestamp_string}"
assert not browse.children[0].can_expand assert not browse.children[0].can_expand
assert browse.children[0].can_play assert browse.children[0].can_play
event_identifier = browse.children[0].identifier
media = await media_source.async_resolve_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}/{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" assert media.mime_type == "video/mp4"
# Fetch event media # Fetch event media
@ -801,8 +1045,6 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store):
subscriber = FakeSubscriber() subscriber = FakeSubscriber()
device_manager = await subscriber.async_get_device_manager() device_manager = await subscriber.async_get_device_manager()
device_manager.add_device(nest_device) device_manager.add_device(nest_device)
# Fetch media for events when published
subscriber.cache_policy.fetch = True
with patch( with patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" "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 len(browse.children) == 1
assert browse.children[0].domain == DOMAIN 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) event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT)
assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert browse.children[0].title == f"Motion @ {event_timestamp_string}"
assert not browse.children[0].can_expand assert not browse.children[0].can_expand
assert browse.children[0].can_play assert browse.children[0].can_play
event_identifier = browse.children[0].identifier
media = await media_source.async_resolve_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}/{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" assert media.mime_type == "video/mp4"
# Verify media exists # 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 assert contents == IMAGE_BYTES_FROM_EVENT
async def test_media_store_filesystem_error(hass, auth, hass_client): async def test_media_store_save_filesystem_error(hass, auth, hass_client):
"""Test a filesystem error read/writing event media.""" """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() event_timestamp = dt_util.now()
await async_setup_devices( # The client fetches the media from the server, but has a failure when
hass, # persisting the media to disk.
auth, client = await hass_client()
CAMERA_DEVICE_TYPE, with patch("homeassistant.components.nest.media_source.open", side_effect=OSError):
BATTERY_CAMERA_TRAITS, await subscriber.async_receive_event(
events=[
create_event_message( create_event_message(
create_battery_event_data(MOTION_EVENT), create_battery_event_data(MOTION_EVENT),
timestamp=event_timestamp, 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 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
assert device.name == DEVICE_NAME assert device.name == DEVICE_NAME
# Capture any events published
received_events = async_capture_events(hass, NEST_EVENT)
auth.responses = [ auth.responses = [
aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), 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 assert len(received_events) == 1
# persisting the media to disk. 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() client = await hass_client()
with patch("homeassistant.components.nest.media_source.open", side_effect=OSError): with patch("homeassistant.components.nest.media_source.open", side_effect=OSError):
response = await client.get( 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 assert response.status == HTTPStatus.NOT_FOUND, (
contents = await response.read() "Response not matched: %s" % response
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.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): 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, BATTERY_CAMERA_TRAITS,
) )
# Media fetched as soon as it is published
subscriber.cache_policy.fetch = True
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)})
assert device 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}" hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}"
) )
assert len(browse.children) == 5 assert len(browse.children) == 5
child_events = iter(browse.children)
# Verify all other content is still persisted correctly # Verify all other content is still persisted correctly
client = await hass_client() client = await hass_client()
for i in range(3, 8): for i in reversed(range(3, 8)):
response = await client.get( child_event = next(child_events)
f"/api/nest/event_media/{device.id}/event-session-{i}" response = await client.get(f"/api/nest/event_media/{child_event.identifier}")
)
assert response.status == HTTPStatus.OK, "Response not matched: %s" % response assert response.status == HTTPStatus.OK, "Response not matched: %s" % response
contents = await response.read() contents = await response.read()
assert contents == f"image-bytes-{i}".encode() assert contents == f"image-bytes-{i}".encode()