core/homeassistant/components/nest/__init__.py

330 lines
11 KiB
Python

"""Support for Nest devices."""
from __future__ import annotations
from http import HTTPStatus
import logging
from aiohttp import web
from google_nest_sdm.event import EventMessage
from google_nest_sdm.exceptions import (
AuthException,
ConfigurationException,
GoogleNestException,
)
import voluptuous as vol
from homeassistant.auth.permissions.const import POLICY_READ
from homeassistant.components.http.const 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,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
Unauthorized,
)
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.entity_registry import async_entries_for_device
from homeassistant.helpers.typing import ConfigType
from . import api, config_flow
from .const import (
CONF_PROJECT_ID,
CONF_SUBSCRIBER_ID,
DATA_NEST_CONFIG,
DATA_SDM,
DATA_SUBSCRIBER,
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
OOB_REDIRECT_URI,
)
from .events import EVENT_NAME_MAP, NEST_EVENT
from .legacy import async_setup_legacy, async_setup_legacy_entry
from .media_source import get_media_source_devices
_LOGGER = logging.getLogger(__name__)
DATA_NEST_UNAVAILABLE = "nest_unavailable"
NEST_SETUP_NOTIFICATION = "nest_setup"
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 = ["sensor", "camera", "climate"]
WEB_AUTH_DOMAIN = DOMAIN
INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed"
class WebAuth(config_entry_oauth2_flow.LocalOAuth2Implementation):
"""OAuth implementation using OAuth for web applications."""
name = "OAuth for Web"
def __init__(
self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str
) -> None:
"""Initialize WebAuth."""
super().__init__(
hass,
WEB_AUTH_DOMAIN,
client_id,
client_secret,
OAUTH2_AUTHORIZE.format(project_id=project_id),
OAUTH2_TOKEN,
)
class InstalledAppAuth(config_entry_oauth2_flow.LocalOAuth2Implementation):
"""OAuth implementation using OAuth for installed applications."""
name = "OAuth for Apps"
def __init__(
self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str
) -> None:
"""Initialize InstalledAppAuth."""
super().__init__(
hass,
INSTALLED_AUTH_DOMAIN,
client_id,
client_secret,
OAUTH2_AUTHORIZE.format(project_id=project_id),
OAUTH2_TOKEN,
)
@property
def redirect_uri(self) -> str:
"""Return the redirect uri."""
return OOB_REDIRECT_URI
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Nest components with dispatch between old/new flows."""
hass.data[DOMAIN] = {}
if DOMAIN not in config:
return True
if CONF_PROJECT_ID not in config[DOMAIN]:
return await async_setup_legacy(hass, config)
# For setup of ConfigEntry below
hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN]
project_id = config[DOMAIN][CONF_PROJECT_ID]
config_flow.NestFlowHandler.register_sdm_api(hass)
config_flow.NestFlowHandler.async_register_implementation(
hass,
InstalledAppAuth(
hass,
config[DOMAIN][CONF_CLIENT_ID],
config[DOMAIN][CONF_CLIENT_SECRET],
project_id,
),
)
config_flow.NestFlowHandler.async_register_implementation(
hass,
WebAuth(
hass,
config[DOMAIN][CONF_CLIENT_ID],
config[DOMAIN][CONF_CLIENT_SECRET],
project_id,
),
)
return True
class SignalUpdateCallback:
"""An EventCallback invoked when new events arrive from subscriber."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize EventCallback."""
self._hass = hass
async def async_handle_event(self, event_message: EventMessage) -> None:
"""Process an incoming EventMessage."""
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 = await self._hass.helpers.device_registry.async_get_registry()
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_id,
}
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."""
if DATA_SDM not in entry.data:
return await async_setup_legacy_entry(hass, entry)
subscriber = await api.new_subscriber(hass, entry)
if not subscriber:
return False
callback = SignalUpdateCallback(hass)
subscriber.set_update_callback(callback.async_handle_event)
try:
await subscriber.start_async()
except AuthException as err:
_LOGGER.debug("Subscriber authentication error: %s", err)
raise ConfigEntryAuthFailed from err
except ConfigurationException as err:
_LOGGER.error("Configuration error: %s", err)
subscriber.stop_async()
return False
except GoogleNestException as err:
if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]:
_LOGGER.error("Subscriber error: %s", err)
hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True
subscriber.stop_async()
raise ConfigEntryNotReady from err
try:
await subscriber.async_get_device_manager()
except GoogleNestException as err:
if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]:
_LOGGER.error("Device manager error: %s", err)
hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True
subscriber.stop_async()
raise ConfigEntryNotReady from err
hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None)
hass.data[DOMAIN][DATA_SUBSCRIBER] = subscriber
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
hass.http.register_view(NestEventMediaView(hass))
return True
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][DATA_SUBSCRIBER]
subscriber.stop_async()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(DATA_SUBSCRIBER)
hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None)
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:
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 GoogleNestException 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 NestEventMediaView(HomeAssistantView):
"""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_id}"
name = "api:nest:event_media"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize NestEventMediaView."""
self.hass = hass
async def get(
self, request: web.Request, device_id: str, event_id: str
) -> web.StreamResponse:
"""Start a GET request."""
user = request[KEY_HASS_USER]
entity_registry = await self.hass.helpers.entity_registry.async_get_registry()
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 = await 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:
event_media = await nest_device.event_media_manager.get_media(event_id)
except GoogleNestException as err:
raise HomeAssistantError("Unable to fetch media for event") from err
if not event_media:
return self._json_error(
f"No event found for event_id '{event_id}'", HTTPStatus.NOT_FOUND
)
media = event_media.media
return web.Response(
body=media.contents, content_type=media.event_image_type.content_type
)
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)