Add an image placeholder for Nest WebRTC cameras (#58250)
This commit is contained in:
parent
6d30105c9f
commit
9c5a79c641
2 changed files with 52 additions and 6 deletions
|
@ -3,9 +3,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
import datetime
|
import datetime
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
from google_nest_sdm.camera_traits import (
|
from google_nest_sdm.camera_traits import (
|
||||||
CameraEventImageTrait,
|
CameraEventImageTrait,
|
||||||
CameraImageTrait,
|
CameraImageTrait,
|
||||||
|
@ -38,6 +40,15 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
# Used to schedule an alarm to refresh the stream before expiration
|
# Used to schedule an alarm to refresh the stream before expiration
|
||||||
STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30)
|
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(
|
async def async_setup_sdm_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
@ -62,6 +73,30 @@ async def async_setup_sdm_entry(
|
||||||
async_add_entities(entities)
|
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):
|
class NestCamera(Camera):
|
||||||
"""Devices that support cameras."""
|
"""Devices that support cameras."""
|
||||||
|
|
||||||
|
@ -212,7 +247,14 @@ class NestCamera(Camera):
|
||||||
# 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:
|
||||||
|
if self.frontend_stream_type != STREAM_TYPE_WEB_RTC:
|
||||||
return None
|
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)
|
return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG)
|
||||||
|
|
||||||
async def _async_active_event_image(self) -> bytes | None:
|
async def _async_active_event_image(self) -> bytes | None:
|
||||||
|
|
|
@ -135,7 +135,7 @@ async def fire_alarm(hass, point_in_time):
|
||||||
await hass.async_block_till_done()
|
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."""
|
"""Get image from the camera, a wrapper around camera.async_get_image."""
|
||||||
# Note: this patches ImageFrame to simulate decoding an image from a live
|
# 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
|
# 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,
|
autopatch=True,
|
||||||
return_value=IMAGE_BYTES_FROM_STREAM,
|
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):
|
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["success"]
|
||||||
assert msg["result"]["answer"] == "v=0\r\ns=-\r\n"
|
assert msg["result"]["answer"] == "v=0\r\ns=-\r\n"
|
||||||
|
|
||||||
# Nest WebRTC cameras do not support a still image
|
# Nest WebRTC cameras return a placeholder
|
||||||
with pytest.raises(HomeAssistantError):
|
content = await async_get_image(hass)
|
||||||
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):
|
async def test_camera_web_rtc_unsupported(hass, auth, hass_ws_client):
|
||||||
|
|
Loading…
Add table
Reference in a new issue