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,
|
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."""
|
||||||
|
|
|
@ -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=[],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Add table
Reference in a new issue