mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Delete nest event image fetching and use same APIs as media player (#62789)
This commit is contained in:
parent
91900f8e4e
commit
4203e1b064
@ -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]
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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 = {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user