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 from collections.abc import Callable
import datetime import datetime
import logging import logging
import os
from pathlib import Path from pathlib import Path
from typing import Any 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.event import ImageEventBase
from google_nest_sdm.exceptions import GoogleNestException from google_nest_sdm.exceptions import GoogleNestException
from haffmpeg.tools import IMAGE_JPEG from haffmpeg.tools import IMAGE_JPEG
import voluptuous as vol
from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.components.camera import SUPPORT_STREAM, Camera
from homeassistant.components.camera.const import STREAM_TYPE_HLS, STREAM_TYPE_WEB_RTC 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady 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 import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow 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 from .device_info import NestDeviceInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -64,6 +67,17 @@ async def async_setup_sdm_entry(
entities.append(NestCamera(device)) entities.append(NestCamera(device))
async_add_entities(entities) 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): class NestCamera(Camera):
"""Devices that support cameras.""" """Devices that support cameras."""
@ -292,3 +306,33 @@ class NestCamera(Camera):
except GoogleNestException as err: except GoogleNestException as err:
raise HomeAssistantError(f"Nest API error: {err}") from err raise HomeAssistantError(f"Nest API error: {err}") from err
return stream.answer_sdp 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" API_URL = "https://smartdevicemanagement.googleapis.com/v1"
OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" 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 # 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: set_away_mode:
name: 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: fields:
away_mode: away_mode:
name: Away mode name: Away mode
@ -22,7 +44,7 @@ set_away_mode:
set_eta: set_eta:
name: Set estimated time of arrival 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: fields:
eta: eta:
name: ETA name: ETA
@ -51,7 +73,7 @@ set_eta:
cancel_eta: cancel_eta:
name: 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: fields:
trip_id: trip_id:
name: Trip ID name: Trip ID

View file

@ -7,7 +7,8 @@ pubsub subscriber.
import datetime import datetime
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import patch import os
from unittest.mock import mock_open, patch
import aiohttp import aiohttp
from google_nest_sdm.device import Device from google_nest_sdm.device import Device
@ -21,7 +22,9 @@ from homeassistant.components.camera import (
STREAM_TYPE_HLS, STREAM_TYPE_HLS,
STREAM_TYPE_WEB_RTC, STREAM_TYPE_WEB_RTC,
) )
from homeassistant.components.nest.const import SERVICE_SNAPSHOT_EVENT
from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component 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): async def test_no_devices(hass):
"""Test configuration that returns no devices.""" """Test configuration that returns no devices."""
await async_setup_camera(hass) 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): async def test_camera_image_from_event_not_supported(hass, auth):
"""Test fallback to stream image when event images are not supported.""" """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() traits = DEVICE_TRAITS.copy()
del traits["sdm.devices.traits.CameraEventImage"] del traits["sdm.devices.traits.CameraEventImage"]
subscriber = await async_setup_camera(hass, traits, auth=auth) 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["type"] == TYPE_RESULT
assert msg["success"] assert msg["success"]
assert msg["result"]["answer"] == "v=0\r\ns=-\r\n" 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)