1 """Nest Media Source implementation.
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.
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.
13 Users may also record clips to local storage, unrelated to this MediaSource.
15 For additional background on Nest Camera events see:
16 https://developers.google.com/nest/device-access/api/camera#handle_camera_events
19 from __future__
import annotations
21 from collections.abc
import Mapping
22 from dataclasses
import dataclass
25 from typing
import Any
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 (
35 from google_nest_sdm.google_nest_subscriber
import GoogleNestSubscriber
36 from google_nest_sdm.transcoder
import Transcoder
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
57 _LOGGER = logging.getLogger(__name__)
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"
65 STORAGE_KEY =
"nest.event_media"
68 STORAGE_SAVE_DELAY_SECONDS = 120
70 MEDIA_PATH = f
"{DOMAIN}/event_media"
73 DISK_READ_LRU_MAX_SIZE = 32
77 hass: HomeAssistant, subscriber: GoogleNestSubscriber
79 """Create the disk backed EventMediaStore."""
80 media_path = hass.config.path(MEDIA_PATH)
83 os.makedirs(media_path, exist_ok=
True)
85 await hass.async_add_executor_job(mkdir)
86 store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY, private=
True)
91 """Get a nest clip transcoder."""
92 media_path = hass.config.path(MEDIA_PATH)
94 return Transcoder(ffmpeg_manager.binary, media_path)
98 """Storage hook to locally persist nest media for events.
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.
104 The default implementation in nest is in memory, and this allows the data
105 to be backed by disk.
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.
115 subscriber: GoogleNestSubscriber,
116 store: Store[dict[str, Any]],
119 """Initialize NestEventMediaStore."""
124 self.
_data_data: dict[str, Any] |
None =
None
125 self.
_devices_devices: Mapping[str, str] |
None = {}
129 if self.
_data_data
is None:
132 _LOGGER.debug(
"Loaded empty event store")
135 _LOGGER.debug(
"Loaded event store with %d records", len(data))
136 self.
_data_data = data
137 return self.
_data_data
141 self.
_data_data = data
143 def provide_data() -> dict:
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")
156 self.
_devices_devices.
get(device_id, f
"{device_id}-unknown_device")
158 else "unknown_device"
162 """Return the filename for image media for an event."""
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"
169 """Return the filename for clip preview media for an event session."""
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"
176 self, device_id: str, event: ImageEventBase
178 """Return the filename for clip preview thumbnail media for an event session."""
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"
185 """Return the filename in storage for a media key."""
186 return f
"{self._media_path}/{media_key}"
189 """Load media content."""
192 def load_media(filename: str) -> bytes |
None:
193 if not os.path.exists(filename):
195 _LOGGER.debug(
"Reading event media from disk store: %s", filename)
196 with open(filename,
"rb")
as media:
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)
206 """Write media content."""
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):
213 "Event media already exists, not overwriting: %s", filename
216 _LOGGER.debug(
"Saving event media to disk store: %s", filename)
217 with open(filename,
"wb")
as media:
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)
226 """Remove media content."""
229 def remove_media(filename: str) ->
None:
230 if not os.path.exists(filename):
232 _LOGGER.debug(
"Removing event media from disk store: %s", filename)
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)
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()
245 for device
in device_manager.devices.values():
246 if device_entry := device_registry.async_get_device(
247 identifiers={(DOMAIN, device.name)}
249 devices[device.name] = device_entry.id
254 """Set up Nest media source."""
260 """Return a mapping of device id to eligible Nest event media devices."""
264 for device_id, device
in devices.items()
265 if CameraEventImageTrait.NAME
in device.traits
266 or CameraClipPreviewTrait.NAME
in device.traits
272 """Media identifier for a node in the Media Browse tree.
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).
279 event_token: str |
None =
None
283 """Media identifier represented as a string."""
285 return f
"{self.device_id}/{self.event_token}"
286 return self.device_id
290 """Parse the identifier path string into a MediaId."""
291 if identifier
is None or identifier ==
"":
293 parts = identifier.split(
"/")
295 return MediaId(parts[0], parts[1])
300 """Provide Nest Media Sources for Nest Cameras.
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.
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.
310 name: str = MEDIA_SOURCE_TITLE
313 """Initialize NestMediaSource."""
318 """Resolve media identifier to a url."""
321 raise Unresolvable(
"No identifier specified for MediaSourceItem")
323 if not (device := devices.get(media_id.device_id)):
325 f
"Unable to find device with identifier: {item.identifier}"
327 if not media_id.event_token:
333 f
"Unable to resolve recent event for device: {item.identifier}"
335 media_id = last_event_id
339 content_type = EventImageType.IMAGE.content_type
340 if CameraClipPreviewTrait.NAME
in device.traits:
341 content_type = EventImageType.CLIP_PREVIEW.content_type
343 EVENT_MEDIA_API_URL_FORMAT.format(
344 device_id=media_id.device_id, event_token=media_id.event_token
350 """Return media for the specified level of the directory tree.
352 The top level is the root that contains devices. Inside each device are
353 media for events for that device.
357 "Browsing media for identifier=%s, media_id=%s", item.identifier, media_id
363 browse_root.children = []
364 for device_id, child_device
in devices.items():
367 MediaId(device_id), child_device
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,
373 browse_device.can_play =
True
374 browse_root.children.append(browse_device)
378 if not (device := devices.get(media_id.device_id)):
380 f
"Unable to find device with identiifer: {item.identifier}"
384 if CameraClipPreviewTrait.NAME
in device.traits:
386 str, ClipPreviewSession
388 if media_id.event_token
is None:
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(
400 if not (single_clip := clips.get(media_id.event_token)):
402 f
"Unable to find event with identiifer: {item.identifier}"
408 if media_id.event_token
is None:
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(
420 if not (single_image := images.get(media_id.event_token)):
422 f
"Unable to find event with identiifer: {item.identifier}"
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}
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}
442 """Return devices in the root."""
446 media_class=MediaClass.DIRECTORY,
447 media_content_type=MediaType.VIDEO,
448 children_media_class=MediaClass.VIDEO,
449 title=MEDIA_SOURCE_TITLE,
458 device_id: MediaId, device: Device
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()
465 return MediaId(device_id.device_id, next(iter(clips)).event_token)
466 images = await device.event_media_manager.async_image_sessions()
469 return MediaId(device_id.device_id, next(iter(images)).event_token)
473 """Return details for the specified device."""
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),
490 event_id: MediaId, device: Device, event: ClipPreviewSession
491 ) -> BrowseMediaSource:
492 """Build a BrowseMediaSource for a specific clip preview event."""
494 MEDIA_SOURCE_EVENT_TITLE_MAP.get(event_type,
"Event")
495 for event_type
in event.event_types
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),
508 thumbnail=EVENT_THUMBNAIL_URL_FORMAT.format(
509 device_id=event_id.device_id, event_token=event_id.event_token
516 event_id: MediaId, device: Device, event: ImageSession
517 ) -> BrowseMediaSource:
518 """Build a BrowseMediaSource for a specific image event."""
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),
530 thumbnail=EVENT_THUMBNAIL_URL_FORMAT.format(
531 device_id=event_id.device_id, event_token=event_id.event_token
web.Response get(self, web.Request request, str config_key)
FFmpegManager get_ffmpeg_manager(HomeAssistant hass)
Mapping[str, Device] async_nest_devices_by_device_id(HomeAssistant hass)
None open(self, **Any kwargs)
None async_delay_save(self, Callable[[], _T] data_func, float delay=0)