hass-core/homeassistant/components/nest/camera_sdm.py
Allen Porter 560e3811a3
Generate nest images thumbnails from events (#44638)
* Capture nest still images from events

Use python google-nest-sdm API for fetching images.  Update home assistant
to use the google-nest-sdm API for fetching the image contents generated
by the server.  This uses the existing websession object for server fetches,
reducing the amount of new code and facilites unit testing using the existing
mechanism.

Simplify tests using the image fetch API rather than a snapshot API
2021-01-06 07:02:04 -08:00

202 lines
7 KiB
Python

"""Support for Google Nest SDM Cameras."""
import datetime
import logging
from typing import Optional
from google_nest_sdm.camera_traits import (
CameraEventImageTrait,
CameraImageTrait,
CameraLiveStreamTrait,
)
from google_nest_sdm.device import Device
from google_nest_sdm.exceptions import GoogleNestException
from haffmpeg.tools import IMAGE_JPEG
from homeassistant.components.camera import SUPPORT_STREAM, Camera
from homeassistant.components.ffmpeg import async_get_image
from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.dt import utcnow
from .const import DATA_SUBSCRIBER, DOMAIN
from .device_info import DeviceInfo
_LOGGER = logging.getLogger(__name__)
# Used to schedule an alarm to refresh the stream before expiration
STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30)
async def async_setup_sdm_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
) -> None:
"""Set up the cameras."""
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
try:
device_manager = await subscriber.async_get_device_manager()
except GoogleNestException as err:
raise PlatformNotReady from err
# Fetch initial data so we have data when entities subscribe.
entities = []
for device in device_manager.devices.values():
if (
CameraImageTrait.NAME in device.traits
or CameraLiveStreamTrait.NAME in device.traits
):
entities.append(NestCamera(device))
async_add_entities(entities)
class NestCamera(Camera):
"""Devices that support cameras."""
def __init__(self, device: Device):
"""Initialize the camera."""
super().__init__()
self._device = device
self._device_info = DeviceInfo(device)
self._stream = None
self._stream_refresh_unsub = None
# Cache of most recent event image
self._event_id = None
self._event_image_bytes = None
@property
def should_poll(self) -> bool:
"""Disable polling since entities have state pushed via pubsub."""
return False
@property
def unique_id(self) -> Optional[str]:
"""Return a unique ID."""
# The API "name" field is a unique device identifier.
return f"{self._device.name}-camera"
@property
def name(self):
"""Return the name of the camera."""
return self._device_info.device_name
@property
def device_info(self):
"""Return device specific attributes."""
return self._device_info.device_info
@property
def brand(self):
"""Return the camera brand."""
return self._device_info.device_brand
@property
def model(self):
"""Return the camera model."""
return self._device_info.device_model
@property
def supported_features(self):
"""Flag supported features."""
supported_features = 0
if CameraLiveStreamTrait.NAME in self._device.traits:
supported_features |= SUPPORT_STREAM
return supported_features
async def stream_source(self):
"""Return the source of the stream."""
if CameraLiveStreamTrait.NAME not in self._device.traits:
return None
trait = self._device.traits[CameraLiveStreamTrait.NAME]
if not self._stream:
_LOGGER.debug("Fetching stream url")
self._stream = await trait.generate_rtsp_stream()
self._schedule_stream_refresh()
if self._stream.expires_at < utcnow():
_LOGGER.warning("Stream already expired")
return self._stream.rtsp_stream_url
def _schedule_stream_refresh(self):
"""Schedules an alarm to refresh the stream url before expiration."""
_LOGGER.debug("New stream url expires at %s", self._stream.expires_at)
refresh_time = self._stream.expires_at - STREAM_EXPIRATION_BUFFER
# Schedule an alarm to extend the stream
if self._stream_refresh_unsub is not None:
self._stream_refresh_unsub()
self._stream_refresh_unsub = async_track_point_in_utc_time(
self.hass,
self._handle_stream_refresh,
refresh_time,
)
async def _handle_stream_refresh(self, now):
"""Alarm that fires to check if the stream should be refreshed."""
if not self._stream:
return
_LOGGER.debug("Extending stream url")
try:
self._stream = await self._stream.extend_rtsp_stream()
except GoogleNestException as err:
_LOGGER.debug("Failed to extend stream: %s", err)
# Next attempt to catch a url will get a new one
self._stream = None
return
self._schedule_stream_refresh()
async def async_will_remove_from_hass(self):
"""Invalidates the RTSP token when unloaded."""
if self._stream:
_LOGGER.debug("Invalidating stream")
await self._stream.stop_rtsp_stream()
if self._stream_refresh_unsub:
self._stream_refresh_unsub()
async def async_added_to_hass(self):
"""Run when entity is added to register update signal handler."""
self.async_on_remove(
self._device.add_update_listener(self.async_write_ha_state)
)
async def async_camera_image(self):
"""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
# Fetch still image from the live stream
stream_url = await self.stream_source()
if not stream_url:
return None
return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG)
async def _async_active_event_image(self):
"""Return image from any active events happening."""
if CameraEventImageTrait.NAME not in self._device.traits:
return None
trait = self._device.active_event_trait
if not trait:
return None
# Reuse image bytes if they have already been fetched
event_id = trait.last_event.event_id
if self._event_id is not None and self._event_id == event_id:
return self._event_image_bytes
_LOGGER.info("Fetching URL for event_id %s", event_id)
try:
event_image = await trait.generate_active_event_image()
except GoogleNestException as err:
_LOGGER.debug("Unable to generate event image URL: %s", err)
return None
if not event_image:
return None
try:
image_bytes = await event_image.contents()
except GoogleNestException as err:
_LOGGER.debug("Unable to fetch event image: %s", err)
return None
self._event_id = event_id
self._event_image_bytes = image_bytes
return image_bytes