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