diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index a643de0e6c9..262ed3325b2 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -4,7 +4,11 @@ import datetime import logging from typing import Optional -from google_nest_sdm.camera_traits import CameraImageTrait, CameraLiveStreamTrait +from google_nest_sdm.camera_traits import ( + CameraEventImageTrait, + CameraImageTrait, + CameraLiveStreamTrait, +) from google_nest_sdm.device import Device from google_nest_sdm.exceptions import GoogleNestException from haffmpeg.tools import IMAGE_JPEG @@ -59,6 +63,9 @@ class NestCamera(Camera): self._device_info = DeviceInfo(device) self._stream = None self._stream_refresh_unsub = None + # Cache of most recent event image + self._event_id = None + self._event_image_bytes = None @property def should_poll(self) -> bool: @@ -156,7 +163,40 @@ class NestCamera(Camera): async def async_camera_image(self): """Return bytes of camera image.""" + # Returns the snapshot of the last event for ~30 seconds after the event + active_event_image = await self._async_active_event_image() + if active_event_image: + return active_event_image + # Fetch still image from the live stream stream_url = await self.stream_source() if not stream_url: return None return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG) + + async def _async_active_event_image(self): + """Return image from any active events happening.""" + if CameraEventImageTrait.NAME not in self._device.traits: + return None + trait = self._device.active_event_trait + if not trait: + return None + # Reuse image bytes if they have already been fetched + event_id = trait.last_event.event_id + if self._event_id is not None and self._event_id == event_id: + return self._event_image_bytes + _LOGGER.info("Fetching URL for event_id %s", event_id) + try: + event_image = await trait.generate_active_event_image() + except GoogleNestException as err: + _LOGGER.debug("Unable to generate event image URL: %s", err) + return None + if not event_image: + return None + try: + image_bytes = await event_image.contents() + except GoogleNestException as err: + _LOGGER.debug("Unable to fetch event image: %s", err) + return None + self._event_id = event_id + self._event_image_bytes = image_bytes + return image_bytes diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index c334633e362..1d64ba73d89 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "requirements": [ "python-nest==4.1.0", - "google-nest-sdm==0.2.5" + "google-nest-sdm==0.2.6" ], "codeowners": [ "@awarecan", diff --git a/requirements_all.txt b/requirements_all.txt index 1b2e8af3dc3..e44562185e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -681,7 +681,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.2.5 +google-nest-sdm==0.2.6 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94b0005b8cd..15fc3010bb4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.2.5 +google-nest-sdm==0.2.6 # homeassistant.components.gree greeclimate==0.10.3 diff --git a/tests/components/nest/camera_sdm_test.py b/tests/components/nest/camera_sdm_test.py index 07a21eb2b68..f2aee2d17c5 100644 --- a/tests/components/nest/camera_sdm_test.py +++ b/tests/components/nest/camera_sdm_test.py @@ -10,6 +10,7 @@ from unittest.mock import patch import aiohttp from google_nest_sdm.device import Device +from google_nest_sdm.event import EventMessage import pytest from homeassistant.components import camera @@ -36,9 +37,69 @@ DEVICE_TRAITS = { "videoCodecs": ["H264"], "audioCodecs": ["AAC"], }, + "sdm.devices.traits.CameraEventImage": {}, + "sdm.devices.traits.CameraMotion": {}, } DATETIME_FORMAT = "YY-MM-DDTHH:MM:SS" DOMAIN = "nest" +MOTION_EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + +# Tests can assert that image bytes came from an event or was decoded +# from the live stream. +IMAGE_BYTES_FROM_EVENT = b"test url image bytes" +IMAGE_BYTES_FROM_STREAM = b"test stream image bytes" + +TEST_IMAGE_URL = "https://domain/sdm_event_snapshot/dGTZwR3o4Y1..." +GENERATE_IMAGE_URL_RESPONSE = { + "results": { + "url": TEST_IMAGE_URL, + "token": "g.0.eventToken", + }, +} +IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} + + +def make_motion_event(timestamp: datetime.datetime = None) -> EventMessage: + """Create an EventMessage for a motion event.""" + if not timestamp: + timestamp = utcnow() + return EventMessage( + { + "eventId": "some-event-id", + "timestamp": timestamp.isoformat(timespec="seconds"), + "resourceUpdate": { + "name": DEVICE_ID, + "events": { + "sdm.devices.events.CameraMotion.Motion": { + "eventSessionId": "CjY5Y3VKaTZwR3o4Y19YbTVfMF...", + "eventId": MOTION_EVENT_ID, + }, + }, + }, + }, + auth=None, + ) + + +def make_stream_url_response( + expiration: datetime.datetime = None, token_num: int = 0 +) -> aiohttp.web.Response: + """Make response for the API that generates a streaming url.""" + if not expiration: + # Default to an arbitrary time in the future + expiration = utcnow() + datetime.timedelta(seconds=100) + return aiohttp.web.json_response( + { + "results": { + "streamUrls": { + "rtspUrl": f"rtsp://some/url?auth=g.{token_num}.streamingToken" + }, + "streamExtensionToken": f"g.{token_num}.extensionToken", + "streamToken": f"g.{token_num}.streamingToken", + "expiresAt": expiration.isoformat(timespec="seconds"), + }, + } + ) async def async_setup_camera(hass, traits={}, auth=None): @@ -63,6 +124,19 @@ async def fire_alarm(hass, point_in_time): await hass.async_block_till_done() +async def async_get_image(hass): + """Get image from the camera, a wrapper around camera.async_get_image.""" + # Note: this patches ImageFrame to simulate decoding an image from a live + # stream, however the test may not use it. Tests assert on the image + # contents to determine if the image came from the live stream or event. + with patch( + "homeassistant.components.ffmpeg.ImageFrame.get_image", + autopatch=True, + return_value=IMAGE_BYTES_FROM_STREAM, + ): + return await camera.async_get_image(hass, "camera.my_camera") + + async def test_no_devices(hass): """Test configuration that returns no devices.""" await async_setup_camera(hass) @@ -106,22 +180,7 @@ async def test_camera_device(hass): async def test_camera_stream(hass, auth): """Test a basic camera and fetch its live stream.""" - now = utcnow() - expiration = now + datetime.timedelta(seconds=100) - auth.responses = [ - aiohttp.web.json_response( - { - "results": { - "streamUrls": { - "rtspUrl": "rtsp://some/url?auth=g.0.streamingToken" - }, - "streamExtensionToken": "g.1.extensionToken", - "streamToken": "g.0.streamingToken", - "expiresAt": expiration.isoformat(timespec="seconds"), - }, - } - ) - ] + auth.responses = [make_stream_url_response()] await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) assert len(hass.states.async_all()) == 1 @@ -132,14 +191,8 @@ async def test_camera_stream(hass, auth): stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" - with patch( - "homeassistant.components.ffmpeg.ImageFrame.get_image", - autopatch=True, - return_value=b"image bytes", - ): - image = await camera.async_get_image(hass, "camera.my_camera") - - assert image.content == b"image bytes" + image = await async_get_image(hass) + assert image.content == IMAGE_BYTES_FROM_STREAM async def test_camera_stream_missing_trait(hass, auth): @@ -166,10 +219,9 @@ async def test_camera_stream_missing_trait(hass, auth): stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source is None - # Currently on support getting the image from a live stream + # Unable to get an image from the live stream with pytest.raises(HomeAssistantError): - image = await camera.async_get_image(hass, "camera.my_camera") - assert image is None + await async_get_image(hass) async def test_refresh_expired_stream_token(hass, auth): @@ -180,38 +232,11 @@ async def test_refresh_expired_stream_token(hass, auth): stream_3_expiration = now + datetime.timedelta(seconds=360) auth.responses = [ # Stream URL #1 - aiohttp.web.json_response( - { - "results": { - "streamUrls": { - "rtspUrl": "rtsp://some/url?auth=g.1.streamingToken" - }, - "streamExtensionToken": "g.1.extensionToken", - "streamToken": "g.1.streamingToken", - "expiresAt": stream_1_expiration.isoformat(timespec="seconds"), - }, - } - ), + make_stream_url_response(stream_1_expiration, token_num=1), # Stream URL #2 - aiohttp.web.json_response( - { - "results": { - "streamExtensionToken": "g.2.extensionToken", - "streamToken": "g.2.streamingToken", - "expiresAt": stream_2_expiration.isoformat(timespec="seconds"), - }, - } - ), + make_stream_url_response(stream_2_expiration, token_num=2), # Stream URL #3 - aiohttp.web.json_response( - { - "results": { - "streamExtensionToken": "g.3.extensionToken", - "streamToken": "g.3.streamingToken", - "expiresAt": stream_3_expiration.isoformat(timespec="seconds"), - }, - } - ), + make_stream_url_response(stream_3_expiration, token_num=3), ] await async_setup_camera( hass, @@ -258,36 +283,10 @@ async def test_stream_response_already_expired(hass, auth): stream_1_expiration = now + datetime.timedelta(seconds=-90) stream_2_expiration = now + datetime.timedelta(seconds=+90) auth.responses = [ - aiohttp.web.json_response( - { - "results": { - "streamUrls": { - "rtspUrl": "rtsp://some/url?auth=g.1.streamingToken" - }, - "streamExtensionToken": "g.1.extensionToken", - "streamToken": "g.1.streamingToken", - "expiresAt": stream_1_expiration.isoformat(timespec="seconds"), - }, - } - ), - aiohttp.web.json_response( - { - "results": { - "streamUrls": { - "rtspUrl": "rtsp://some/url?auth=g.2.streamingToken" - }, - "streamExtensionToken": "g.2.extensionToken", - "streamToken": "g.2.streamingToken", - "expiresAt": stream_2_expiration.isoformat(timespec="seconds"), - }, - } - ), + make_stream_url_response(stream_1_expiration, token_num=1), + make_stream_url_response(stream_2_expiration, token_num=2), ] - await async_setup_camera( - hass, - DEVICE_TRAITS, - auth=auth, - ) + await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") @@ -307,21 +306,8 @@ async def test_stream_response_already_expired(hass, auth): async def test_camera_removed(hass, auth): """Test case where entities are removed and stream tokens expired.""" - now = utcnow() - expiration = now + datetime.timedelta(seconds=100) auth.responses = [ - aiohttp.web.json_response( - { - "results": { - "streamUrls": { - "rtspUrl": "rtsp://some/url?auth=g.0.streamingToken" - }, - "streamExtensionToken": "g.1.extensionToken", - "streamToken": "g.0.streamingToken", - "expiresAt": expiration.isoformat(timespec="seconds"), - }, - } - ), + make_stream_url_response(), aiohttp.web.json_response({"results": {}}), ] await async_setup_camera( @@ -349,39 +335,13 @@ async def test_refresh_expired_stream_failure(hass, auth): stream_1_expiration = now + datetime.timedelta(seconds=90) stream_2_expiration = now + datetime.timedelta(seconds=180) auth.responses = [ - aiohttp.web.json_response( - { - "results": { - "streamUrls": { - "rtspUrl": "rtsp://some/url?auth=g.1.streamingToken" - }, - "streamExtensionToken": "g.1.extensionToken", - "streamToken": "g.1.streamingToken", - "expiresAt": stream_1_expiration.isoformat(timespec="seconds"), - }, - } - ), + make_stream_url_response(expiration=stream_1_expiration, token_num=1), # Extending the stream fails with arbitrary error aiohttp.web.Response(status=500), # Next attempt to get a stream fetches a new url - aiohttp.web.json_response( - { - "results": { - "streamUrls": { - "rtspUrl": "rtsp://some/url?auth=g.2.streamingToken" - }, - "streamExtensionToken": "g.2.extensionToken", - "streamToken": "g.2.streamingToken", - "expiresAt": stream_2_expiration.isoformat(timespec="seconds"), - }, - } - ), + make_stream_url_response(expiration=stream_2_expiration, token_num=2), ] - await async_setup_camera( - hass, - DEVICE_TRAITS, - auth=auth, - ) + await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") @@ -399,3 +359,116 @@ async def test_refresh_expired_stream_failure(hass, auth): # The stream is entirely refreshed stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.2.streamingToken" + + +async def test_camera_image_from_last_event(hass, auth): + """Test an image generated from an event.""" + # The subscriber receives a message related to an image event. The camera + # holds on to the event message. When the test asks for a capera snapshot + # it exchanges the event id for an image url and fetches the image. + subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) + assert len(hass.states.async_all()) == 1 + assert hass.states.get("camera.my_camera") + + # Simulate a pubsub message received by the subscriber with a motion event. + await subscriber.async_receive_event(make_motion_event()) + await hass.async_block_till_done() + + auth.responses = [ + # Fake response from API that returns url image + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + # Fake response for the image content fetch + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + + image = await async_get_image(hass) + assert image.content == IMAGE_BYTES_FROM_EVENT + # Verify expected image fetch request was captured + assert auth.url == TEST_IMAGE_URL + assert auth.headers == IMAGE_AUTHORIZATION_HEADERS + + # An additional fetch uses the cache and does not send another RPC + image = await async_get_image(hass) + assert image.content == IMAGE_BYTES_FROM_EVENT + # Verify expected image fetch request was captured + assert auth.url == TEST_IMAGE_URL + assert auth.headers == IMAGE_AUTHORIZATION_HEADERS + + +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 + traits = DEVICE_TRAITS.copy() + del traits["sdm.devices.traits.CameraEventImage"] + subscriber = await async_setup_camera(hass, traits, auth=auth) + assert len(hass.states.async_all()) == 1 + assert hass.states.get("camera.my_camera") + + await subscriber.async_receive_event(make_motion_event()) + await hass.async_block_till_done() + + # Camera fetches a stream url since CameraEventImage is not supported + auth.responses = [make_stream_url_response()] + + image = await async_get_image(hass) + assert image.content == IMAGE_BYTES_FROM_STREAM + + +async def test_generate_event_image_url_failure(hass, auth): + """Test fallback to stream on failure to create an image url.""" + subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) + assert len(hass.states.async_all()) == 1 + assert hass.states.get("camera.my_camera") + + await subscriber.async_receive_event(make_motion_event()) + await hass.async_block_till_done() + + auth.responses = [ + # Fail to generate the image url + aiohttp.web.Response(status=500), + # Camera fetches a stream url as a fallback + make_stream_url_response(), + ] + + image = await async_get_image(hass) + assert image.content == IMAGE_BYTES_FROM_STREAM + + +async def test_fetch_event_image_failure(hass, auth): + """Test fallback to a stream on image download failure.""" + subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) + assert len(hass.states.async_all()) == 1 + assert hass.states.get("camera.my_camera") + + await subscriber.async_receive_event(make_motion_event()) + await hass.async_block_till_done() + + auth.responses = [ + # Fake response from API that returns url image + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + # Fail to download the image + aiohttp.web.Response(status=500), + # Camera fetches a stream url as a fallback + make_stream_url_response(), + ] + + image = await async_get_image(hass) + assert image.content == IMAGE_BYTES_FROM_STREAM + + +async def test_event_image_expired(hass, auth): + """Test fallback for an event event image that has expired.""" + subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) + assert len(hass.states.async_all()) == 1 + assert hass.states.get("camera.my_camera") + + # Simulate a pubsub message has already expired + event_timestamp = utcnow() - datetime.timedelta(seconds=40) + await subscriber.async_receive_event(make_motion_event(event_timestamp)) + await hass.async_block_till_done() + + # Fallback to a stream url since the event message is expired. + auth.responses = [make_stream_url_response()] + + image = await async_get_image(hass) + assert image.content == IMAGE_BYTES_FROM_STREAM diff --git a/tests/components/nest/climate_sdm_test.py b/tests/components/nest/climate_sdm_test.py index 43a422e223e..ef332d0e848 100644 --- a/tests/components/nest/climate_sdm_test.py +++ b/tests/components/nest/climate_sdm_test.py @@ -933,14 +933,14 @@ async def test_thermostat_set_hvac_fan_only(hass, auth): assert len(auth.captured_requests) == 2 - (method, url, json) = auth.captured_requests.pop(0) + (method, url, json, headers) = auth.captured_requests.pop(0) assert method == "post" assert url == "some-device-id:executeCommand" assert json == { "command": "sdm.devices.commands.Fan.SetTimer", "params": {"timerMode": "ON"}, } - (method, url, json) = auth.captured_requests.pop(0) + (method, url, json, headers) = auth.captured_requests.pop(0) assert method == "post" assert url == "some-device-id:executeCommand" assert json == { diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 4ab780f57e6..764f037d181 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -23,6 +23,7 @@ class FakeAuth(AbstractAuth): self.method = None self.url = None self.json = None + self.headers = None self.captured_requests = [] # Set up by fixture self.client = None @@ -31,12 +32,13 @@ class FakeAuth(AbstractAuth): """Return a valid access token.""" return "" - async def request(self, method, url, json): + async def request(self, method, url, **kwargs): """Capure the request arguments for tests to assert on.""" self.method = method self.url = url - self.json = json - self.captured_requests.append((method, url, json)) + self.json = kwargs.get("json") + self.headers = kwargs.get("headers") + self.captured_requests.append((method, url, self.json, self.headers)) return await self.client.get("/") async def response_handler(self, request):