diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 31ae302f908..c9c30268095 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -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] diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 988d9d761fe..22a89f57235 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -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) diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index 27c1fca4541..0b4304d31b2 100644 --- a/tests/components/nest/test_camera_sdm.py +++ b/tests/components/nest/test_camera_sdm.py @@ -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() diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index e2c810cc873..1ddce0c7818 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -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 = {