Delete nest event image fetching and use same APIs as media player (#62789)

This commit is contained in:
Allen Porter 2022-01-07 07:37:54 -08:00 committed by GitHub
parent 91900f8e4e
commit 4203e1b064
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 38 additions and 129 deletions

View File

@ -6,18 +6,16 @@ from collections.abc import Callable
import datetime import datetime
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
from google_nest_sdm.camera_traits import ( from google_nest_sdm.camera_traits import (
CameraEventImageTrait, CameraEventImageTrait,
CameraImageTrait, CameraImageTrait,
CameraLiveStreamTrait, CameraLiveStreamTrait,
EventImageGenerator,
RtspStream, RtspStream,
StreamingProtocol, StreamingProtocol,
) )
from google_nest_sdm.device import Device from google_nest_sdm.device import Device
from google_nest_sdm.event import ImageEventBase from google_nest_sdm.event_media import EventMedia
from google_nest_sdm.exceptions import ApiException from google_nest_sdm.exceptions import ApiException
from haffmpeg.tools import IMAGE_JPEG from haffmpeg.tools import IMAGE_JPEG
@ -77,10 +75,6 @@ class NestCamera(Camera):
self._stream: RtspStream | None = None self._stream: RtspStream | None = None
self._create_stream_url_lock = asyncio.Lock() self._create_stream_url_lock = asyncio.Lock()
self._stream_refresh_unsub: Callable[[], None] | None = None self._stream_refresh_unsub: Callable[[], None] | None = None
# Cache of most recent event image
self._event_id: str | None = None
self._event_image_bytes: bytes | None = None
self._event_image_cleanup_unsub: Callable[[], None] | None = None
self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits
self._placeholder_image: bytes | None = None self._placeholder_image: bytes | None = None
@ -202,10 +196,6 @@ class NestCamera(Camera):
) )
if self._stream_refresh_unsub: if self._stream_refresh_unsub:
self._stream_refresh_unsub() self._stream_refresh_unsub()
self._event_id = None
self._event_image_bytes = None
if self._event_image_cleanup_unsub is not None:
self._event_image_cleanup_unsub()
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when entity is added to register update signal handler.""" """Run when entity is added to register update signal handler."""
@ -217,10 +207,17 @@ class NestCamera(Camera):
self, width: int | None = None, height: int | None = None self, width: int | None = None, height: int | None = None
) -> bytes | None: ) -> bytes | None:
"""Return bytes of camera image.""" """Return bytes of camera image."""
# Returns the snapshot of the last event for ~30 seconds after the event if CameraEventImageTrait.NAME in self._device.traits:
active_event_image = await self._async_active_event_image() # Returns the snapshot of the last event for ~30 seconds after the event
if active_event_image: event_media: EventMedia | None = None
return active_event_image try:
event_media = (
await self._device.event_media_manager.get_active_event_media()
)
except ApiException as err:
_LOGGER.debug("Failure while getting image for event: %s", err)
if event_media:
return event_media.media.contents
# Fetch still image from the live stream # Fetch still image from the live stream
stream_url = await self.stream_source() stream_url = await self.stream_source()
if not stream_url: if not stream_url:
@ -235,63 +232,6 @@ class NestCamera(Camera):
return self._placeholder_image return self._placeholder_image
return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG) return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG)
async def _async_active_event_image(self) -> bytes | None:
"""Return image from any active events happening."""
if CameraEventImageTrait.NAME not in self._device.traits:
return None
if not (trait := self._device.active_event_trait):
return None
# Reuse image bytes if they have already been fetched
if not isinstance(trait, EventImageGenerator):
return None
event: ImageEventBase | None = trait.last_event
if not event:
return None
if self._event_id is not None and self._event_id == event.event_id:
return self._event_image_bytes
_LOGGER.debug("Generating event image URL for event_id %s", event.event_id)
image_bytes = await self._async_fetch_active_event_image(trait)
if image_bytes is None:
return None
self._event_id = event.event_id
self._event_image_bytes = image_bytes
self._schedule_event_image_cleanup(event.expires_at)
return image_bytes
async def _async_fetch_active_event_image(
self, trait: EventImageGenerator
) -> bytes | None:
"""Return image bytes for an active event."""
# pylint: disable=no-self-use
try:
event_image = await trait.generate_active_event_image()
except ApiException as err:
_LOGGER.debug("Unable to generate event image URL: %s", err)
return None
if not event_image:
return None
try:
return await event_image.contents()
except ApiException as err:
_LOGGER.debug("Unable to fetch event image: %s", err)
return None
def _schedule_event_image_cleanup(self, point_in_time: datetime.datetime) -> None:
"""Schedules an alarm to remove the image bytes from memory, honoring expiration."""
if self._event_image_cleanup_unsub is not None:
self._event_image_cleanup_unsub()
self._event_image_cleanup_unsub = async_track_point_in_utc_time(
self.hass,
self._handle_event_image_cleanup,
point_in_time,
)
def _handle_event_image_cleanup(self, now: Any) -> None:
"""Clear images cached from events and scheduled callback."""
self._event_id = None
self._event_image_bytes = None
self._event_image_cleanup_unsub = None
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str: async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str:
"""Return the source of the stream.""" """Return the source of the stream."""
trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME] trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME]

View File

@ -1,5 +1,9 @@
"""Common libraries for test setup.""" """Common libraries for test setup."""
import shutil
from unittest.mock import patch
import uuid
import aiohttp import aiohttp
from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.auth import AbstractAuth
import pytest import pytest
@ -63,3 +67,12 @@ async def auth(aiohttp_client):
app.router.add_post("/", auth.response_handler) app.router.add_post("/", auth.response_handler)
auth.client = await aiohttp_client(app) auth.client = await aiohttp_client(app)
return auth return auth
@pytest.fixture(autouse=True)
def cleanup_media_storage(hass):
"""Test cleanup, remove any media storage persisted during the test."""
tmp_path = str(uuid.uuid4())
with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path):
yield
shutil.rmtree(hass.config.path(tmp_path), ignore_errors=True)

View File

@ -52,6 +52,7 @@ DEVICE_TRAITS = {
DATETIME_FORMAT = "YY-MM-DDTHH:MM:SS" DATETIME_FORMAT = "YY-MM-DDTHH:MM:SS"
DOMAIN = "nest" DOMAIN = "nest"
MOTION_EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." MOTION_EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..."
EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..."
# Tests can assert that image bytes came from an event or was decoded # Tests can assert that image bytes came from an event or was decoded
# from the live stream. # from the live stream.
@ -69,7 +70,9 @@ IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"}
def make_motion_event( def make_motion_event(
event_id: str = MOTION_EVENT_ID, timestamp: datetime.datetime = None event_id: str = MOTION_EVENT_ID,
event_session_id: str = EVENT_SESSION_ID,
timestamp: datetime.datetime = None,
) -> EventMessage: ) -> EventMessage:
"""Create an EventMessage for a motion event.""" """Create an EventMessage for a motion event."""
if not timestamp: if not timestamp:
@ -82,7 +85,7 @@ def make_motion_event(
"name": DEVICE_ID, "name": DEVICE_ID,
"events": { "events": {
"sdm.devices.events.CameraMotion.Motion": { "sdm.devices.events.CameraMotion.Motion": {
"eventSessionId": "CjY5Y3VKaTZwR3o4Y19YbTVfMF...", "eventSessionId": event_session_id,
"eventId": event_id, "eventId": event_id,
}, },
}, },
@ -625,48 +628,6 @@ async def test_event_image_expired(hass, auth):
assert image.content == IMAGE_BYTES_FROM_STREAM assert image.content == IMAGE_BYTES_FROM_STREAM
async def test_event_image_becomes_expired(hass, auth):
"""Test fallback for an event event image that has been cleaned up on expiration."""
subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
assert len(hass.states.async_all()) == 1
assert hass.states.get("camera.my_camera")
event_timestamp = utcnow()
await subscriber.async_receive_event(make_motion_event(timestamp=event_timestamp))
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 is refetched after being cleared by expiration alarm
aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE),
aiohttp.web.Response(body=b"updated image bytes"),
]
image = await async_get_image(hass)
assert image.content == IMAGE_BYTES_FROM_EVENT
# Event image is still valid before expiration
next_update = event_timestamp + datetime.timedelta(seconds=25)
await fire_alarm(hass, next_update)
image = await async_get_image(hass)
assert image.content == IMAGE_BYTES_FROM_EVENT
# Fire an alarm well after expiration, removing image from cache
# Note: This test does not override the "now" logic within the underlying
# python library that tracks active events. Instead, it exercises the
# alarm behavior only. That is, the library may still think the event is
# active even though Home Assistant does not due to patching time.
next_update = event_timestamp + datetime.timedelta(seconds=180)
await fire_alarm(hass, next_update)
image = await async_get_image(hass)
assert image.content == b"updated image bytes"
async def test_multiple_event_images(hass, auth): async def test_multiple_event_images(hass, auth):
"""Test fallback for an event event image that has been cleaned up on expiration.""" """Test fallback for an event event image that has been cleaned up on expiration."""
subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth)
@ -674,7 +635,9 @@ async def test_multiple_event_images(hass, auth):
assert hass.states.get("camera.my_camera") assert hass.states.get("camera.my_camera")
event_timestamp = utcnow() event_timestamp = utcnow()
await subscriber.async_receive_event(make_motion_event(timestamp=event_timestamp)) await subscriber.async_receive_event(
make_motion_event(event_session_id="event-session-1", timestamp=event_timestamp)
)
await hass.async_block_till_done() await hass.async_block_till_done()
auth.responses = [ auth.responses = [
@ -692,7 +655,11 @@ async def test_multiple_event_images(hass, auth):
next_event_timestamp = event_timestamp + datetime.timedelta(seconds=25) next_event_timestamp = event_timestamp + datetime.timedelta(seconds=25)
await subscriber.async_receive_event( await subscriber.async_receive_event(
make_motion_event(event_id="updated-event-id", timestamp=next_event_timestamp) make_motion_event(
event_id="updated-event-id",
event_session_id="event-session-2",
timestamp=next_event_timestamp,
)
) )
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -6,10 +6,8 @@ as media in the media source.
import datetime import datetime
from http import HTTPStatus from http import HTTPStatus
import shutil
from typing import Generator from typing import Generator
from unittest.mock import patch from unittest.mock import patch
import uuid
import aiohttp import aiohttp
from google_nest_sdm.device import Device from google_nest_sdm.device import Device
@ -74,15 +72,6 @@ IMAGE_BYTES_FROM_EVENT = b"test url image bytes"
IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"}
@pytest.fixture(autouse=True)
def cleanup_media_storage(hass):
"""Test cleanup, remove any media storage persisted during the test."""
tmp_path = str(uuid.uuid4())
with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path):
yield
shutil.rmtree(hass.config.path(tmp_path), ignore_errors=True)
async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): async def async_setup_devices(hass, auth, device_type, traits={}, events=[]):
"""Set up the platform and prerequisites.""" """Set up the platform and prerequisites."""
devices = { devices = {