diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 99234c3de8a8647efd4960ab3ee2bd0ded6caab0..abebc8db3efe4de93e434705f74114eecef283d1 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections.abc import Callable import datetime +import io import logging from typing import Any +from PIL import Image, ImageDraw, ImageFilter from google_nest_sdm.camera_traits import ( CameraEventImageTrait, CameraImageTrait, @@ -38,6 +40,15 @@ _LOGGER = logging.getLogger(__name__) # Used to schedule an alarm to refresh the stream before expiration STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) +# The Google Home app dispays a placeholder image that appears as a faint +# light source (dim, blurred sphere) giving the user an indication the camera +# is available, not just a blank screen. These constants define a blurred +# ellipse at the top left of the thumbnail. +PLACEHOLDER_ELLIPSE_BLUR = 0.1 +PLACEHOLDER_ELLIPSE_XY = [-0.4, 0.3, 0.3, 0.4] +PLACEHOLDER_OVERLAY_COLOR = "#ffffff" +PLACEHOLDER_ELLIPSE_OPACITY = 255 + async def async_setup_sdm_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -62,6 +73,30 @@ async def async_setup_sdm_entry( async_add_entities(entities) +def placeholder_image(width: int | None = None, height: int | None = None) -> Image: + """Return a camera image preview for cameras without live thumbnails.""" + if not width or not height: + return Image.new("RGB", (1, 1)) + # Draw a dark scene with a fake light source + blank = Image.new("RGB", (width, height)) + overlay = Image.new("RGB", blank.size, color=PLACEHOLDER_OVERLAY_COLOR) + ellipse = Image.new("L", blank.size, color=0) + draw = ImageDraw.Draw(ellipse) + draw.ellipse( + ( + width * PLACEHOLDER_ELLIPSE_XY[0], + height * PLACEHOLDER_ELLIPSE_XY[1], + width * PLACEHOLDER_ELLIPSE_XY[2], + height * PLACEHOLDER_ELLIPSE_XY[3], + ), + fill=PLACEHOLDER_ELLIPSE_OPACITY, + ) + mask = ellipse.filter( + ImageFilter.GaussianBlur(radius=width * PLACEHOLDER_ELLIPSE_BLUR) + ) + return Image.composite(overlay, blank, mask) + + class NestCamera(Camera): """Devices that support cameras.""" @@ -212,7 +247,14 @@ class NestCamera(Camera): # Fetch still image from the live stream stream_url = await self.stream_source() if not stream_url: - return None + if self.frontend_stream_type != STREAM_TYPE_WEB_RTC: + return None + # Nest Web RTC cams only have image previews for events, and not + # for "now" by design to save batter, and need a placeholder. + image = placeholder_image(width=width, height=height) + with io.BytesIO() as content: + image.save(content, format="JPEG", optimize=True) + return content.getvalue() return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG) async def _async_active_event_image(self) -> bytes | None: diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index b7637bf3e2e18d92d4ace5e1db82b54abb6cf4fb..1ac1b4ca6f960ca5cda37bc1ca5c31e6bcaca5dd 100644 --- a/tests/components/nest/test_camera_sdm.py +++ b/tests/components/nest/test_camera_sdm.py @@ -135,7 +135,7 @@ async def fire_alarm(hass, point_in_time): await hass.async_block_till_done() -async def async_get_image(hass): +async def async_get_image(hass, width=None, height=None): """Get image from the camera, a wrapper around camera.async_get_image.""" # Note: this patches ImageFrame to simulate decoding an image from a live # stream, however the test may not use it. Tests assert on the image @@ -145,7 +145,9 @@ async def async_get_image(hass): autopatch=True, return_value=IMAGE_BYTES_FROM_STREAM, ): - return await camera.async_get_image(hass, "camera.my_camera") + return await camera.async_get_image( + hass, "camera.my_camera", width=width, height=height + ) async def test_no_devices(hass): @@ -721,9 +723,11 @@ async def test_camera_web_rtc(hass, auth, hass_ws_client): assert msg["success"] assert msg["result"]["answer"] == "v=0\r\ns=-\r\n" - # Nest WebRTC cameras do not support a still image - with pytest.raises(HomeAssistantError): - await async_get_image(hass) + # Nest WebRTC cameras return a placeholder + content = await async_get_image(hass) + assert content.content_type == "image/jpeg" + content = await async_get_image(hass, width=1024, height=768) + assert content.content_type == "image/jpeg" async def test_camera_web_rtc_unsupported(hass, auth, hass_ws_client):