Home Assistant Unofficial Reference 2024.12.1
media_source.py
Go to the documentation of this file.
1 """Nest Media Source implementation.
2 
3 The Nest MediaSource implementation provides a directory tree of devices and
4 events and associated media (e.g. an image or clip). Camera device events
5 publish an event message, received by the subscriber library. Media for an
6 event, such as camera image or clip, may be fetched from the cloud during a
7 short time window after the event happens.
8 
9 The actual management of associating events to devices, fetching media for
10 events, caching, and the overall lifetime of recent events are managed outside
11 of the Nest MediaSource.
12 
13 Users may also record clips to local storage, unrelated to this MediaSource.
14 
15 For additional background on Nest Camera events see:
16 https://developers.google.com/nest/device-access/api/camera#handle_camera_events
17 """
18 
19 from __future__ import annotations
20 
21 from collections.abc import Mapping
22 from dataclasses import dataclass
23 import logging
24 import os
25 from typing import Any
26 
27 from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait
28 from google_nest_sdm.device import Device
29 from google_nest_sdm.event import EventImageType, ImageEventBase
30 from google_nest_sdm.event_media import (
31  ClipPreviewSession,
32  EventMediaStore,
33  ImageSession,
34 )
35 from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
36 from google_nest_sdm.transcoder import Transcoder
37 
38 from homeassistant.components.ffmpeg import get_ffmpeg_manager
39 from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
41  BrowseMediaSource,
42  MediaSource,
43  MediaSourceItem,
44  PlayMedia,
45  Unresolvable,
46 )
47 from homeassistant.core import HomeAssistant, callback
48 from homeassistant.helpers import device_registry as dr
49 from homeassistant.helpers.storage import Store
50 from homeassistant.helpers.template import DATE_STR_FORMAT
51 from homeassistant.util import dt as dt_util
52 
53 from .const import DOMAIN
54 from .device_info import NestDeviceInfo, async_nest_devices_by_device_id
55 from .events import EVENT_NAME_MAP, MEDIA_SOURCE_EVENT_TITLE_MAP
56 
57 _LOGGER = logging.getLogger(__name__)
58 
59 MEDIA_SOURCE_TITLE = "Nest"
60 DEVICE_TITLE_FORMAT = "{device_name}: Recent Events"
61 CLIP_TITLE_FORMAT = "{event_name} @ {event_time}"
62 EVENT_MEDIA_API_URL_FORMAT = "/api/nest/event_media/{device_id}/{event_token}"
63 EVENT_THUMBNAIL_URL_FORMAT = "/api/nest/event_media/{device_id}/{event_token}/thumbnail"
64 
65 STORAGE_KEY = "nest.event_media"
66 STORAGE_VERSION = 1
67 # Buffer writes every few minutes (plus guaranteed to be written at shutdown)
68 STORAGE_SAVE_DELAY_SECONDS = 120
69 # Path under config directory
70 MEDIA_PATH = f"{DOMAIN}/event_media"
71 
72 # Size of small in-memory disk cache to avoid excessive disk reads
73 DISK_READ_LRU_MAX_SIZE = 32
74 
75 
77  hass: HomeAssistant, subscriber: GoogleNestSubscriber
78 ) -> EventMediaStore:
79  """Create the disk backed EventMediaStore."""
80  media_path = hass.config.path(MEDIA_PATH)
81 
82  def mkdir() -> None:
83  os.makedirs(media_path, exist_ok=True)
84 
85  await hass.async_add_executor_job(mkdir)
86  store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY, private=True)
87  return NestEventMediaStore(hass, subscriber, store, media_path)
88 
89 
90 async def async_get_transcoder(hass: HomeAssistant) -> Transcoder:
91  """Get a nest clip transcoder."""
92  media_path = hass.config.path(MEDIA_PATH)
93  ffmpeg_manager = get_ffmpeg_manager(hass)
94  return Transcoder(ffmpeg_manager.binary, media_path)
95 
96 
97 class NestEventMediaStore(EventMediaStore):
98  """Storage hook to locally persist nest media for events.
99 
100  This interface is meant to provide two storage features:
101  - media storage of events (jpgs, mp4s)
102  - metadata about events (e.g. motion, person), filename of the media, etc.
103 
104  The default implementation in nest is in memory, and this allows the data
105  to be backed by disk.
106 
107  The nest event media manager internal to the subscriber manages the lifetime
108  of individual objects stored here (e.g. purging when going over storage
109  limits). This store manages the addition/deletion once instructed.
110  """
111 
112  def __init__(
113  self,
114  hass: HomeAssistant,
115  subscriber: GoogleNestSubscriber,
116  store: Store[dict[str, Any]],
117  media_path: str,
118  ) -> None:
119  """Initialize NestEventMediaStore."""
120  self._hass_hass = hass
121  self._subscriber_subscriber = subscriber
122  self._store_store = store
123  self._media_path_media_path = media_path
124  self._data_data: dict[str, Any] | None = None
125  self._devices_devices: Mapping[str, str] | None = {}
126 
127  async def async_load(self) -> dict | None:
128  """Load data."""
129  if self._data_data is None:
130  self._devices_devices = await self._get_devices_get_devices()
131  if (data := await self._store_store.async_load()) is None:
132  _LOGGER.debug("Loaded empty event store")
133  self._data_data = {}
134  else:
135  _LOGGER.debug("Loaded event store with %d records", len(data))
136  self._data_data = data
137  return self._data_data
138 
139  async def async_save(self, data: dict) -> None:
140  """Save data."""
141  self._data_data = data
142 
143  def provide_data() -> dict:
144  return data
145 
146  self._store_store.async_delay_save(provide_data, STORAGE_SAVE_DELAY_SECONDS)
147 
148  def get_media_key(self, device_id: str, event: ImageEventBase) -> str:
149  """Return the filename to use for a new event."""
150  if event.event_image_type != EventImageType.IMAGE:
151  raise ValueError("No longer used for video clips")
152  return self.get_image_media_keyget_image_media_key(device_id, event)
153 
154  def _map_device_id(self, device_id: str) -> str:
155  return (
156  self._devices_devices.get(device_id, f"{device_id}-unknown_device")
157  if self._devices_devices
158  else "unknown_device"
159  )
160 
161  def get_image_media_key(self, device_id: str, event: ImageEventBase) -> str:
162  """Return the filename for image media for an event."""
163  device_id_str = self._map_device_id_map_device_id(device_id)
164  time_str = str(int(event.timestamp.timestamp()))
165  event_type_str = EVENT_NAME_MAP.get(event.event_type, "event")
166  return f"{device_id_str}/{time_str}-{event_type_str}.jpg"
167 
168  def get_clip_preview_media_key(self, device_id: str, event: ImageEventBase) -> str:
169  """Return the filename for clip preview media for an event session."""
170  device_id_str = self._map_device_id_map_device_id(device_id)
171  time_str = str(int(event.timestamp.timestamp()))
172  event_type_str = EVENT_NAME_MAP.get(event.event_type, "event")
173  return f"{device_id_str}/{time_str}-{event_type_str}.mp4"
174 
176  self, device_id: str, event: ImageEventBase
177  ) -> str:
178  """Return the filename for clip preview thumbnail media for an event session."""
179  device_id_str = self._map_device_id_map_device_id(device_id)
180  time_str = str(int(event.timestamp.timestamp()))
181  event_type_str = EVENT_NAME_MAP.get(event.event_type, "event")
182  return f"{device_id_str}/{time_str}-{event_type_str}_thumb.gif"
183 
184  def get_media_filename(self, media_key: str) -> str:
185  """Return the filename in storage for a media key."""
186  return f"{self._media_path}/{media_key}"
187 
188  async def async_load_media(self, media_key: str) -> bytes | None:
189  """Load media content."""
190  filename = self.get_media_filenameget_media_filename(media_key)
191 
192  def load_media(filename: str) -> bytes | None:
193  if not os.path.exists(filename):
194  return None
195  _LOGGER.debug("Reading event media from disk store: %s", filename)
196  with open(filename, "rb") as media:
197  return media.read()
198 
199  try:
200  return await self._hass_hass.async_add_executor_job(load_media, filename)
201  except OSError as err:
202  _LOGGER.error("Unable to read media file: %s %s", filename, err)
203  return None
204 
205  async def async_save_media(self, media_key: str, content: bytes) -> None:
206  """Write media content."""
207  filename = self.get_media_filenameget_media_filename(media_key)
208 
209  def save_media(filename: str, content: bytes) -> None:
210  os.makedirs(os.path.dirname(filename), exist_ok=True)
211  if os.path.exists(filename):
212  _LOGGER.debug(
213  "Event media already exists, not overwriting: %s", filename
214  )
215  return
216  _LOGGER.debug("Saving event media to disk store: %s", filename)
217  with open(filename, "wb") as media:
218  media.write(content)
219 
220  try:
221  await self._hass_hass.async_add_executor_job(save_media, filename, content)
222  except OSError as err:
223  _LOGGER.error("Unable to write media file: %s %s", filename, err)
224 
225  async def async_remove_media(self, media_key: str) -> None:
226  """Remove media content."""
227  filename = self.get_media_filenameget_media_filename(media_key)
228 
229  def remove_media(filename: str) -> None:
230  if not os.path.exists(filename):
231  return
232  _LOGGER.debug("Removing event media from disk store: %s", filename)
233  os.remove(filename)
234 
235  try:
236  await self._hass_hass.async_add_executor_job(remove_media, filename)
237  except OSError as err:
238  _LOGGER.error("Unable to remove media file: %s %s", filename, err)
239 
240  async def _get_devices(self) -> Mapping[str, str]:
241  """Return a mapping of nest device id to home assistant device id."""
242  device_registry = dr.async_get(self._hass_hass)
243  device_manager = await self._subscriber_subscriber.async_get_device_manager()
244  devices = {}
245  for device in device_manager.devices.values():
246  if device_entry := device_registry.async_get_device(
247  identifiers={(DOMAIN, device.name)}
248  ):
249  devices[device.name] = device_entry.id
250  return devices
251 
252 
253 async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
254  """Set up Nest media source."""
255  return NestMediaSource(hass)
256 
257 
258 @callback
259 def async_get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]:
260  """Return a mapping of device id to eligible Nest event media devices."""
261  devices = async_nest_devices_by_device_id(hass)
262  return {
263  device_id: device
264  for device_id, device in devices.items()
265  if CameraEventImageTrait.NAME in device.traits
266  or CameraClipPreviewTrait.NAME in device.traits
267  }
268 
269 
270 @dataclass
271 class MediaId:
272  """Media identifier for a node in the Media Browse tree.
273 
274  A MediaId can refer to either a device, or a specific event for a device
275  that is associated with media (e.g. image or video clip).
276  """
277 
278  device_id: str
279  event_token: str | None = None
280 
281  @property
282  def identifier(self) -> str:
283  """Media identifier represented as a string."""
284  if self.event_token:
285  return f"{self.device_id}/{self.event_token}"
286  return self.device_id
287 
288 
289 def parse_media_id(identifier: str | None = None) -> MediaId | None:
290  """Parse the identifier path string into a MediaId."""
291  if identifier is None or identifier == "":
292  return None
293  parts = identifier.split("/")
294  if len(parts) > 1:
295  return MediaId(parts[0], parts[1])
296  return MediaId(parts[0])
297 
298 
300  """Provide Nest Media Sources for Nest Cameras.
301 
302  The media source generates a directory tree of devices and media associated
303  with events for each device (e.g. motion, person, etc). Each node in the
304  tree has a unique MediaId.
305 
306  The lifecycle for event media is handled outside of NestMediaSource, and
307  instead it just asks the device for all events it knows about.
308  """
309 
310  name: str = MEDIA_SOURCE_TITLE
311 
312  def __init__(self, hass: HomeAssistant) -> None:
313  """Initialize NestMediaSource."""
314  super().__init__(DOMAIN)
315  self.hasshass = hass
316 
317  async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
318  """Resolve media identifier to a url."""
319  media_id: MediaId | None = parse_media_id(item.identifier)
320  if not media_id:
321  raise Unresolvable("No identifier specified for MediaSourceItem")
322  devices = async_get_media_source_devices(self.hasshass)
323  if not (device := devices.get(media_id.device_id)):
324  raise Unresolvable(
325  f"Unable to find device with identifier: {item.identifier}"
326  )
327  if not media_id.event_token:
328  # The device resolves to the most recent event if available
329  if not (
330  last_event_id := await _async_get_recent_event_id(media_id, device)
331  ):
332  raise Unresolvable(
333  f"Unable to resolve recent event for device: {item.identifier}"
334  )
335  media_id = last_event_id
336 
337  # Infer content type from the device, since it only supports one
338  # snapshot type (either jpg or mp4 clip)
339  content_type = EventImageType.IMAGE.content_type
340  if CameraClipPreviewTrait.NAME in device.traits:
341  content_type = EventImageType.CLIP_PREVIEW.content_type
342  return PlayMedia(
343  EVENT_MEDIA_API_URL_FORMAT.format(
344  device_id=media_id.device_id, event_token=media_id.event_token
345  ),
346  content_type,
347  )
348 
349  async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
350  """Return media for the specified level of the directory tree.
351 
352  The top level is the root that contains devices. Inside each device are
353  media for events for that device.
354  """
355  media_id: MediaId | None = parse_media_id(item.identifier)
356  _LOGGER.debug(
357  "Browsing media for identifier=%s, media_id=%s", item.identifier, media_id
358  )
359  devices = async_get_media_source_devices(self.hasshass)
360  if media_id is None:
361  # Browse the root and return child devices
362  browse_root = _browse_root()
363  browse_root.children = []
364  for device_id, child_device in devices.items():
365  browse_device = _browse_device(MediaId(device_id), child_device)
366  if last_event_id := await _async_get_recent_event_id(
367  MediaId(device_id), child_device
368  ):
369  browse_device.thumbnail = EVENT_THUMBNAIL_URL_FORMAT.format(
370  device_id=last_event_id.device_id,
371  event_token=last_event_id.event_token,
372  )
373  browse_device.can_play = True
374  browse_root.children.append(browse_device)
375  return browse_root
376 
377  # Browse either a device or events within a device
378  if not (device := devices.get(media_id.device_id)):
379  raise BrowseError(
380  f"Unable to find device with identiifer: {item.identifier}"
381  )
382  # Clip previews are a session with multiple possible event types (e.g.
383  # person, motion, etc) and a single mp4
384  if CameraClipPreviewTrait.NAME in device.traits:
385  clips: dict[
386  str, ClipPreviewSession
387  ] = await _async_get_clip_preview_sessions(device)
388  if media_id.event_token is None:
389  # Browse a specific device and return child events
390  browse_device = _browse_device(media_id, device)
391  browse_device.children = []
392  for clip in clips.values():
393  event_id = MediaId(media_id.device_id, clip.event_token)
394  browse_device.children.append(
395  _browse_clip_preview(event_id, device, clip)
396  )
397  return browse_device
398 
399  # Browse a specific event
400  if not (single_clip := clips.get(media_id.event_token)):
401  raise BrowseError(
402  f"Unable to find event with identiifer: {item.identifier}"
403  )
404  return _browse_clip_preview(media_id, device, single_clip)
405 
406  # Image events are 1:1 of media to event
407  images: dict[str, ImageSession] = await _async_get_image_sessions(device)
408  if media_id.event_token is None:
409  # Browse a specific device and return child events
410  browse_device = _browse_device(media_id, device)
411  browse_device.children = []
412  for image in images.values():
413  event_id = MediaId(media_id.device_id, image.event_token)
414  browse_device.children.append(
415  _browse_image_event(event_id, device, image)
416  )
417  return browse_device
418 
419  # Browse a specific event
420  if not (single_image := images.get(media_id.event_token)):
421  raise BrowseError(
422  f"Unable to find event with identiifer: {item.identifier}"
423  )
424  return _browse_image_event(media_id, device, single_image)
425 
426 
428  device: Device,
429 ) -> dict[str, ClipPreviewSession]:
430  """Return clip preview sessions for the device."""
431  events = await device.event_media_manager.async_clip_preview_sessions()
432  return {e.event_token: e for e in events}
433 
434 
435 async def _async_get_image_sessions(device: Device) -> dict[str, ImageSession]:
436  """Return image events for the device."""
437  events = await device.event_media_manager.async_image_sessions()
438  return {e.event_token: e for e in events}
439 
440 
441 def _browse_root() -> BrowseMediaSource:
442  """Return devices in the root."""
443  return BrowseMediaSource(
444  domain=DOMAIN,
445  identifier="",
446  media_class=MediaClass.DIRECTORY,
447  media_content_type=MediaType.VIDEO,
448  children_media_class=MediaClass.VIDEO,
449  title=MEDIA_SOURCE_TITLE,
450  can_play=False,
451  can_expand=True,
452  thumbnail=None,
453  children=[],
454  )
455 
456 
458  device_id: MediaId, device: Device
459 ) -> MediaId | None:
460  """Return thumbnail for most recent device event."""
461  if CameraClipPreviewTrait.NAME in device.traits:
462  clips = await device.event_media_manager.async_clip_preview_sessions()
463  if not clips:
464  return None
465  return MediaId(device_id.device_id, next(iter(clips)).event_token)
466  images = await device.event_media_manager.async_image_sessions()
467  if not images:
468  return None
469  return MediaId(device_id.device_id, next(iter(images)).event_token)
470 
471 
472 def _browse_device(device_id: MediaId, device: Device) -> BrowseMediaSource:
473  """Return details for the specified device."""
474  device_info = NestDeviceInfo(device)
475  return BrowseMediaSource(
476  domain=DOMAIN,
477  identifier=device_id.identifier,
478  media_class=MediaClass.DIRECTORY,
479  media_content_type=MediaType.VIDEO,
480  children_media_class=MediaClass.VIDEO,
481  title=DEVICE_TITLE_FORMAT.format(device_name=device_info.device_name),
482  can_play=False,
483  can_expand=True,
484  thumbnail=None,
485  children=[],
486  )
487 
488 
490  event_id: MediaId, device: Device, event: ClipPreviewSession
491 ) -> BrowseMediaSource:
492  """Build a BrowseMediaSource for a specific clip preview event."""
493  types = [
494  MEDIA_SOURCE_EVENT_TITLE_MAP.get(event_type, "Event")
495  for event_type in event.event_types
496  ]
497  return BrowseMediaSource(
498  domain=DOMAIN,
499  identifier=event_id.identifier,
500  media_class=MediaClass.IMAGE,
501  media_content_type=MediaType.IMAGE,
502  title=CLIP_TITLE_FORMAT.format(
503  event_name=", ".join(types),
504  event_time=dt_util.as_local(event.timestamp).strftime(DATE_STR_FORMAT),
505  ),
506  can_play=True,
507  can_expand=False,
508  thumbnail=EVENT_THUMBNAIL_URL_FORMAT.format(
509  device_id=event_id.device_id, event_token=event_id.event_token
510  ),
511  children=[],
512  )
513 
514 
516  event_id: MediaId, device: Device, event: ImageSession
517 ) -> BrowseMediaSource:
518  """Build a BrowseMediaSource for a specific image event."""
519  return BrowseMediaSource(
520  domain=DOMAIN,
521  identifier=event_id.identifier,
522  media_class=MediaClass.IMAGE,
523  media_content_type=MediaType.IMAGE,
524  title=CLIP_TITLE_FORMAT.format(
525  event_name=MEDIA_SOURCE_EVENT_TITLE_MAP.get(event.event_type, "Event"),
526  event_time=dt_util.as_local(event.timestamp).strftime(DATE_STR_FORMAT),
527  ),
528  can_play=False,
529  can_expand=False,
530  thumbnail=EVENT_THUMBNAIL_URL_FORMAT.format(
531  device_id=event_id.device_id, event_token=event_id.event_token
532  ),
533  children=[],
534  )
str get_media_key(self, str device_id, ImageEventBase event)
str get_clip_preview_media_key(self, str device_id, ImageEventBase event)
None async_save_media(self, str media_key, bytes content)
str get_image_media_key(self, str device_id, ImageEventBase event)
str get_clip_preview_thumbnail_media_key(self, str device_id, ImageEventBase event)
None __init__(self, HomeAssistant hass, GoogleNestSubscriber subscriber, Store[dict[str, Any]] store, str media_path)
PlayMedia async_resolve_media(self, MediaSourceItem item)
BrowseMediaSource async_browse_media(self, MediaSourceItem item)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
FFmpegManager get_ffmpeg_manager(HomeAssistant hass)
Definition: __init__.py:106
Mapping[str, Device] async_nest_devices_by_device_id(HomeAssistant hass)
Definition: device_info.py:95
MediaSource async_get_media_source(HomeAssistant hass)
Transcoder async_get_transcoder(HomeAssistant hass)
Definition: media_source.py:90
BrowseMediaSource _browse_device(MediaId device_id, Device device)
EventMediaStore async_get_media_event_store(HomeAssistant hass, GoogleNestSubscriber subscriber)
Definition: media_source.py:78
Mapping[str, Device] async_get_media_source_devices(HomeAssistant hass)
dict[str, ImageSession] _async_get_image_sessions(Device device)
BrowseMediaSource _browse_clip_preview(MediaId event_id, Device device, ClipPreviewSession event)
BrowseMediaSource _browse_image_event(MediaId event_id, Device device, ImageSession event)
dict[str, ClipPreviewSession] _async_get_clip_preview_sessions(Device device)
MediaId|None _async_get_recent_event_id(MediaId device_id, Device device)
MediaId|None parse_media_id(str|None identifier=None)
None open(self, **Any kwargs)
Definition: lock.py:86
None async_delay_save(self, Callable[[], _T] data_func, float delay=0)
Definition: storage.py:444