1
mirror of https://github.com/home-assistant/core synced 2024-09-06 10:29:55 +02:00

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 logging
from pathlib import Path
from typing import Any
from google_nest_sdm.camera_traits import (
CameraEventImageTrait,
CameraImageTrait,
CameraLiveStreamTrait,
EventImageGenerator,
RtspStream,
StreamingProtocol,
)
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 haffmpeg.tools import IMAGE_JPEG
@ -77,10 +75,6 @@ class NestCamera(Camera):
self._stream: RtspStream | None = None
self._create_stream_url_lock = asyncio.Lock()
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._placeholder_image: bytes | None = None
@ -202,10 +196,6 @@ class NestCamera(Camera):
)
if 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:
"""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
) -> bytes | None:
"""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
if CameraEventImageTrait.NAME in self._device.traits:
# Returns the snapshot of the last event for ~30 seconds after the event
event_media: EventMedia | None = None
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
stream_url = await self.stream_source()
if not stream_url:
@ -235,63 +232,6 @@ class NestCamera(Camera):
return self._placeholder_image
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:
"""Return the source of the stream."""
trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME]

View File

@ -1,5 +1,9 @@
"""Common libraries for test setup."""
import shutil
from unittest.mock import patch
import uuid
import aiohttp
from google_nest_sdm.auth import AbstractAuth
import pytest
@ -63,3 +67,12 @@ async def auth(aiohttp_client):
app.router.add_post("/", auth.response_handler)
auth.client = await aiohttp_client(app)
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"
DOMAIN = "nest"
MOTION_EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..."
EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..."
# Tests can assert that image bytes came from an event or was decoded
# from the live stream.
@ -69,7 +70,9 @@ IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"}
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:
"""Create an EventMessage for a motion event."""
if not timestamp:
@ -82,7 +85,7 @@ def make_motion_event(
"name": DEVICE_ID,
"events": {
"sdm.devices.events.CameraMotion.Motion": {
"eventSessionId": "CjY5Y3VKaTZwR3o4Y19YbTVfMF...",
"eventSessionId": event_session_id,
"eventId": event_id,
},
},
@ -625,48 +628,6 @@ async def test_event_image_expired(hass, auth):
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):
"""Test fallback for an event event image that has been cleaned up on expiration."""
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")
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()
auth.responses = [
@ -692,7 +655,11 @@ async def test_multiple_event_images(hass, auth):
next_event_timestamp = event_timestamp + datetime.timedelta(seconds=25)
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()

View File

@ -6,10 +6,8 @@ as media in the media source.
import datetime
from http import HTTPStatus
import shutil
from typing import Generator
from unittest.mock import patch
import uuid
import aiohttp
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"}
@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=[]):
"""Set up the platform and prerequisites."""
devices = {