"""Support for Google Nest SDM Cameras.""" from abc import ABC import asyncio from collections.abc import Awaitable, Callable import datetime import functools import logging from pathlib import Path from typing import override from google_nest_sdm.camera_traits import ( CameraLiveStreamTrait, RtspStream, StreamingProtocol, WebRtcStream, ) from google_nest_sdm.device import Device from google_nest_sdm.exceptions import ApiException from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( Camera, CameraEntityFeature, WebRTCAnswer, WebRTCClientConfiguration, WebRTCSendMessage, ) from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow from .device_info import NestDeviceInfo from .types import NestConfigEntry _LOGGER = logging.getLogger(__name__) PLACEHOLDER = Path(__file__).parent / "placeholder.png" # Used to schedule an alarm to refresh the stream before expiration STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) # Refresh streams with a bounded interval and backoff on failure MIN_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=1) MAX_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=10) BACKOFF_MULTIPLIER = 1.5 async def async_setup_entry( hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cameras.""" def devices_added(devices: list[Device]) -> None: entities: list[NestCameraBaseEntity] = [] for device in devices: if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None: continue if StreamingProtocol.WEB_RTC in live_stream.supported_protocols: entities.append(NestWebRTCEntity(device)) elif StreamingProtocol.RTSP in live_stream.supported_protocols: entities.append(NestRTSPEntity(device)) async_add_entities(entities) entry.runtime_data.register_devices_listener(devices_added) class StreamRefresh: """Class that will refresh an expiring stream. This class will schedule an alarm for the next expiration time of a stream. When the alarm fires, it runs the provided `refresh_cb` to extend the lifetime of the stream and return a new expiration time. A simple backoff will be applied when the refresh callback fails. """ def __init__( self, hass: HomeAssistant, expires_at: datetime.datetime, refresh_cb: Callable[[], Awaitable[datetime.datetime | None]], ) -> None: """Initialize StreamRefresh.""" self._hass = hass self._unsub: Callable[[], None] | None = None self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL self._refresh_cb = refresh_cb self._schedule_stream_refresh(expires_at - STREAM_EXPIRATION_BUFFER) def unsub(self) -> None: """Invalidates the stream.""" if self._unsub: self._unsub() async def _handle_refresh(self, _: datetime.datetime) -> None: """Alarm that fires to check if the stream should be refreshed.""" self._unsub = None try: expires_at = await self._refresh_cb() except ApiException as err: _LOGGER.debug("Failed to refresh stream: %s", err) # Increase backoff until the max backoff interval is reached self._min_refresh_interval = min( self._min_refresh_interval * BACKOFF_MULTIPLIER, MAX_REFRESH_BACKOFF_INTERVAL, ) refresh_time = utcnow() + self._min_refresh_interval else: if expires_at is None: return self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL # Reset backoff # Defend against invalid stream expiration time in the past refresh_time = max( expires_at - STREAM_EXPIRATION_BUFFER, utcnow() + self._min_refresh_interval, ) self._schedule_stream_refresh(refresh_time) def _schedule_stream_refresh(self, refresh_time: datetime.datetime) -> None: """Schedules an alarm to refresh any streams before expiration.""" _LOGGER.debug("Scheduling stream refresh for %s", refresh_time) self._unsub = async_track_point_in_utc_time( self._hass, self._handle_refresh, refresh_time, ) class NestCameraBaseEntity(Camera, ABC): """Devices that support cameras.""" _attr_has_entity_name = True _attr_name = None _attr_is_streaming = True _attr_supported_features = CameraEntityFeature.STREAM def __init__(self, device: Device) -> None: """Initialize the camera.""" super().__init__() self._device = device nest_device_info = NestDeviceInfo(device) self._attr_device_info = nest_device_info.device_info self._attr_brand = nest_device_info.device_brand self._attr_model = nest_device_info.device_model self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 # The API "name" field is a unique device identifier. self._attr_unique_id = f"{self._device.name}-camera" # pylint: disable=home-assistant-entity-unique-id-redundant-platform @override async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" self.async_on_remove( self._device.add_update_listener(self.async_write_ha_state) ) class NestRTSPEntity(NestCameraBaseEntity): """Nest cameras that use RTSP.""" _rtsp_stream: RtspStream | None = None _rtsp_live_stream_trait: CameraLiveStreamTrait def __init__(self, device: Device) -> None: """Initialize the camera.""" super().__init__(device) self._create_stream_url_lock = asyncio.Lock() self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME] self._refresh_unsub: Callable[[], None] | None = None @property @override def use_stream_for_stills(self) -> bool: """Always use the RTSP stream to generate snapshots.""" return True @property @override def available(self) -> bool: """Return True if entity is available.""" # Cameras are marked unavailable on stream errors in #54659 however nest # streams have a high error rate (#60353). Given nest streams are so flaky, # marking the stream unavailable has other side effects like not showing # the camera image which sometimes are still able to work. Until the # streams are fixed, just leave the streams as available. return True @override async def stream_source(self) -> str | None: """Return the source of the stream.""" async with self._create_stream_url_lock: if not self._rtsp_stream: _LOGGER.debug("Fetching stream url") try: self._rtsp_stream = ( await self._rtsp_live_stream_trait.generate_rtsp_stream() ) except ApiException as err: raise HomeAssistantError(f"Nest API error: {err}") from err refresh = StreamRefresh( self.hass, self._rtsp_stream.expires_at, self._async_refresh_stream, ) self._refresh_unsub = refresh.unsub assert self._rtsp_stream if self._rtsp_stream.expires_at < utcnow(): _LOGGER.warning("Stream already expired") return self._rtsp_stream.rtsp_stream_url async def _async_refresh_stream(self) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" if not self._rtsp_stream: return None _LOGGER.debug("Extending RTSP stream") try: self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream() except ApiException as err: _LOGGER.debug("Failed to extend stream: %s", err) # Next attempt to catch a url will get a new one self._rtsp_stream = None if self.stream: await self.stream.stop() self.stream = None return None # Update the stream worker with the latest valid url if self.stream: self.stream.update_source(self._rtsp_stream.rtsp_stream_url) return self._rtsp_stream.expires_at @override async def async_will_remove_from_hass(self) -> None: """Invalidates the RTSP token when unloaded.""" await super().async_will_remove_from_hass() if self._refresh_unsub is not None: self._refresh_unsub() if self._rtsp_stream: try: await self._rtsp_stream.stop_stream() except ApiException as err: _LOGGER.debug("Error stopping stream: %s", err) self._rtsp_stream = None class NestWebRTCEntity(NestCameraBaseEntity): """Nest cameras that use WebRTC.""" def __init__(self, device: Device) -> None: """Initialize the camera.""" super().__init__(device) self._webrtc_sessions: dict[str, WebRtcStream] = {} self._refresh_unsub: dict[str, Callable[[], None]] = {} # The bundled placeholder is a PNG; the camera platform would otherwise # serve it as its default image/jpeg, corrupting the frame for any # client that trusts the Content-Type header. self.content_type = "image/png" async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" if not (webrtc_stream := self._webrtc_sessions.get(session_id)): return None _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id) webrtc_stream = await webrtc_stream.extend_stream() if session_id in self._webrtc_sessions: self._webrtc_sessions[session_id] = webrtc_stream return webrtc_stream.expires_at return None @override async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a placeholder image for WebRTC cameras. WebRTC cameras don't support snapshots. """ return await self.hass.async_add_executor_job(self.placeholder_image) @classmethod @functools.cache def placeholder_image(cls) -> bytes: """Return placeholder image to use when no stream is available.""" return PLACEHOLDER.read_bytes() @override async def async_handle_async_webrtc_offer( self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage ) -> None: """Return the source of the stream.""" trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME] try: stream = await trait.generate_web_rtc_stream(offer_sdp) except ApiException as err: raise HomeAssistantError(f"Nest API error: {err}") from err _LOGGER.debug( "Started WebRTC session %s, %s", session_id, stream.media_session_id ) self._webrtc_sessions[session_id] = stream send_message(WebRTCAnswer(stream.answer_sdp)) refresh = StreamRefresh( self.hass, stream.expires_at, functools.partial(self._async_refresh_stream, session_id), ) self._refresh_unsub[session_id] = refresh.unsub @override async def async_on_webrtc_candidate( self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Ignore WebRTC candidates for Nest cloud based cameras.""" return @callback @override def close_webrtc_session(self, session_id: str) -> None: """Close a WebRTC session.""" if (stream := self._webrtc_sessions.pop(session_id, None)) is not None: _LOGGER.debug( "Closing WebRTC session %s, %s", session_id, stream.media_session_id ) unsub = self._refresh_unsub.pop(session_id) unsub() async def stop_stream() -> None: try: await stream.stop_stream() except ApiException as err: _LOGGER.debug("Error stopping stream: %s", err) self.hass.async_create_task(stop_stream()) super().close_webrtc_session(session_id) @callback @override def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: """Return the WebRTC client configuration adjustable per integration.""" return WebRTCClientConfiguration(data_channel="dataSendChannel") @override async def async_will_remove_from_hass(self) -> None: """Invalidates the RTSP token when unloaded.""" await super().async_will_remove_from_hass() for session_id in list(self._webrtc_sessions.keys()): self.close_webrtc_session(session_id)