1 """Support for Nest devices."""
3 from __future__
import annotations
5 from abc
import ABC, abstractmethod
7 from collections.abc
import Awaitable, Callable
8 from http
import HTTPStatus
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 (
19 ConfigurationException,
23 from google_nest_sdm.traits
import TraitType
24 import voluptuous
as vol
35 CONF_MONITORED_CONDITIONS,
38 EVENT_HOMEASSISTANT_STOP,
43 ConfigEntryAuthFailed,
49 config_validation
as cv,
50 device_registry
as dr,
51 entity_registry
as er,
60 CONF_SUBSCRIBER_ID_IMPORTED,
61 CONF_SUBSCRIPTION_NAME,
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,
76 _LOGGER = logging.getLogger(__name__)
79 SENSOR_SCHEMA = vol.Schema(
80 {vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)}
83 CONFIG_SCHEMA = vol.Schema(
87 vol.Required(CONF_CLIENT_ID): cv.string,
88 vol.Required(CONF_CLIENT_SECRET): cv.string,
90 vol.Optional(CONF_PROJECT_ID): cv.string,
91 vol.Optional(CONF_SUBSCRIBER_ID): cv.string,
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,
99 extra=vol.ALLOW_EXTRA,
103 PLATFORMS = [Platform.CAMERA, Platform.CLIMATE, Platform.EVENT, Platform.SENSOR]
109 EVENT_MEDIA_CACHE_SIZE = 256
111 THUMBNAIL_SIZE_PX = 175
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] = {}
125 """An EventCallback invoked when new events arrive from subscriber."""
130 config_reload_cb: Callable[[], Awaitable[
None]],
131 config_entry_id: str,
133 """Initialize EventCallback."""
139 """Process an incoming EventMessage."""
140 if event_message.relation_update:
141 _LOGGER.info(
"Devices or homes have changed; Need reload to take effect")
143 if not event_message.resource_update_name:
145 device_id = event_message.resource_update_name
146 if not (events := event_message.resource_update_events):
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)}
156 for api_event_type, image_event
in events.items():
157 if not (event_type := EVENT_NAME_MAP.get(api_event_type)):
159 nest_event_id = image_event.event_token
161 "device_id": device_entry.id,
163 "timestamp": event_message.timestamp,
164 "nest_event_id": nest_event_id,
167 TraitType.CAMERA_EVENT_IMAGE
in supported_traits
168 or TraitType.CAMERA_CLIP_PREVIEW
in supported_traits
171 "image": EVENT_THUMBNAIL_URL_FORMAT.format(
172 device_id=device_entry.id, event_token=image_event.event_token
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
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)
186 device_manager := self.
_hass_hass.data[DOMAIN]
188 .
get(DATA_DEVICE_MANAGER)
189 )
or not (device := device_manager.devices.get(device_id)):
191 return list(device.traits)
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))
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]
205 subscriber = await api.new_subscriber(hass, entry)
209 subscriber.cache_policy.event_cache_size = EVENT_MEDIA_CACHE_SIZE
210 subscriber.cache_policy.fetch =
True
215 async
def async_config_reload() -> None:
216 await hass.config_entries.async_reload(entry.entry_id)
219 subscriber.set_update_callback(update_callback.async_handle_event)
221 await subscriber.start_async()
222 except AuthException
as err:
224 f
"Subscriber authentication error: {err!s}"
226 except ConfigurationException
as err:
227 _LOGGER.error(
"Configuration error: %s", err)
228 subscriber.stop_async()
230 except SubscriberException
as err:
231 subscriber.stop_async()
235 device_manager = await subscriber.async_get_device_manager()
236 except ApiException
as err:
237 subscriber.stop_async()
241 def on_hass_stop(_: Event) ->
None:
242 """Close connection when hass stops."""
243 subscriber.stop_async()
245 entry.async_on_unload(
246 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
249 hass.data[DOMAIN][entry.entry_id] = {
250 DATA_SUBSCRIBER: subscriber,
251 DATA_DEVICE_MANAGER: device_manager,
254 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
260 """Unload a config entry."""
261 if DATA_SDM
not in entry.data:
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)
269 hass.data[DOMAIN].pop(entry.entry_id)
275 """Handle removal of pubsub subscriptions created during config flow."""
277 DATA_SDM
not in entry.data
279 CONF_SUBSCRIPTION_NAME
in entry.data
or CONF_SUBSCRIBER_ID
in entry.data
281 or CONF_SUBSCRIBER_ID_IMPORTED
in entry.data
285 subscriber = await api.new_subscriber(hass, entry)
288 _LOGGER.debug(
"Deleting subscriber '%s'", subscriber.subscriber_id)
290 await subscriber.delete_subscription()
291 except (AuthException, SubscriberException)
as err:
294 "Unable to delete subscription '%s'; Will be automatically cleaned up"
295 " by cloud console: %s"
297 subscriber.subscriber_id,
301 subscriber.stop_async()
305 """Base class for media event APIs."""
308 """Initialize NestEventViewBase."""
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)
318 if not user.permissions.check_entity(entry.entity_id, POLICY_READ):
322 if not (nest_device := devices.get(device_id)):
324 f
"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND
327 media = await self.
load_mediaload_media(nest_device, event_token)
328 except DecodeException:
330 f
"Event token was invalid '{event_token}'", HTTPStatus.NOT_FOUND
332 except ApiException
as err:
336 f
"No event found for event_id '{event_token}'", HTTPStatus.NOT_FOUND
341 async
def load_media(self, nest_device: Device, event_token: str) -> Media |
None:
342 """Load the specified media."""
346 """Process the specified media."""
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)
355 """Returns media for related to events for a specific device.
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.
361 url =
"/api/nest/event_media/{device_id}/{event_token}"
362 name =
"api:nest:event_media"
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)
369 """Process the specified media."""
370 return web.Response(body=media.contents, content_type=media.content_type)
374 """Returns media for related to events for a specific device.
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.
379 mp4 clips are transcoded and thumbnailed by the SDM transcoder. jpgs are thumbnailed
380 from the original in this view.
383 url =
"/api/nest/event_media/{device_id}/{event_token}/thumbnail"
384 name =
"api:nest:event_media"
387 """Initialize NestEventMediaThumbnailView."""
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:
397 await nest_device.event_media_manager.get_clip_thumbnail_from_token(
401 return await nest_device.event_media_manager.get_media_from_token(event_token)
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
411 return web.Response(body=contents, content_type=content_type)
web.StreamResponse get(self, web.Request request, str device_id, str event_token)
web.StreamResponse handle_media(self, Media media)
None __init__(self, HomeAssistant hass)
Media|None load_media(self, Device nest_device, str event_token)
web.StreamResponse _json_error(self, str message, HTTPStatus status)
list[TraitType] _supported_traits(self, str device_id)
None async_handle_event(self, EventMessage event_message)
None __init__(self, HomeAssistant hass, Callable[[], Awaitable[None]] config_reload_cb, str config_entry_id)
web.Response get(self, web.Request request, str config_key)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
bool async_setup(HomeAssistant hass, ConfigType config)
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
list[RegistryEntry] async_entries_for_device(EntityRegistry registry, str device_id, bool include_disabled_entities=False)