Delete nest event image fetching and use same APIs as media player (#62789)
parent
91900f8e4e
commit
4203e1b064
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
Loading…
Reference in New Issue