Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for Nest devices."""
2 
3 from __future__ import annotations
4 
5 from abc import ABC, abstractmethod
6 import asyncio
7 from collections.abc import Awaitable, Callable
8 from http import HTTPStatus
9 import logging
10 
11 from aiohttp import web
12 from google_nest_sdm.camera_traits import CameraClipPreviewTrait
13 from google_nest_sdm.device import Device
14 from google_nest_sdm.event import EventMessage
15 from google_nest_sdm.event_media import Media
16 from google_nest_sdm.exceptions import (
17  ApiException,
18  AuthException,
19  ConfigurationException,
20  DecodeException,
21  SubscriberException,
22 )
23 from google_nest_sdm.traits import TraitType
24 import voluptuous as vol
25 
26 from homeassistant.auth.permissions.const import POLICY_READ
27 from homeassistant.components.camera import Image, img_util
28 from homeassistant.components.http import KEY_HASS_USER
29 from homeassistant.components.http.view import HomeAssistantView
30 from homeassistant.config_entries import ConfigEntry
31 from homeassistant.const import (
32  CONF_BINARY_SENSORS,
33  CONF_CLIENT_ID,
34  CONF_CLIENT_SECRET,
35  CONF_MONITORED_CONDITIONS,
36  CONF_SENSORS,
37  CONF_STRUCTURE,
38  EVENT_HOMEASSISTANT_STOP,
39  Platform,
40 )
41 from homeassistant.core import Event, HomeAssistant, callback
42 from homeassistant.exceptions import (
43  ConfigEntryAuthFailed,
44  ConfigEntryNotReady,
45  HomeAssistantError,
46  Unauthorized,
47 )
48 from homeassistant.helpers import (
49  config_validation as cv,
50  device_registry as dr,
51  entity_registry as er,
52 )
53 from homeassistant.helpers.entity_registry import async_entries_for_device
54 from homeassistant.helpers.typing import ConfigType
55 
56 from . import api
57 from .const import (
58  CONF_PROJECT_ID,
59  CONF_SUBSCRIBER_ID,
60  CONF_SUBSCRIBER_ID_IMPORTED,
61  CONF_SUBSCRIPTION_NAME,
62  DATA_DEVICE_MANAGER,
63  DATA_SDM,
64  DATA_SUBSCRIBER,
65  DOMAIN,
66 )
67 from .events import EVENT_NAME_MAP, NEST_EVENT
68 from .media_source import (
69  EVENT_MEDIA_API_URL_FORMAT,
70  EVENT_THUMBNAIL_URL_FORMAT,
71  async_get_media_event_store,
72  async_get_media_source_devices,
73  async_get_transcoder,
74 )
75 
76 _LOGGER = logging.getLogger(__name__)
77 
78 
79 SENSOR_SCHEMA = vol.Schema(
80  {vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)}
81 )
82 
83 CONFIG_SCHEMA = vol.Schema(
84  {
85  DOMAIN: vol.Schema(
86  {
87  vol.Required(CONF_CLIENT_ID): cv.string,
88  vol.Required(CONF_CLIENT_SECRET): cv.string,
89  # Required to use the new API (optional for compatibility)
90  vol.Optional(CONF_PROJECT_ID): cv.string,
91  vol.Optional(CONF_SUBSCRIBER_ID): cv.string,
92  # Config that only currently works on the old API
93  vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, [cv.string]),
94  vol.Optional(CONF_SENSORS): SENSOR_SCHEMA,
95  vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA,
96  }
97  )
98  },
99  extra=vol.ALLOW_EXTRA,
100 )
101 
102 # Platforms for SDM API
103 PLATFORMS = [Platform.CAMERA, Platform.CLIMATE, Platform.EVENT, Platform.SENSOR]
104 
105 # Fetch media events with a disk backed cache, with a limit for each camera
106 # device. The largest media items are mp4 clips at ~450kb each, and we target
107 # ~125MB of storage per camera to try to balance a reasonable user experience
108 # for event history not not filling the disk.
109 EVENT_MEDIA_CACHE_SIZE = 256 # number of events
110 
111 THUMBNAIL_SIZE_PX = 175
112 
113 
114 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
115  """Set up Nest components with dispatch between old/new flows."""
116  hass.data[DOMAIN] = {}
117 
118  hass.http.register_view(NestEventMediaView(hass))
119  hass.http.register_view(NestEventMediaThumbnailView(hass))
120 
121  return True
122 
123 
125  """An EventCallback invoked when new events arrive from subscriber."""
126 
127  def __init__(
128  self,
129  hass: HomeAssistant,
130  config_reload_cb: Callable[[], Awaitable[None]],
131  config_entry_id: str,
132  ) -> None:
133  """Initialize EventCallback."""
134  self._hass_hass = hass
135  self._config_reload_cb_config_reload_cb = config_reload_cb
136  self._config_entry_id_config_entry_id = config_entry_id
137 
138  async def async_handle_event(self, event_message: EventMessage) -> None:
139  """Process an incoming EventMessage."""
140  if event_message.relation_update:
141  _LOGGER.info("Devices or homes have changed; Need reload to take effect")
142  return
143  if not event_message.resource_update_name:
144  return
145  device_id = event_message.resource_update_name
146  if not (events := event_message.resource_update_events):
147  return
148  _LOGGER.debug("Event Update %s", events.keys())
149  device_registry = dr.async_get(self._hass_hass)
150  device_entry = device_registry.async_get_device(
151  identifiers={(DOMAIN, device_id)}
152  )
153  if not device_entry:
154  return
155  supported_traits = self._supported_traits_supported_traits(device_id)
156  for api_event_type, image_event in events.items():
157  if not (event_type := EVENT_NAME_MAP.get(api_event_type)):
158  continue
159  nest_event_id = image_event.event_token
160  message = {
161  "device_id": device_entry.id,
162  "type": event_type,
163  "timestamp": event_message.timestamp,
164  "nest_event_id": nest_event_id,
165  }
166  if (
167  TraitType.CAMERA_EVENT_IMAGE in supported_traits
168  or TraitType.CAMERA_CLIP_PREVIEW in supported_traits
169  ):
170  attachment = {
171  "image": EVENT_THUMBNAIL_URL_FORMAT.format(
172  device_id=device_entry.id, event_token=image_event.event_token
173  )
174  }
175  if TraitType.CAMERA_CLIP_PREVIEW in supported_traits:
176  attachment["video"] = EVENT_MEDIA_API_URL_FORMAT.format(
177  device_id=device_entry.id, event_token=image_event.event_token
178  )
179  message["attachment"] = attachment
180  if image_event.zones:
181  message["zones"] = image_event.zones
182  self._hass_hass.bus.async_fire(NEST_EVENT, message)
183 
184  def _supported_traits(self, device_id: str) -> list[TraitType]:
185  if not (
186  device_manager := self._hass_hass.data[DOMAIN]
187  .get(self._config_entry_id_config_entry_id, {})
188  .get(DATA_DEVICE_MANAGER)
189  ) or not (device := device_manager.devices.get(device_id)):
190  return []
191  return list(device.traits)
192 
193 
194 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
195  """Set up Nest from a config entry with dispatch between old/new flows."""
196  if DATA_SDM not in entry.data:
197  hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
198  return False
199 
200  if entry.unique_id != entry.data[CONF_PROJECT_ID]:
201  hass.config_entries.async_update_entry(
202  entry, unique_id=entry.data[CONF_PROJECT_ID]
203  )
204 
205  subscriber = await api.new_subscriber(hass, entry)
206  if not subscriber:
207  return False
208  # Keep media for last N events in memory
209  subscriber.cache_policy.event_cache_size = EVENT_MEDIA_CACHE_SIZE
210  subscriber.cache_policy.fetch = True
211  # Use disk backed event media store
212  subscriber.cache_policy.store = await async_get_media_event_store(hass, subscriber)
213  subscriber.cache_policy.transcoder = await async_get_transcoder(hass)
214 
215  async def async_config_reload() -> None:
216  await hass.config_entries.async_reload(entry.entry_id)
217 
218  update_callback = SignalUpdateCallback(hass, async_config_reload, entry.entry_id)
219  subscriber.set_update_callback(update_callback.async_handle_event)
220  try:
221  await subscriber.start_async()
222  except AuthException as err:
223  raise ConfigEntryAuthFailed(
224  f"Subscriber authentication error: {err!s}"
225  ) from err
226  except ConfigurationException as err:
227  _LOGGER.error("Configuration error: %s", err)
228  subscriber.stop_async()
229  return False
230  except SubscriberException as err:
231  subscriber.stop_async()
232  raise ConfigEntryNotReady(f"Subscriber error: {err!s}") from err
233 
234  try:
235  device_manager = await subscriber.async_get_device_manager()
236  except ApiException as err:
237  subscriber.stop_async()
238  raise ConfigEntryNotReady(f"Device manager error: {err!s}") from err
239 
240  @callback
241  def on_hass_stop(_: Event) -> None:
242  """Close connection when hass stops."""
243  subscriber.stop_async()
244 
245  entry.async_on_unload(
246  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
247  )
248 
249  hass.data[DOMAIN][entry.entry_id] = {
250  DATA_SUBSCRIBER: subscriber,
251  DATA_DEVICE_MANAGER: device_manager,
252  }
253 
254  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
255 
256  return True
257 
258 
259 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
260  """Unload a config entry."""
261  if DATA_SDM not in entry.data:
262  # Legacy API
263  return True
264  _LOGGER.debug("Stopping nest subscriber")
265  subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER]
266  subscriber.stop_async()
267  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
268  if unload_ok:
269  hass.data[DOMAIN].pop(entry.entry_id)
270 
271  return unload_ok
272 
273 
274 async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
275  """Handle removal of pubsub subscriptions created during config flow."""
276  if (
277  DATA_SDM not in entry.data
278  or not (
279  CONF_SUBSCRIPTION_NAME in entry.data or CONF_SUBSCRIBER_ID in entry.data
280  )
281  or CONF_SUBSCRIBER_ID_IMPORTED in entry.data
282  ):
283  return
284 
285  subscriber = await api.new_subscriber(hass, entry)
286  if not subscriber:
287  return
288  _LOGGER.debug("Deleting subscriber '%s'", subscriber.subscriber_id)
289  try:
290  await subscriber.delete_subscription()
291  except (AuthException, SubscriberException) as err:
292  _LOGGER.warning(
293  (
294  "Unable to delete subscription '%s'; Will be automatically cleaned up"
295  " by cloud console: %s"
296  ),
297  subscriber.subscriber_id,
298  err,
299  )
300  finally:
301  subscriber.stop_async()
302 
303 
304 class NestEventViewBase(HomeAssistantView, ABC):
305  """Base class for media event APIs."""
306 
307  def __init__(self, hass: HomeAssistant) -> None:
308  """Initialize NestEventViewBase."""
309  self.hasshass = hass
310 
311  async def get(
312  self, request: web.Request, device_id: str, event_token: str
313  ) -> web.StreamResponse:
314  """Start a GET request."""
315  user = request[KEY_HASS_USER]
316  entity_registry = er.async_get(self.hasshass)
317  for entry in async_entries_for_device(entity_registry, device_id):
318  if not user.permissions.check_entity(entry.entity_id, POLICY_READ):
319  raise Unauthorized(entity_id=entry.entity_id)
320 
321  devices = async_get_media_source_devices(self.hasshass)
322  if not (nest_device := devices.get(device_id)):
323  return self._json_error_json_error(
324  f"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND
325  )
326  try:
327  media = await self.load_mediaload_media(nest_device, event_token)
328  except DecodeException:
329  return self._json_error_json_error(
330  f"Event token was invalid '{event_token}'", HTTPStatus.NOT_FOUND
331  )
332  except ApiException as err:
333  raise HomeAssistantError("Unable to fetch media for event") from err
334  if not media:
335  return self._json_error_json_error(
336  f"No event found for event_id '{event_token}'", HTTPStatus.NOT_FOUND
337  )
338  return await self.handle_mediahandle_media(media)
339 
340  @abstractmethod
341  async def load_media(self, nest_device: Device, event_token: str) -> Media | None:
342  """Load the specified media."""
343 
344  @abstractmethod
345  async def handle_media(self, media: Media) -> web.StreamResponse:
346  """Process the specified media."""
347 
348  def _json_error(self, message: str, status: HTTPStatus) -> web.StreamResponse:
349  """Return a json error message with additional logging."""
350  _LOGGER.debug(message)
351  return self.json_message(message, status)
352 
353 
355  """Returns media for related to events for a specific device.
356 
357  This is primarily used to render media for events for MediaSource. The media type
358  depends on the specific device e.g. an image, or a movie clip preview.
359  """
360 
361  url = "/api/nest/event_media/{device_id}/{event_token}"
362  name = "api:nest:event_media"
363 
364  async def load_media(self, nest_device: Device, event_token: str) -> Media | None:
365  """Load the specified media."""
366  return await nest_device.event_media_manager.get_media_from_token(event_token)
367 
368  async def handle_media(self, media: Media) -> web.StreamResponse:
369  """Process the specified media."""
370  return web.Response(body=media.contents, content_type=media.content_type)
371 
372 
374  """Returns media for related to events for a specific device.
375 
376  This is primarily used to render media for events for MediaSource. The media type
377  depends on the specific device e.g. an image, or a movie clip preview.
378 
379  mp4 clips are transcoded and thumbnailed by the SDM transcoder. jpgs are thumbnailed
380  from the original in this view.
381  """
382 
383  url = "/api/nest/event_media/{device_id}/{event_token}/thumbnail"
384  name = "api:nest:event_media"
385 
386  def __init__(self, hass: HomeAssistant) -> None:
387  """Initialize NestEventMediaThumbnailView."""
388  super().__init__(hass)
389  self._lock_lock = asyncio.Lock()
390  self.hasshasshass = hass
391 
392  async def load_media(self, nest_device: Device, event_token: str) -> Media | None:
393  """Load the specified media."""
394  if CameraClipPreviewTrait.NAME in nest_device.traits:
395  async with self._lock_lock: # Only one transcode subprocess at a time
396  return (
397  await nest_device.event_media_manager.get_clip_thumbnail_from_token(
398  event_token
399  )
400  )
401  return await nest_device.event_media_manager.get_media_from_token(event_token)
402 
403  async def handle_media(self, media: Media) -> web.StreamResponse:
404  """Start a GET request."""
405  contents = media.contents
406  if (content_type := media.content_type) == "image/jpeg":
407  image = Image(media.event_image_type.content_type, contents)
408  contents = img_util.scale_jpeg_camera_image(
409  image, THUMBNAIL_SIZE_PX, THUMBNAIL_SIZE_PX
410  )
411  return web.Response(body=contents, content_type=content_type)
web.StreamResponse handle_media(self, Media media)
Definition: __init__.py:403
Media|None load_media(self, Device nest_device, str event_token)
Definition: __init__.py:392
Media|None load_media(self, Device nest_device, str event_token)
Definition: __init__.py:364
web.StreamResponse handle_media(self, Media media)
Definition: __init__.py:368
web.StreamResponse get(self, web.Request request, str device_id, str event_token)
Definition: __init__.py:313
web.StreamResponse handle_media(self, Media media)
Definition: __init__.py:345
None __init__(self, HomeAssistant hass)
Definition: __init__.py:307
Media|None load_media(self, Device nest_device, str event_token)
Definition: __init__.py:341
web.StreamResponse _json_error(self, str message, HTTPStatus status)
Definition: __init__.py:348
list[TraitType] _supported_traits(self, str device_id)
Definition: __init__.py:184
None async_handle_event(self, EventMessage event_message)
Definition: __init__.py:138
None __init__(self, HomeAssistant hass, Callable[[], Awaitable[None]] config_reload_cb, str config_entry_id)
Definition: __init__.py:132
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
Transcoder async_get_transcoder(HomeAssistant hass)
Definition: media_source.py:90
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)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:259
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:114
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:274
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:194
list[RegistryEntry] async_entries_for_device(EntityRegistry registry, str device_id, bool include_disabled_entities=False)