458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""Support for Nest devices."""
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
import asyncio
|
|
from collections.abc import Awaitable, Callable
|
|
from http import HTTPStatus
|
|
import logging
|
|
|
|
from aiohttp import web
|
|
from google_nest_sdm.camera_traits import CameraClipPreviewTrait
|
|
from google_nest_sdm.device import Device
|
|
from google_nest_sdm.event import EventMessage
|
|
from google_nest_sdm.event_media import Media
|
|
from google_nest_sdm.exceptions import (
|
|
ApiException,
|
|
AuthException,
|
|
ConfigurationException,
|
|
DecodeException,
|
|
SubscriberException,
|
|
)
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.auth.permissions.const import POLICY_READ
|
|
from homeassistant.components.application_credentials import (
|
|
ClientCredential,
|
|
async_import_client_credential,
|
|
)
|
|
from homeassistant.components.camera import Image, img_util
|
|
from homeassistant.components.http import KEY_HASS_USER
|
|
from homeassistant.components.http.view import HomeAssistantView
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import (
|
|
CONF_BINARY_SENSORS,
|
|
CONF_CLIENT_ID,
|
|
CONF_CLIENT_SECRET,
|
|
CONF_MONITORED_CONDITIONS,
|
|
CONF_SENSORS,
|
|
CONF_STRUCTURE,
|
|
Platform,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import (
|
|
ConfigEntryAuthFailed,
|
|
ConfigEntryNotReady,
|
|
HomeAssistantError,
|
|
Unauthorized,
|
|
)
|
|
from homeassistant.helpers import (
|
|
config_validation as cv,
|
|
device_registry as dr,
|
|
entity_registry as er,
|
|
)
|
|
from homeassistant.helpers.entity_registry import async_entries_for_device
|
|
from homeassistant.helpers.issue_registry import (
|
|
IssueSeverity,
|
|
async_create_issue,
|
|
async_delete_issue,
|
|
)
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
from . import api, config_flow
|
|
from .const import (
|
|
CONF_PROJECT_ID,
|
|
CONF_SUBSCRIBER_ID,
|
|
CONF_SUBSCRIBER_ID_IMPORTED,
|
|
DATA_DEVICE_MANAGER,
|
|
DATA_NEST_CONFIG,
|
|
DATA_SDM,
|
|
DATA_SUBSCRIBER,
|
|
DOMAIN,
|
|
INSTALLED_AUTH_DOMAIN,
|
|
WEB_AUTH_DOMAIN,
|
|
)
|
|
from .events import EVENT_NAME_MAP, NEST_EVENT
|
|
from .legacy import async_setup_legacy, async_setup_legacy_entry
|
|
from .media_source import (
|
|
async_get_media_event_store,
|
|
async_get_media_source_devices,
|
|
async_get_transcoder,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
SENSOR_SCHEMA = vol.Schema(
|
|
{vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)}
|
|
)
|
|
|
|
CONFIG_SCHEMA = vol.Schema(
|
|
{
|
|
DOMAIN: vol.Schema(
|
|
{
|
|
vol.Required(CONF_CLIENT_ID): cv.string,
|
|
vol.Required(CONF_CLIENT_SECRET): cv.string,
|
|
# Required to use the new API (optional for compatibility)
|
|
vol.Optional(CONF_PROJECT_ID): cv.string,
|
|
vol.Optional(CONF_SUBSCRIBER_ID): cv.string,
|
|
# Config that only currently works on the old API
|
|
vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, [cv.string]),
|
|
vol.Optional(CONF_SENSORS): SENSOR_SCHEMA,
|
|
vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA,
|
|
}
|
|
)
|
|
},
|
|
extra=vol.ALLOW_EXTRA,
|
|
)
|
|
|
|
# Platforms for SDM API
|
|
PLATFORMS = [Platform.SENSOR, Platform.CAMERA, Platform.CLIMATE]
|
|
|
|
# Fetch media events with a disk backed cache, with a limit for each camera
|
|
# device. The largest media items are mp4 clips at ~120kb each, and we target
|
|
# ~125MB of storage per camera to try to balance a reasonable user experience
|
|
# for event history not not filling the disk.
|
|
EVENT_MEDIA_CACHE_SIZE = 1024 # number of events
|
|
|
|
THUMBNAIL_SIZE_PX = 175
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
"""Set up Nest components with dispatch between old/new flows."""
|
|
hass.data[DOMAIN] = {}
|
|
|
|
hass.http.register_view(NestEventMediaView(hass))
|
|
hass.http.register_view(NestEventMediaThumbnailView(hass))
|
|
|
|
if DOMAIN not in config:
|
|
return True # ConfigMode.SDM_APPLICATION_CREDENTIALS
|
|
|
|
# Note that configuration.yaml deprecation warnings are handled in the
|
|
# config entry since we don't know what type of credentials we have and
|
|
# whether or not they can be imported.
|
|
hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN]
|
|
|
|
config_mode = config_flow.get_config_mode(hass)
|
|
if config_mode == config_flow.ConfigMode.LEGACY:
|
|
return await async_setup_legacy(hass, config)
|
|
|
|
return True
|
|
|
|
|
|
class SignalUpdateCallback:
|
|
"""An EventCallback invoked when new events arrive from subscriber."""
|
|
|
|
def __init__(
|
|
self, hass: HomeAssistant, config_reload_cb: Callable[[], Awaitable[None]]
|
|
) -> None:
|
|
"""Initialize EventCallback."""
|
|
self._hass = hass
|
|
self._config_reload_cb = config_reload_cb
|
|
|
|
async def async_handle_event(self, event_message: EventMessage) -> None:
|
|
"""Process an incoming EventMessage."""
|
|
if event_message.relation_update:
|
|
_LOGGER.info("Devices or homes have changed; Need reload to take effect")
|
|
return
|
|
if not event_message.resource_update_name:
|
|
return
|
|
device_id = event_message.resource_update_name
|
|
if not (events := event_message.resource_update_events):
|
|
return
|
|
_LOGGER.debug("Event Update %s", events.keys())
|
|
device_registry = dr.async_get(self._hass)
|
|
device_entry = device_registry.async_get_device({(DOMAIN, device_id)})
|
|
if not device_entry:
|
|
return
|
|
for api_event_type, image_event in events.items():
|
|
if not (event_type := EVENT_NAME_MAP.get(api_event_type)):
|
|
continue
|
|
message = {
|
|
"device_id": device_entry.id,
|
|
"type": event_type,
|
|
"timestamp": event_message.timestamp,
|
|
"nest_event_id": image_event.event_token,
|
|
}
|
|
if image_event.zones:
|
|
message["zones"] = image_event.zones
|
|
self._hass.bus.async_fire(NEST_EVENT, message)
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Set up Nest from a config entry with dispatch between old/new flows."""
|
|
config_mode = config_flow.get_config_mode(hass)
|
|
if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY:
|
|
return await async_setup_legacy_entry(hass, entry)
|
|
|
|
if config_mode == config_flow.ConfigMode.SDM:
|
|
await async_import_config(hass, entry)
|
|
elif entry.unique_id != entry.data[CONF_PROJECT_ID]:
|
|
hass.config_entries.async_update_entry(
|
|
entry, unique_id=entry.data[CONF_PROJECT_ID]
|
|
)
|
|
|
|
async_delete_issue(hass, DOMAIN, "removed_app_auth")
|
|
|
|
subscriber = await api.new_subscriber(hass, entry)
|
|
if not subscriber:
|
|
return False
|
|
# Keep media for last N events in memory
|
|
subscriber.cache_policy.event_cache_size = EVENT_MEDIA_CACHE_SIZE
|
|
subscriber.cache_policy.fetch = True
|
|
# Use disk backed event media store
|
|
subscriber.cache_policy.store = await async_get_media_event_store(hass, subscriber)
|
|
subscriber.cache_policy.transcoder = await async_get_transcoder(hass)
|
|
|
|
async def async_config_reload() -> None:
|
|
await hass.config_entries.async_reload(entry.entry_id)
|
|
|
|
callback = SignalUpdateCallback(hass, async_config_reload)
|
|
subscriber.set_update_callback(callback.async_handle_event)
|
|
try:
|
|
await subscriber.start_async()
|
|
except AuthException as err:
|
|
raise ConfigEntryAuthFailed(
|
|
f"Subscriber authentication error: {str(err)}"
|
|
) from err
|
|
except ConfigurationException as err:
|
|
_LOGGER.error("Configuration error: %s", err)
|
|
subscriber.stop_async()
|
|
return False
|
|
except SubscriberException as err:
|
|
subscriber.stop_async()
|
|
raise ConfigEntryNotReady(f"Subscriber error: {str(err)}") from err
|
|
|
|
try:
|
|
device_manager = await subscriber.async_get_device_manager()
|
|
except ApiException as err:
|
|
subscriber.stop_async()
|
|
raise ConfigEntryNotReady(f"Device manager error: {str(err)}") from err
|
|
|
|
hass.data[DOMAIN][entry.entry_id] = {
|
|
DATA_SUBSCRIBER: subscriber,
|
|
DATA_DEVICE_MANAGER: device_manager,
|
|
}
|
|
|
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
|
|
return True
|
|
|
|
|
|
async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Attempt to import configuration.yaml settings."""
|
|
config = hass.data[DOMAIN][DATA_NEST_CONFIG]
|
|
new_data = {
|
|
CONF_PROJECT_ID: config[CONF_PROJECT_ID],
|
|
**entry.data,
|
|
}
|
|
if CONF_SUBSCRIBER_ID not in entry.data:
|
|
if CONF_SUBSCRIBER_ID not in config:
|
|
raise ValueError("Configuration option 'subscriber_id' missing")
|
|
new_data.update(
|
|
{
|
|
CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID],
|
|
# Don't delete user managed subscriber
|
|
CONF_SUBSCRIBER_ID_IMPORTED: True,
|
|
}
|
|
)
|
|
hass.config_entries.async_update_entry(
|
|
entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID]
|
|
)
|
|
|
|
if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN:
|
|
# App Auth credentials have been deprecated and must be re-created
|
|
# by the user in the config flow
|
|
async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
"removed_app_auth",
|
|
is_fixable=False,
|
|
severity=IssueSeverity.ERROR,
|
|
translation_key="removed_app_auth",
|
|
translation_placeholders={
|
|
"more_info_url": (
|
|
"https://www.home-assistant.io/more-info/nest-auth-deprecation"
|
|
),
|
|
"documentation_url": "https://www.home-assistant.io/integrations/nest/",
|
|
},
|
|
)
|
|
raise ConfigEntryAuthFailed(
|
|
"Google has deprecated App Auth credentials, and the integration "
|
|
"must be reconfigured in the UI to restore access to Nest Devices."
|
|
)
|
|
|
|
if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN:
|
|
await async_import_client_credential(
|
|
hass,
|
|
DOMAIN,
|
|
ClientCredential(
|
|
config[CONF_CLIENT_ID],
|
|
config[CONF_CLIENT_SECRET],
|
|
),
|
|
WEB_AUTH_DOMAIN,
|
|
)
|
|
|
|
async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
"deprecated_yaml",
|
|
breaks_in_ha_version="2022.10.0",
|
|
is_fixable=False,
|
|
severity=IssueSeverity.WARNING,
|
|
translation_key="deprecated_yaml",
|
|
)
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
if DATA_SDM not in entry.data:
|
|
# Legacy API
|
|
return True
|
|
_LOGGER.debug("Stopping nest subscriber")
|
|
subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER]
|
|
subscriber.stop_async()
|
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
if unload_ok:
|
|
hass.data[DOMAIN].pop(entry.entry_id)
|
|
|
|
return unload_ok
|
|
|
|
|
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Handle removal of pubsub subscriptions created during config flow."""
|
|
if (
|
|
DATA_SDM not in entry.data
|
|
or CONF_SUBSCRIBER_ID not in entry.data
|
|
or CONF_SUBSCRIBER_ID_IMPORTED in entry.data
|
|
):
|
|
return
|
|
|
|
subscriber = await api.new_subscriber(hass, entry)
|
|
if not subscriber:
|
|
return
|
|
_LOGGER.debug("Deleting subscriber '%s'", subscriber.subscriber_id)
|
|
try:
|
|
await subscriber.delete_subscription()
|
|
except (AuthException, SubscriberException) as err:
|
|
_LOGGER.warning(
|
|
(
|
|
"Unable to delete subscription '%s'; Will be automatically cleaned up"
|
|
" by cloud console: %s"
|
|
),
|
|
subscriber.subscriber_id,
|
|
err,
|
|
)
|
|
finally:
|
|
subscriber.stop_async()
|
|
|
|
|
|
class NestEventViewBase(HomeAssistantView, ABC):
|
|
"""Base class for media event APIs."""
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize NestEventViewBase."""
|
|
self.hass = hass
|
|
|
|
async def get(
|
|
self, request: web.Request, device_id: str, event_token: str
|
|
) -> web.StreamResponse:
|
|
"""Start a GET request."""
|
|
user = request[KEY_HASS_USER]
|
|
entity_registry = er.async_get(self.hass)
|
|
for entry in async_entries_for_device(entity_registry, device_id):
|
|
if not user.permissions.check_entity(entry.entity_id, POLICY_READ):
|
|
raise Unauthorized(entity_id=entry.entity_id)
|
|
|
|
devices = async_get_media_source_devices(self.hass)
|
|
if not (nest_device := devices.get(device_id)):
|
|
return self._json_error(
|
|
f"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND
|
|
)
|
|
try:
|
|
media = await self.load_media(nest_device, event_token)
|
|
except DecodeException:
|
|
return self._json_error(
|
|
f"Event token was invalid '{event_token}'", HTTPStatus.NOT_FOUND
|
|
)
|
|
except ApiException as err:
|
|
raise HomeAssistantError("Unable to fetch media for event") from err
|
|
if not media:
|
|
return self._json_error(
|
|
f"No event found for event_id '{event_token}'", HTTPStatus.NOT_FOUND
|
|
)
|
|
return await self.handle_media(media)
|
|
|
|
@abstractmethod
|
|
async def load_media(self, nest_device: Device, event_token: str) -> Media | None:
|
|
"""Load the specified media."""
|
|
|
|
@abstractmethod
|
|
async def handle_media(self, media: Media) -> web.StreamResponse:
|
|
"""Process the specified media."""
|
|
|
|
def _json_error(self, message: str, status: HTTPStatus) -> web.StreamResponse:
|
|
"""Return a json error message with additional logging."""
|
|
_LOGGER.debug(message)
|
|
return self.json_message(message, status)
|
|
|
|
|
|
class NestEventMediaView(NestEventViewBase):
|
|
"""Returns media for related to events for a specific device.
|
|
|
|
This is primarily used to render media for events for MediaSource. The media type
|
|
depends on the specific device e.g. an image, or a movie clip preview.
|
|
"""
|
|
|
|
url = "/api/nest/event_media/{device_id}/{event_token}"
|
|
name = "api:nest:event_media"
|
|
|
|
async def load_media(self, nest_device: Device, event_token: str) -> Media | None:
|
|
"""Load the specified media."""
|
|
return await nest_device.event_media_manager.get_media_from_token(event_token)
|
|
|
|
async def handle_media(self, media: Media) -> web.StreamResponse:
|
|
"""Process the specified media."""
|
|
return web.Response(body=media.contents, content_type=media.content_type)
|
|
|
|
|
|
class NestEventMediaThumbnailView(NestEventViewBase):
|
|
"""Returns media for related to events for a specific device.
|
|
|
|
This is primarily used to render media for events for MediaSource. The media type
|
|
depends on the specific device e.g. an image, or a movie clip preview.
|
|
|
|
mp4 clips are transcoded and thumbnailed by the SDM transcoder. jpgs are thumbnailed
|
|
from the original in this view.
|
|
"""
|
|
|
|
url = "/api/nest/event_media/{device_id}/{event_token}/thumbnail"
|
|
name = "api:nest:event_media"
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize NestEventMediaThumbnailView."""
|
|
super().__init__(hass)
|
|
self._lock = asyncio.Lock()
|
|
self.hass = hass
|
|
|
|
async def load_media(self, nest_device: Device, event_token: str) -> Media | None:
|
|
"""Load the specified media."""
|
|
if CameraClipPreviewTrait.NAME in nest_device.traits:
|
|
async with self._lock: # Only one transcode subprocess at a time
|
|
return (
|
|
await nest_device.event_media_manager.get_clip_thumbnail_from_token(
|
|
event_token
|
|
)
|
|
)
|
|
return await nest_device.event_media_manager.get_media_from_token(event_token)
|
|
|
|
async def handle_media(self, media: Media) -> web.StreamResponse:
|
|
"""Start a GET request."""
|
|
contents = media.contents
|
|
if (content_type := media.content_type) == "image/jpeg":
|
|
image = Image(media.event_image_type.content_type, contents)
|
|
contents = img_util.scale_jpeg_camera_image(
|
|
image, THUMBNAIL_SIZE_PX, THUMBNAIL_SIZE_PX
|
|
)
|
|
return web.Response(body=contents, content_type=content_type)
|