Add an entity service for saving nest event related snapshots (#58369)

* Add an entity service for saving nest event related snapshots

Add an entity service `nest.snapshot_event` for recording camera event
related media to disk. This is based on `camera.snapshot` but takes in
a parameter for a Nest API event_id.

PR #58299 adds `nest_event_id` to events published by nest so that they can
be hooked up to this service for capturing events.

Future related work includes:
- Height & Width parameters for the rendered image
- Support video clips for new battery cameras
- An API for proxying media related to events, separate from the camera image thumbnail
- A Nest MediaSource for browsing media related to events

* Revert debugging information

* Add test coverage for OSError failure case

* Add service description for nest snapshot service

* Reduce unnecessary diffs.

* Sort nest camera imports

* Remove unnecessary if block in snapshot
This commit is contained in:
Allen Porter 2021-11-29 23:04:29 -08:00 committed by GitHub
parent cc543b200d
commit c4e5242b0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 223 additions and 6 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
import datetime
import logging
import os
from pathlib import Path
from typing import Any
@ -19,6 +20,7 @@ from google_nest_sdm.device import Device
from google_nest_sdm.event import ImageEventBase
from google_nest_sdm.exceptions import GoogleNestException
from haffmpeg.tools import IMAGE_JPEG
import voluptuous as vol
from homeassistant.components.camera import SUPPORT_STREAM, Camera
from homeassistant.components.camera.const import STREAM_TYPE_HLS, STREAM_TYPE_WEB_RTC
@ -26,12 +28,13 @@ from homeassistant.components.ffmpeg import async_get_image
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
from .const import DATA_SUBSCRIBER, DOMAIN
from .const import DATA_SUBSCRIBER, DOMAIN, SERVICE_SNAPSHOT_EVENT
from .device_info import NestDeviceInfo
_LOGGER = logging.getLogger(__name__)
@ -64,6 +67,17 @@ async def async_setup_sdm_entry(
entities.append(NestCamera(device))
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SNAPSHOT_EVENT,
{
vol.Required("nest_event_id"): cv.string,
vol.Required("filename"): cv.string,
},
"_async_snapshot_event",
)
class NestCamera(Camera):
"""Devices that support cameras."""
@ -292,3 +306,33 @@ class NestCamera(Camera):
except GoogleNestException as err:
raise HomeAssistantError(f"Nest API error: {err}") from err
return stream.answer_sdp
async def _async_snapshot_event(self, nest_event_id: str, filename: str) -> None:
"""Save media for a Nest event, based on `camera.snapshot`."""
_LOGGER.debug("Taking snapshot for event id '%s'", nest_event_id)
if not self.hass.config.is_allowed_path(filename):
raise HomeAssistantError("No access to write snapshot '%s'" % filename)
# Fetch media associated with the event
if not (trait := self._device.traits.get(CameraEventImageTrait.NAME)):
raise HomeAssistantError("Camera does not support event image snapshots")
try:
event_image = await trait.generate_image(nest_event_id)
except GoogleNestException as err:
raise HomeAssistantError("Unable to create event snapshot") from err
try:
image = await event_image.contents()
except GoogleNestException as err:
raise HomeAssistantError("Unable to fetch event snapshot") from err
_LOGGER.debug("Writing event snapshot to '%s'", filename)
def _write_image() -> None:
"""Executor helper to write image."""
os.makedirs(os.path.dirname(filename), exist_ok=True)
with open(filename, "wb") as img_file:
img_file.write(image)
try:
await self.hass.async_add_executor_job(_write_image)
except OSError as err:
raise HomeAssistantError("Failed to write snapshot image") from err

View file

@ -22,3 +22,5 @@ SDM_SCOPES = [
]
API_URL = "https://smartdevicemanagement.googleapis.com/v1"
OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"
SERVICE_SNAPSHOT_EVENT = "snapshot_event"

View file

@ -1,8 +1,30 @@
# Describes the format for available Nest services
snapshot_event:
name: Take event snapshot
description: Take a snapshot from a camera for an event.
target:
entity:
integration: nest
domain: camera
fields:
nest_event_id:
name: Nest Event Id
description: The nest_event_id from the event to snapshot. Can be populated by an automation trigger for a 'nest_event' with 'data_template'.
required: true
selector:
text:
filename:
name: Filename
description: A filename where the snapshot for the event is written.
required: true
example: "/tmp/snapshot_my_camera.jpg"
selector:
text:
set_away_mode:
name: Set away mode
description: Set the away mode for a Nest structure.
description: Set the away mode for a Nest structure. For Legacy API.
fields:
away_mode:
name: Away mode
@ -22,7 +44,7 @@ set_away_mode:
set_eta:
name: Set estimated time of arrival
description: Set or update the estimated time of arrival window for a Nest structure.
description: Set or update the estimated time of arrival window for a Nest structure. For Legacy API.
fields:
eta:
name: ETA
@ -51,7 +73,7 @@ set_eta:
cancel_eta:
name: Cancel ETA
description: Cancel an existing estimated time of arrival window for a Nest structure.
description: Cancel an existing estimated time of arrival window for a Nest structure. For Legacy API.
fields:
trip_id:
name: Trip ID

View file

@ -7,7 +7,8 @@ pubsub subscriber.
import datetime
from http import HTTPStatus
from unittest.mock import patch
import os
from unittest.mock import mock_open, patch
import aiohttp
from google_nest_sdm.device import Device
@ -21,7 +22,9 @@ from homeassistant.components.camera import (
STREAM_TYPE_HLS,
STREAM_TYPE_WEB_RTC,
)
from homeassistant.components.nest.const import SERVICE_SNAPSHOT_EVENT
from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
@ -150,6 +153,20 @@ async def async_get_image(hass, width=None, height=None):
)
async def async_call_service_event_snapshot(hass, filename):
"""Call the event snapshot service."""
return await hass.services.async_call(
DOMAIN,
SERVICE_SNAPSHOT_EVENT,
{
ATTR_ENTITY_ID: "camera.my_camera",
"nest_event_id": "some-event-id",
"filename": filename,
},
blocking=True,
)
async def test_no_devices(hass):
"""Test configuration that returns no devices."""
await async_setup_camera(hass)
@ -519,7 +536,7 @@ async def test_camera_image_from_last_event(hass, auth):
async def test_camera_image_from_event_not_supported(hass, auth):
"""Test fallback to stream image when event images are not supported."""
# Create a device that does not support the CameraEventImgae trait
# Create a device that does not support the CameraEventImage trait
traits = DEVICE_TRAITS.copy()
del traits["sdm.devices.traits.CameraEventImage"]
subscriber = await async_setup_camera(hass, traits, auth=auth)
@ -865,3 +882,135 @@ async def test_camera_multiple_streams(hass, auth, hass_ws_client):
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"]["answer"] == "v=0\r\ns=-\r\n"
async def test_service_snapshot_event_image(hass, auth, tmpdir):
"""Test calling the snapshot_event service."""
await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
auth.responses = [
aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE),
aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT),
]
filename = f"{tmpdir}/snapshot.jpg"
with patch.object(hass.config, "is_allowed_path", return_value=True):
assert await async_call_service_event_snapshot(hass, filename)
assert os.path.exists(filename)
with open(filename, "rb") as f:
contents = f.read()
assert contents == IMAGE_BYTES_FROM_EVENT
async def test_service_snapshot_no_access_to_filename(hass, auth, tmpdir):
"""Test calling the snapshot_event service with a disallowed file path."""
await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
filename = f"{tmpdir}/snapshot.jpg"
with patch.object(
hass.config, "is_allowed_path", return_value=False
), pytest.raises(HomeAssistantError, match=r"No access.*"):
assert await async_call_service_event_snapshot(hass, filename)
assert not os.path.exists(filename)
async def test_camera_snapshot_from_event_not_supported(hass, auth, tmpdir):
"""Test a camera that does not support snapshots."""
# Create a device that does not support the CameraEventImage trait
traits = DEVICE_TRAITS.copy()
del traits["sdm.devices.traits.CameraEventImage"]
await async_setup_camera(hass, traits, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
filename = f"{tmpdir}/snapshot.jpg"
with patch.object(hass.config, "is_allowed_path", return_value=True), pytest.raises(
HomeAssistantError, match=r"Camera does not support.*"
):
await async_call_service_event_snapshot(hass, filename)
assert not os.path.exists(filename)
async def test_service_snapshot_event_generate_url_failure(hass, auth, tmpdir):
"""Test failure while creating a snapshot url."""
await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
auth.responses = [
aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR),
]
filename = f"{tmpdir}/snapshot.jpg"
with patch.object(hass.config, "is_allowed_path", return_value=True), pytest.raises(
HomeAssistantError, match=r"Unable to create.*"
):
await async_call_service_event_snapshot(hass, filename)
assert not os.path.exists(filename)
async def test_service_snapshot_event_image_fetch_invalid(hass, auth, tmpdir):
"""Test failure when fetching an image snapshot."""
await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
auth.responses = [
aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE),
aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR),
]
filename = f"{tmpdir}/snapshot.jpg"
with patch.object(hass.config, "is_allowed_path", return_value=True), pytest.raises(
HomeAssistantError, match=r"Unable to fetch.*"
):
await async_call_service_event_snapshot(hass, filename)
assert not os.path.exists(filename)
async def test_service_snapshot_event_image_create_directory(hass, auth, tmpdir):
"""Test creating the directory when writing the snapshot."""
await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
auth.responses = [
aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE),
aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT),
]
filename = f"{tmpdir}/path/does/not/exist/snapshot.jpg"
with patch.object(hass.config, "is_allowed_path", return_value=True):
assert await async_call_service_event_snapshot(hass, filename)
assert os.path.exists(filename)
with open(filename, "rb") as f:
contents = f.read()
assert contents == IMAGE_BYTES_FROM_EVENT
async def test_service_snapshot_event_write_failure(hass, auth, tmpdir):
"""Test a failure when writing the snapshot."""
await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
auth.responses = [
aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE),
aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT),
]
filename = f"{tmpdir}/snapshot.jpg"
with patch.object(hass.config, "is_allowed_path", return_value=True), patch(
"homeassistant.components.nest.camera_sdm.open", mock_open(), create=True
) as mocked_open, pytest.raises(HomeAssistantError, match=r"Failed to write.*"):
mocked_open.side_effect = IOError()
assert await async_call_service_event_snapshot(hass, filename)
assert not os.path.exists(filename)