Improve nest media player clip/image and event handling for multiple events in a short time range (#63149)
This commit is contained in:
parent
42706f780c
commit
789c0a24dd
6 changed files with 594 additions and 259 deletions
|
@ -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."""
|
||||
|
|
|
@ -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=[],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Reference in a new issue