Home Assistant Unofficial Reference 2024.12.1
camera.py
Go to the documentation of this file.
1 """Support for Google Nest SDM Cameras."""
2 
3 from __future__ import annotations
4 
5 from abc import ABC
6 import asyncio
7 from collections.abc import Awaitable, Callable
8 import datetime
9 import functools
10 import logging
11 from pathlib import Path
12 
13 from google_nest_sdm.camera_traits import (
14  CameraLiveStreamTrait,
15  RtspStream,
16  StreamingProtocol,
17  WebRtcStream,
18 )
19 from google_nest_sdm.device import Device
20 from google_nest_sdm.device_manager import DeviceManager
21 from google_nest_sdm.exceptions import ApiException
22 from webrtc_models import RTCIceCandidateInit
23 
25  Camera,
26  CameraEntityFeature,
27  WebRTCAnswer,
28  WebRTCClientConfiguration,
29  WebRTCSendMessage,
30 )
31 from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME
32 from homeassistant.config_entries import ConfigEntry
33 from homeassistant.core import HomeAssistant, callback
34 from homeassistant.exceptions import HomeAssistantError
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 from homeassistant.helpers.event import async_track_point_in_utc_time
37 from homeassistant.util.dt import utcnow
38 
39 from .const import DATA_DEVICE_MANAGER, DOMAIN
40 from .device_info import NestDeviceInfo
41 
42 _LOGGER = logging.getLogger(__name__)
43 
44 PLACEHOLDER = Path(__file__).parent / "placeholder.png"
45 
46 # Used to schedule an alarm to refresh the stream before expiration
47 STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30)
48 
49 # Refresh streams with a bounded interval and backoff on failure
50 MIN_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=1)
51 MAX_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=10)
52 BACKOFF_MULTIPLIER = 1.5
53 
54 
56  hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
57 ) -> None:
58  """Set up the cameras."""
59 
60  device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
61  DATA_DEVICE_MANAGER
62  ]
63  entities: list[NestCameraBaseEntity] = []
64  for device in device_manager.devices.values():
65  if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None:
66  continue
67  if StreamingProtocol.WEB_RTC in live_stream.supported_protocols:
68  entities.append(NestWebRTCEntity(device))
69  elif StreamingProtocol.RTSP in live_stream.supported_protocols:
70  entities.append(NestRTSPEntity(device))
71 
72  async_add_entities(entities)
73 
74 
76  """Class that will refresh an expiring stream.
77 
78  This class will schedule an alarm for the next expiration time of a stream.
79  When the alarm fires, it runs the provided `refresh_cb` to extend the
80  lifetime of the stream and return a new expiration time.
81 
82  A simple backoff will be applied when the refresh callback fails.
83  """
84 
85  def __init__(
86  self,
87  hass: HomeAssistant,
88  expires_at: datetime.datetime,
89  refresh_cb: Callable[[], Awaitable[datetime.datetime | None]],
90  ) -> None:
91  """Initialize StreamRefresh."""
92  self._hass_hass = hass
93  self._unsub_unsub: Callable[[], None] | None = None
94  self._min_refresh_interval_min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL
95  self._refresh_cb_refresh_cb = refresh_cb
96  self._schedule_stream_refresh_schedule_stream_refresh(expires_at - STREAM_EXPIRATION_BUFFER)
97 
98  def unsub(self) -> None:
99  """Invalidates the stream."""
100  if self._unsub_unsub:
101  self._unsub_unsub()
102 
103  async def _handle_refresh(self, _: datetime.datetime) -> None:
104  """Alarm that fires to check if the stream should be refreshed."""
105  self._unsub_unsub = None
106  try:
107  expires_at = await self._refresh_cb_refresh_cb()
108  except ApiException as err:
109  _LOGGER.debug("Failed to refresh stream: %s", err)
110  # Increase backoff until the max backoff interval is reached
111  self._min_refresh_interval_min_refresh_interval = min(
112  self._min_refresh_interval_min_refresh_interval * BACKOFF_MULTIPLIER,
113  MAX_REFRESH_BACKOFF_INTERVAL,
114  )
115  refresh_time = utcnow() + self._min_refresh_interval_min_refresh_interval
116  else:
117  if expires_at is None:
118  return
119  self._min_refresh_interval_min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL # Reset backoff
120  # Defend against invalid stream expiration time in the past
121  refresh_time = max(
122  expires_at - STREAM_EXPIRATION_BUFFER,
123  utcnow() + self._min_refresh_interval_min_refresh_interval,
124  )
125  self._schedule_stream_refresh_schedule_stream_refresh(refresh_time)
126 
127  def _schedule_stream_refresh(self, refresh_time: datetime.datetime) -> None:
128  """Schedules an alarm to refresh any streams before expiration."""
129  _LOGGER.debug("Scheduling stream refresh for %s", refresh_time)
131  self._hass_hass,
132  self._handle_refresh_handle_refresh,
133  refresh_time,
134  )
135 
136 
138  """Devices that support cameras."""
139 
140  _attr_has_entity_name = True
141  _attr_name = None
142  _attr_is_streaming = True
143  _attr_supported_features = CameraEntityFeature.STREAM
144 
145  def __init__(self, device: Device) -> None:
146  """Initialize the camera."""
147  super().__init__()
148  self._device_device = device
149  nest_device_info = NestDeviceInfo(device)
150  self._attr_device_info_attr_device_info = nest_device_info.device_info
151  self._attr_brand_attr_brand = nest_device_info.device_brand
152  self._attr_model_attr_model = nest_device_info.device_model
153  self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
154  # The API "name" field is a unique device identifier.
155  self._attr_unique_id_attr_unique_id = f"{self._device.name}-camera"
156 
157  async def async_added_to_hass(self) -> None:
158  """Run when entity is added to register update signal handler."""
159  self.async_on_removeasync_on_remove(
160  self._device_device.add_update_listener(self.async_write_ha_stateasync_write_ha_stateasync_write_ha_state)
161  )
162 
163 
165  """Nest cameras that use RTSP."""
166 
167  _rtsp_stream: RtspStream | None = None
168  _rtsp_live_stream_trait: CameraLiveStreamTrait
169 
170  def __init__(self, device: Device) -> None:
171  """Initialize the camera."""
172  super().__init__(device)
173  self._create_stream_url_lock_create_stream_url_lock = asyncio.Lock()
174  self._rtsp_live_stream_trait_rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME]
175  self._refresh_unsub_refresh_unsub: Callable[[], None] | None = None
176 
177  @property
178  def use_stream_for_stills(self) -> bool:
179  """Always use the RTSP stream to generate snapshots."""
180  return True
181 
182  @property
183  def available(self) -> bool:
184  """Return True if entity is available."""
185  # Cameras are marked unavailable on stream errors in #54659 however nest
186  # streams have a high error rate (#60353). Given nest streams are so flaky,
187  # marking the stream unavailable has other side effects like not showing
188  # the camera image which sometimes are still able to work. Until the
189  # streams are fixed, just leave the streams as available.
190  return True
191 
192  async def stream_source(self) -> str | None:
193  """Return the source of the stream."""
194  async with self._create_stream_url_lock_create_stream_url_lock:
195  if not self._rtsp_stream_rtsp_stream:
196  _LOGGER.debug("Fetching stream url")
197  try:
198  self._rtsp_stream_rtsp_stream = (
199  await self._rtsp_live_stream_trait_rtsp_live_stream_trait.generate_rtsp_stream()
200  )
201  except ApiException as err:
202  raise HomeAssistantError(f"Nest API error: {err}") from err
203  refresh = StreamRefresh(
204  self.hasshass,
205  self._rtsp_stream_rtsp_stream.expires_at,
206  self._async_refresh_stream_async_refresh_stream,
207  )
208  self._refresh_unsub_refresh_unsub = refresh.unsub
209  assert self._rtsp_stream_rtsp_stream
210  if self._rtsp_stream_rtsp_stream.expires_at < utcnow():
211  _LOGGER.warning("Stream already expired")
212  return self._rtsp_stream_rtsp_stream.rtsp_stream_url
213 
214  async def _async_refresh_stream(self) -> datetime.datetime | None:
215  """Refresh stream to extend expiration time."""
216  if not self._rtsp_stream_rtsp_stream:
217  return None
218  _LOGGER.debug("Extending RTSP stream")
219  try:
220  self._rtsp_stream_rtsp_stream = await self._rtsp_stream_rtsp_stream.extend_rtsp_stream()
221  except ApiException as err:
222  _LOGGER.debug("Failed to extend stream: %s", err)
223  # Next attempt to catch a url will get a new one
224  self._rtsp_stream_rtsp_stream = None
225  if self.streamstreamstream:
226  await self.streamstreamstream.stop()
227  self.streamstreamstream = None
228  return None
229  # Update the stream worker with the latest valid url
230  if self.streamstreamstream:
231  self.streamstreamstream.update_source(self._rtsp_stream_rtsp_stream.rtsp_stream_url)
232  return self._rtsp_stream_rtsp_stream.expires_at
233 
234  async def async_will_remove_from_hass(self) -> None:
235  """Invalidates the RTSP token when unloaded."""
236  await super().async_will_remove_from_hass()
237  if self._refresh_unsub_refresh_unsub is not None:
238  self._refresh_unsub_refresh_unsub()
239  if self._rtsp_stream_rtsp_stream:
240  try:
241  await self._rtsp_stream_rtsp_stream.stop_stream()
242  except ApiException as err:
243  _LOGGER.debug("Error stopping stream: %s", err)
244  self._rtsp_stream_rtsp_stream = None
245 
246 
248  """Nest cameras that use WebRTC."""
249 
250  def __init__(self, device: Device) -> None:
251  """Initialize the camera."""
252  super().__init__(device)
253  self._webrtc_sessions: dict[str, WebRtcStream] = {}
254  self._refresh_unsub: dict[str, Callable[[], None]] = {}
255 
256  async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None:
257  """Refresh stream to extend expiration time."""
258  if not (webrtc_stream := self._webrtc_sessions.get(session_id)):
259  return None
260  _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id)
261  webrtc_stream = await webrtc_stream.extend_stream()
262  if session_id in self._webrtc_sessions:
263  self._webrtc_sessions[session_id] = webrtc_stream
264  return webrtc_stream.expires_at
265  return None
266 
268  self, width: int | None = None, height: int | None = None
269  ) -> bytes | None:
270  """Return a placeholder image for WebRTC cameras that don't support snapshots."""
271  return await self.hasshass.async_add_executor_job(self.placeholder_imageplaceholder_image)
272 
273  @classmethod
274  @functools.cache
275  def placeholder_image(cls) -> bytes:
276  """Return placeholder image to use when no stream is available."""
277  return PLACEHOLDER.read_bytes()
278 
280  self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
281  ) -> None:
282  """Return the source of the stream."""
283  trait: CameraLiveStreamTrait = self._device_device.traits[CameraLiveStreamTrait.NAME]
284  try:
285  stream = await trait.generate_web_rtc_stream(offer_sdp)
286  except ApiException as err:
287  raise HomeAssistantError(f"Nest API error: {err}") from err
288  _LOGGER.debug(
289  "Started WebRTC session %s, %s", session_id, stream.media_session_id
290  )
291  self._webrtc_sessions[session_id] = stream
292  send_message(WebRTCAnswer(stream.answer_sdp))
293  refresh = StreamRefresh(
294  self.hasshass,
295  stream.expires_at,
296  functools.partial(self._async_refresh_stream_async_refresh_stream, session_id),
297  )
298  self._refresh_unsub[session_id] = refresh.unsub
299 
301  self, session_id: str, candidate: RTCIceCandidateInit
302  ) -> None:
303  """Ignore WebRTC candidates for Nest cloud based cameras."""
304  return
305 
306  @callback
307  def close_webrtc_session(self, session_id: str) -> None:
308  """Close a WebRTC session."""
309  if (stream := self._webrtc_sessions.pop(session_id, None)) is not None:
310  _LOGGER.debug(
311  "Closing WebRTC session %s, %s", session_id, stream.media_session_id
312  )
313  unsub = self._refresh_unsub.pop(session_id)
314  unsub()
315 
316  async def stop_stream() -> None:
317  try:
318  await stream.stop_stream()
319  except ApiException as err:
320  _LOGGER.debug("Error stopping stream: %s", err)
321 
322  self.hasshass.async_create_task(stop_stream())
323  super().close_webrtc_session(session_id)
324 
325  @callback
326  def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
327  """Return the WebRTC client configuration adjustable per integration."""
328  return WebRTCClientConfiguration(data_channel="dataSendChannel")
329 
330  async def async_will_remove_from_hass(self) -> None:
331  """Invalidates the RTSP token when unloaded."""
332  await super().async_will_remove_from_hass()
333  for session_id in list(self._webrtc_sessions.keys()):
334  self.close_webrtc_sessionclose_webrtc_sessionclose_webrtc_session(session_id)
None close_webrtc_session(self, str session_id)
Definition: __init__.py:911
datetime.datetime|None _async_refresh_stream(self)
Definition: camera.py:214
bytes|None async_camera_image(self, int|None width=None, int|None height=None)
Definition: camera.py:269
WebRTCClientConfiguration _async_get_webrtc_client_configuration(self)
Definition: camera.py:326
None async_handle_async_webrtc_offer(self, str offer_sdp, str session_id, WebRTCSendMessage send_message)
Definition: camera.py:281
None close_webrtc_session(self, str session_id)
Definition: camera.py:307
datetime.datetime|None _async_refresh_stream(self, str session_id)
Definition: camera.py:256
None async_on_webrtc_candidate(self, str session_id, RTCIceCandidateInit candidate)
Definition: camera.py:302
None _handle_refresh(self, datetime.datetime _)
Definition: camera.py:103
None _schedule_stream_refresh(self, datetime.datetime refresh_time)
Definition: camera.py:127
None __init__(self, HomeAssistant hass, datetime.datetime expires_at, Callable[[], Awaitable[datetime.datetime|None]] refresh_cb)
Definition: camera.py:90
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: camera.py:57
CALLBACK_TYPE async_track_point_in_utc_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
Definition: event.py:1542