diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 5385eb42b26..562b55ba652 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -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 diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index a92a48bfd6c..e98d563c574 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -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" diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml index 98aacf60524..d432d2a3859 100644 --- a/homeassistant/components/nest/services.yaml +++ b/homeassistant/components/nest/services.yaml @@ -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 diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index 1ac1b4ca6f9..07184556819 100644 --- a/tests/components/nest/test_camera_sdm.py +++ b/tests/components/nest/test_camera_sdm.py @@ -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)