From 52b66e88c7c24f5633e6caed368fc5133b7259f0 Mon Sep 17 00:00:00 2001 From: Allen Porter <allen@thebends.org> Date: Wed, 21 Oct 2020 01:17:49 -0700 Subject: [PATCH] Update Nest integration to support Google Nest Device Access (new API) (#41689) Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io> --- CODEOWNERS | 2 +- homeassistant/components/nest/__init__.py | 170 +++++++++++- homeassistant/components/nest/api.py | 35 +++ homeassistant/components/nest/climate.py | 3 +- homeassistant/components/nest/config_flow.py | 72 ++++- homeassistant/components/nest/const.py | 15 ++ homeassistant/components/nest/local_auth.py | 2 +- homeassistant/components/nest/manifest.json | 11 +- homeassistant/components/nest/sensor.py | 217 +-------------- .../components/nest/sensor_legacy.py | 208 +++++++++++++++ homeassistant/components/nest/sensor_sdm.py | 170 ++++++++++++ homeassistant/components/nest/strings.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nest/sensor_sdm_test.py | 248 ++++++++++++++++++ ...fig_flow.py => test_config_flow_legacy.py} | 0 tests/components/nest/test_config_flow_sdm.py | 66 +++++ 17 files changed, 1001 insertions(+), 230 deletions(-) create mode 100644 homeassistant/components/nest/api.py create mode 100644 homeassistant/components/nest/sensor_legacy.py create mode 100644 homeassistant/components/nest/sensor_sdm.py create mode 100644 tests/components/nest/sensor_sdm_test.py rename tests/components/nest/{test_config_flow.py => test_config_flow_legacy.py} (100%) create mode 100644 tests/components/nest/test_config_flow_sdm.py diff --git a/CODEOWNERS b/CODEOWNERS index 91ae8131db8..17138e93769 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -278,7 +278,7 @@ homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM homeassistant/components/nello/* @pschmitt homeassistant/components/ness_alarm/* @nickw444 -homeassistant/components/nest/* @awarecan +homeassistant/components/nest/* @awarecan @allenporter homeassistant/components/netatmo/* @cgtobi homeassistant/components/netdata/* @fabaff homeassistant/components/nexia/* @ryannazaretian @bdraco diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 84390a1a4c3..ae32527bdc4 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -1,13 +1,18 @@ """Support for Nest devices.""" + +import asyncio from datetime import datetime, timedelta import logging import threading +from google_nest_sdm.event import EventCallback, EventMessage +from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from nest import Nest from nest.nest import APIError, AuthorizationError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_CLIENT_ID, @@ -19,25 +24,38 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity -from . import local_auth -from .const import DOMAIN +from . import api, config_flow, local_auth +from .const import ( + API_URL, + DATA_SDM, + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + SIGNAL_NEST_UPDATE, +) _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) +CONF_PROJECT_ID = "project_id" +CONF_SUBSCRIBER_ID = "subscriber_id" + +# Configuration for the legacy nest API SERVICE_CANCEL_ETA = "cancel_eta" SERVICE_SET_ETA = "set_eta" DATA_NEST = "nest" DATA_NEST_CONFIG = "nest_config" -SIGNAL_NEST_UPDATE = "nest_update" - NEST_CONFIG_FILE = "nest.conf" ATTR_ETA = "eta" @@ -61,6 +79,10 @@ CONFIG_SCHEMA = 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, @@ -70,6 +92,10 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = ["sensor"] + +# Services for the legacy API + SET_AWAY_MODE_SCHEMA = vol.Schema( { vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), @@ -94,10 +120,128 @@ CANCEL_ETA_SCHEMA = vol.Schema( ) +async def async_setup(hass: HomeAssistant, config: dict): + """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) + + if CONF_SUBSCRIBER_ID not in config[DOMAIN]: + _LOGGER.error("Configuration option '{CONF_SUBSCRIBER_ID}' required") + return False + + # 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, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + OAUTH2_AUTHORIZE.format(project_id=project_id), + OAUTH2_TOKEN, + ), + ) + + return True + + +class SignalUpdateCallback(EventCallback): + """An EventCallback invoked when new events arrive from subscriber.""" + + def __init__(self, hass: HomeAssistant): + """Initialize EventCallback.""" + self._hass = hass + + def handle_event(self, event_message: EventMessage): + """Process an incoming EventMessage.""" + _LOGGER.debug("Update %s @ %s", event_message.event_id, event_message.timestamp) + traits = event_message.resource_update_traits + if traits: + _LOGGER.debug("Trait update %s", traits.keys()) + events = event_message.resource_update_events + if events: + _LOGGER.debug("Event Update %s", events.keys()) + + if not event_message.resource_update_traits: + # Note: Currently ignoring events like camera motion + return + # This event triggered an update to a device that changed some + # properties which the DeviceManager should already have received. + # Send a signal to refresh state of all listening devices. + dispatcher_send(self._hass, SIGNAL_NEST_UPDATE) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """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) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + config = hass.data[DOMAIN][DATA_NEST_CONFIG] + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), + session, + API_URL, + ) + subscriber = GoogleNestSubscriber( + auth, config[CONF_PROJECT_ID], config[CONF_SUBSCRIBER_ID] + ) + subscriber.set_update_callback(SignalUpdateCallback(hass)) + hass.loop.create_task(subscriber.start_async()) + hass.data[DOMAIN][entry.entry_id] = subscriber + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + if DATA_SDM not in entry.data: + # Legacy API + return True + + subscriber = hass.data[DOMAIN][entry.entry_id] + subscriber.stop_async() + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + def nest_update_event_broker(hass, nest): """ Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. + Used for the legacy nest API. + Runs in its own thread. """ _LOGGER.debug("Listening for nest.update_event") @@ -115,8 +259,8 @@ def nest_update_event_broker(hass, nest): _LOGGER.debug("Stop listening for nest.update_event") -async def async_setup(hass, config): - """Set up Nest components.""" +async def async_setup_legacy(hass, config): + """Set up Nest components using the legacy nest API.""" if DOMAIN not in config: return True @@ -141,14 +285,14 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): - """Set up Nest from a config entry.""" +async def async_setup_legacy_entry(hass, entry): + """Set up Nest from legacy config entry.""" nest = Nest(access_token=entry.data["tokens"]["access_token"]) _LOGGER.debug("proceeding with setup") conf = hass.data.get(DATA_NEST_CONFIG, {}) - hass.data[DATA_NEST] = NestDevice(hass, conf, nest) + hass.data[DATA_NEST] = NestLegacyDevice(hass, conf, nest) if not await hass.async_add_executor_job(hass.data[DATA_NEST].initialize): return False @@ -275,8 +419,8 @@ async def async_setup_entry(hass, entry): return True -class NestDevice: - """Structure Nest functions for hass.""" +class NestLegacyDevice: + """Structure Nest functions for hass for legacy API.""" def __init__(self, hass, conf, nest): """Init Nest Devices.""" diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py new file mode 100644 index 00000000000..46138469d6d --- /dev/null +++ b/homeassistant/components/nest/api.py @@ -0,0 +1,35 @@ +"""API for Google Nest Device Access bound to Home Assistant OAuth.""" + +from aiohttp import ClientSession +from google.oauth2.credentials import Credentials +from google_nest_sdm.auth import AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + +# See https://developers.google.com/nest/device-access/registration + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Google Nest Device Access authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + api_url: str, + ): + """Initialize Google Nest Device Access auth.""" + super().__init__(websession, api_url) + self._oauth_session = oauth_session + + async def async_get_access_token(self): + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token["access_token"] + + async def async_get_creds(self): + """Return a minimal OAuth credential.""" + token = await self.async_get_access_token() + return Credentials(token=token) diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 7fdc1f7ac12..7571845e66e 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -33,7 +33,8 @@ from homeassistant.const import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_NEST, DOMAIN as NEST_DOMAIN, SIGNAL_NEST_UPDATE +from . import DATA_NEST, DOMAIN as NEST_DOMAIN +from .const import SIGNAL_NEST_UPDATE _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 87612f30064..aa4b7af7e12 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -1,8 +1,22 @@ -"""Config flow to configure Nest.""" +"""Config flow to configure Nest. + +This configuration flow supports two APIs: + - The new Device Access program and the Smart Device Management API + - The legacy nest API + +NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with +some overrides to support the old APIs auth flow. That is, for the new +API this class has hardly any special config other than url parameters, +and everything else custom is for the old api. When configured with the +new api via NestFlowHandler.register_sdm_api, the custom methods just +invoke the AbstractOAuth2FlowHandler methods. +""" + import asyncio from collections import OrderedDict import logging import os +from typing import Dict import async_timeout import voluptuous as vol @@ -10,9 +24,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.json import load_json -from .const import DOMAIN +from .const import DATA_SDM, DOMAIN, SDM_SCOPES DATA_FLOW_IMPL = "nest_flow_implementation" _LOGGER = logging.getLogger(__name__) @@ -20,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) @callback def register_flow_implementation(hass, domain, name, gen_authorize_url, convert_code): - """Register a flow implementation. + """Register a flow implementation for legacy api. domain: Domain of the component responsible for the implementation. name: Name of the component. @@ -47,22 +62,57 @@ class CodeInvalid(NestAuthError): @config_entries.HANDLERS.register(DOMAIN) -class NestFlowHandler(config_entries.ConfigFlow): - """Handle a Nest config flow.""" +class NestFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle authentication for both APIs.""" + DOMAIN = DOMAIN VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - def __init__(self): - """Initialize the Nest config flow.""" - self.flow_impl = None + @classmethod + def register_sdm_api(cls, hass): + """Configure the flow handler to use the SDM API.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_SDM] = {} + + def is_sdm_api(self): + """Return true if this flow is setup to use SDM API.""" + return DOMAIN in self.hass.data and DATA_SDM in self.hass.data[DOMAIN] + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> Dict[str, str]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(SDM_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict) -> dict: + """Create an entry for the SDM flow.""" + data[DATA_SDM] = {} + return await super().async_oauth_create_entry(data) async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" + if self.is_sdm_api(): + return await super().async_step_user(user_input) return await self.async_step_init(user_input) async def async_step_init(self, user_input=None): """Handle a flow start.""" + if self.is_sdm_api(): + return None + flows = self.hass.data.get(DATA_FLOW_IMPL, {}) if self.hass.config_entries.async_entries(DOMAIN): @@ -91,6 +141,9 @@ class NestFlowHandler(config_entries.ConfigFlow): implementation type we expect a pin or an external component to deliver the authentication code. """ + if self.is_sdm_api(): + return None + flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] errors = {} @@ -131,6 +184,9 @@ class NestFlowHandler(config_entries.ConfigFlow): async def async_step_import(self, info): """Import existing auth from Nest.""" + if self.is_sdm_api(): + return None + if self.hass.config_entries.async_entries(DOMAIN): return self.async_abort(reason="single_instance_allowed") diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index fab77c6b5a6..b418df97bba 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -1,2 +1,17 @@ """Constants used by the Nest component.""" + DOMAIN = "nest" +DATA_SDM = "sdm" + +SIGNAL_NEST_UPDATE = "nest_update" + +# For the Google Nest Device Access API +OAUTH2_AUTHORIZE = ( + "https://nestservices.google.com/partnerconnections/{project_id}/auth" +) +OAUTH2_TOKEN = "https://www.googleapis.com/oauth2/v4/token" +SDM_SCOPES = [ + "https://www.googleapis.com/auth/sdm.service", + "https://www.googleapis.com/auth/pubsub", +] +API_URL = "https://smartdevicemanagement.googleapis.com/v1" diff --git a/homeassistant/components/nest/local_auth.py b/homeassistant/components/nest/local_auth.py index f54da8039ff..8be2693325e 100644 --- a/homeassistant/components/nest/local_auth.py +++ b/homeassistant/components/nest/local_auth.py @@ -1,4 +1,4 @@ -"""Local Nest authentication.""" +"""Local Nest authentication for the legacy api.""" import asyncio from functools import partial diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 610e80d9a6a..0686b5add1d 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -2,7 +2,14 @@ "domain": "nest", "name": "Nest", "config_flow": true, + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0"], - "codeowners": ["@awarecan"] + "requirements": [ + "python-nest==4.1.0", + "google-nest-sdm==0.1.6" + ], + "codeowners": [ + "@awarecan", + "@allenporter" + ] } diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index 8c4418e7536..b3df3dd393e 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -1,208 +1,17 @@ -"""Support for Nest Thermostat sensors.""" -import logging +"""Support for Nest sensors that dispatches between API versions.""" -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - STATE_OFF, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType -from . import CONF_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice +from .const import DATA_SDM +from .sensor_legacy import async_setup_legacy_entry +from .sensor_sdm import async_setup_sdm_entry -SENSOR_TYPES = ["humidity", "operation_mode", "hvac_state"] -TEMP_SENSOR_TYPES = ["temperature", "target"] - -PROTECT_SENSOR_TYPES = [ - "co_status", - "smoke_status", - "battery_health", - # color_status: "gray", "green", "yellow", "red" - "color_status", -] - -STRUCTURE_SENSOR_TYPES = ["eta"] - -STATE_HEAT = "heat" -STATE_COOL = "cool" - -# security_state is structure level sensor, but only meaningful when -# Nest Cam exist -STRUCTURE_CAMERA_SENSOR_TYPES = ["security_state"] - -_VALID_SENSOR_TYPES = ( - SENSOR_TYPES - + TEMP_SENSOR_TYPES - + PROTECT_SENSOR_TYPES - + STRUCTURE_SENSOR_TYPES - + STRUCTURE_CAMERA_SENSOR_TYPES -) - -SENSOR_UNITS = {"humidity": PERCENTAGE} - -SENSOR_DEVICE_CLASSES = {"humidity": DEVICE_CLASS_HUMIDITY} - -VARIABLE_NAME_MAPPING = {"eta": "eta_begin", "operation_mode": "mode"} - -VALUE_MAPPING = { - "hvac_state": {"heating": STATE_HEAT, "cooling": STATE_COOL, "off": STATE_OFF} -} - -SENSOR_TYPES_DEPRECATED = ["last_ip", "local_ip", "last_connection", "battery_level"] - -DEPRECATED_WEATHER_VARS = [ - "weather_humidity", - "weather_temperature", - "weather_condition", - "wind_speed", - "wind_direction", -] - -_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED + DEPRECATED_WEATHER_VARS - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Nest Sensor. - - No longer used. - """ - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up a Nest sensor based on a config entry.""" - nest = hass.data[DATA_NEST] - - discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_SENSORS, {}) - - # Add all available sensors if no Nest sensor config is set - if discovery_info == {}: - conditions = _VALID_SENSOR_TYPES - else: - conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) - - for variable in conditions: - if variable in _SENSOR_TYPES_DEPRECATED: - if variable in DEPRECATED_WEATHER_VARS: - wstr = ( - "Nest no longer provides weather data like %s. See " - "https://www.home-assistant.io/integrations/#weather " - "for a list of other weather integrations to use." % variable - ) - else: - wstr = ( - f"{variable} is no a longer supported " - "monitored_conditions. See " - "https://www.home-assistant.io/integrations/" - "binary_sensor.nest/ for valid options." - ) - _LOGGER.error(wstr) - - def get_sensors(): - """Get the Nest sensors.""" - all_sensors = [] - for structure in nest.structures(): - all_sensors += [ - NestBasicSensor(structure, None, variable) - for variable in conditions - if variable in STRUCTURE_SENSOR_TYPES - ] - - for structure, device in nest.thermostats(): - all_sensors += [ - NestBasicSensor(structure, device, variable) - for variable in conditions - if variable in SENSOR_TYPES - ] - all_sensors += [ - NestTempSensor(structure, device, variable) - for variable in conditions - if variable in TEMP_SENSOR_TYPES - ] - - for structure, device in nest.smoke_co_alarms(): - all_sensors += [ - NestBasicSensor(structure, device, variable) - for variable in conditions - if variable in PROTECT_SENSOR_TYPES - ] - - structures_has_camera = {} - for structure, device in nest.cameras(): - structures_has_camera[structure] = True - for structure in structures_has_camera: - all_sensors += [ - NestBasicSensor(structure, None, variable) - for variable in conditions - if variable in STRUCTURE_CAMERA_SENSOR_TYPES - ] - - return all_sensors - - async_add_entities(await hass.async_add_executor_job(get_sensors), True) - - -class NestBasicSensor(NestSensorDevice): - """Representation a basic Nest sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_DEVICE_CLASSES.get(self.variable) - - def update(self): - """Retrieve latest state.""" - self._unit = SENSOR_UNITS.get(self.variable) - - if self.variable in VARIABLE_NAME_MAPPING: - self._state = getattr(self.device, VARIABLE_NAME_MAPPING[self.variable]) - elif self.variable in VALUE_MAPPING: - state = getattr(self.device, self.variable) - self._state = VALUE_MAPPING[self.variable].get(state, state) - elif self.variable in PROTECT_SENSOR_TYPES and self.variable != "color_status": - # keep backward compatibility - state = getattr(self.device, self.variable) - self._state = state.capitalize() if state is not None else None - else: - self._state = getattr(self.device, self.variable) - - -class NestTempSensor(NestSensorDevice): - """Representation of a Nest Temperature sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class of the sensor.""" - return DEVICE_CLASS_TEMPERATURE - - def update(self): - """Retrieve latest state.""" - if self.device.temperature_scale == "C": - self._unit = TEMP_CELSIUS - else: - self._unit = TEMP_FAHRENHEIT - - temp = getattr(self.device, self.variable) - if temp is None: - self._state = None - - if isinstance(temp, tuple): - low, high = temp - self._state = f"{int(low)}-{int(high)}" - else: - self._state = round(temp, 1) +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the sensors.""" + if DATA_SDM not in entry.data: + return await async_setup_legacy_entry(hass, entry, async_add_entities) + return await async_setup_sdm_entry(hass, entry, async_add_entities) diff --git a/homeassistant/components/nest/sensor_legacy.py b/homeassistant/components/nest/sensor_legacy.py new file mode 100644 index 00000000000..2df668513e1 --- /dev/null +++ b/homeassistant/components/nest/sensor_legacy.py @@ -0,0 +1,208 @@ +"""Support for Nest Thermostat sensors for the legacy API.""" +import logging + +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from . import CONF_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice + +SENSOR_TYPES = ["humidity", "operation_mode", "hvac_state"] + +TEMP_SENSOR_TYPES = ["temperature", "target"] + +PROTECT_SENSOR_TYPES = [ + "co_status", + "smoke_status", + "battery_health", + # color_status: "gray", "green", "yellow", "red" + "color_status", +] + +STRUCTURE_SENSOR_TYPES = ["eta"] + +STATE_HEAT = "heat" +STATE_COOL = "cool" + +# security_state is structure level sensor, but only meaningful when +# Nest Cam exist +STRUCTURE_CAMERA_SENSOR_TYPES = ["security_state"] + +_VALID_SENSOR_TYPES = ( + SENSOR_TYPES + + TEMP_SENSOR_TYPES + + PROTECT_SENSOR_TYPES + + STRUCTURE_SENSOR_TYPES + + STRUCTURE_CAMERA_SENSOR_TYPES +) + +SENSOR_UNITS = {"humidity": PERCENTAGE} + +SENSOR_DEVICE_CLASSES = {"humidity": DEVICE_CLASS_HUMIDITY} + +VARIABLE_NAME_MAPPING = {"eta": "eta_begin", "operation_mode": "mode"} + +VALUE_MAPPING = { + "hvac_state": {"heating": STATE_HEAT, "cooling": STATE_COOL, "off": STATE_OFF} +} + +SENSOR_TYPES_DEPRECATED = ["last_ip", "local_ip", "last_connection", "battery_level"] + +DEPRECATED_WEATHER_VARS = [ + "weather_humidity", + "weather_temperature", + "weather_condition", + "wind_speed", + "wind_direction", +] + +_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED + DEPRECATED_WEATHER_VARS + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Nest Sensor. + + No longer used. + """ + + +async def async_setup_legacy_entry(hass, entry, async_add_entities): + """Set up a Nest sensor based on a config entry.""" + nest = hass.data[DATA_NEST] + + discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_SENSORS, {}) + + # Add all available sensors if no Nest sensor config is set + if discovery_info == {}: + conditions = _VALID_SENSOR_TYPES + else: + conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) + + for variable in conditions: + if variable in _SENSOR_TYPES_DEPRECATED: + if variable in DEPRECATED_WEATHER_VARS: + wstr = ( + "Nest no longer provides weather data like %s. See " + "https://www.home-assistant.io/integrations/#weather " + "for a list of other weather integrations to use." % variable + ) + else: + wstr = ( + f"{variable} is no a longer supported " + "monitored_conditions. See " + "https://www.home-assistant.io/integrations/" + "binary_sensor.nest/ for valid options." + ) + _LOGGER.error(wstr) + + def get_sensors(): + """Get the Nest sensors.""" + all_sensors = [] + for structure in nest.structures(): + all_sensors += [ + NestBasicSensor(structure, None, variable) + for variable in conditions + if variable in STRUCTURE_SENSOR_TYPES + ] + + for structure, device in nest.thermostats(): + all_sensors += [ + NestBasicSensor(structure, device, variable) + for variable in conditions + if variable in SENSOR_TYPES + ] + all_sensors += [ + NestTempSensor(structure, device, variable) + for variable in conditions + if variable in TEMP_SENSOR_TYPES + ] + + for structure, device in nest.smoke_co_alarms(): + all_sensors += [ + NestBasicSensor(structure, device, variable) + for variable in conditions + if variable in PROTECT_SENSOR_TYPES + ] + + structures_has_camera = {} + for structure, device in nest.cameras(): + structures_has_camera[structure] = True + for structure in structures_has_camera: + all_sensors += [ + NestBasicSensor(structure, None, variable) + for variable in conditions + if variable in STRUCTURE_CAMERA_SENSOR_TYPES + ] + + return all_sensors + + async_add_entities(await hass.async_add_executor_job(get_sensors), True) + + +class NestBasicSensor(NestSensorDevice): + """Representation a basic Nest sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_DEVICE_CLASSES.get(self.variable) + + def update(self): + """Retrieve latest state.""" + self._unit = SENSOR_UNITS.get(self.variable) + + if self.variable in VARIABLE_NAME_MAPPING: + self._state = getattr(self.device, VARIABLE_NAME_MAPPING[self.variable]) + elif self.variable in VALUE_MAPPING: + state = getattr(self.device, self.variable) + self._state = VALUE_MAPPING[self.variable].get(state, state) + elif self.variable in PROTECT_SENSOR_TYPES and self.variable != "color_status": + # keep backward compatibility + state = getattr(self.device, self.variable) + self._state = state.capitalize() if state is not None else None + else: + self._state = getattr(self.device, self.variable) + + +class NestTempSensor(NestSensorDevice): + """Representation of a Nest Temperature sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE + + def update(self): + """Retrieve latest state.""" + if self.device.temperature_scale == "C": + self._unit = TEMP_CELSIUS + else: + self._unit = TEMP_FAHRENHEIT + + temp = getattr(self.device, self.variable) + if temp is None: + self._state = None + + if isinstance(temp, tuple): + low, high = temp + self._state = f"{int(low)}-{int(high)}" + else: + self._state = round(temp, 1) diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py new file mode 100644 index 00000000000..8c567c9b36e --- /dev/null +++ b/homeassistant/components/nest/sensor_sdm.py @@ -0,0 +1,170 @@ +"""Support for Google Nest SDM sensors.""" + +from typing import Optional + +from google_nest_sdm.device import Device +from google_nest_sdm.device_traits import HumidityTrait, InfoTrait, TemperatureTrait + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + TEMP_CELSIUS, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, SIGNAL_NEST_UPDATE + + +async def async_setup_sdm_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the sensors.""" + + subscriber = hass.data[DOMAIN][entry.entry_id] + device_manager = await subscriber.async_device_manager + + # Fetch initial data so we have data when entities subscribe. + + entities = [] + for device in device_manager.devices.values(): + if TemperatureTrait.NAME in device.traits: + entities.append(TemperatureSensor(device)) + if HumidityTrait.NAME in device.traits: + entities.append(HumiditySensor(device)) + async_add_entities(entities) + + +class SensorBase(Entity): + """Representation of a dynamically updated Sensor.""" + + def __init__(self, device: Device): + """Initialize the sensor.""" + self._device = device + + @property + def should_pool(self) -> bool: + """Disable polling since entities have state pushed via pubsub.""" + return False + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + # The API "name" field is a unique device identifier. + return f"{self._device.name}-{self.device_class}" + + @property + def device_name(self): + """Return the name of the physical device that includes the sensor.""" + if InfoTrait.NAME in self._device.traits: + trait = self._device.traits[InfoTrait.NAME] + if trait.custom_name: + return trait.custom_name + # Build a name from the room/structure. Note: This room/structure name + # is not associated with a home assistant Area. + parent_relations = self._device.parent_relations + if parent_relations: + items = sorted(parent_relations.items()) + names = [name for id, name in items] + return " ".join(names) + return self.unique_id + + @property + def device_info(self): + """Return device specific attributes.""" + return { + # The API "name" field is a unique device identifier. + "identifiers": {(DOMAIN, self._device.name)}, + "name": self.device_name, + "manufacturer": "Google Nest", + "model": self.device_model, + } + + @property + def device_model(self): + """Return device model information.""" + # The API intentionally returns minimal information about specific + # devices, instead relying on traits, but we can infer a generic model + # name based on the type + if self._device.type == "sdm.devices.types.CAMERA": + return "Camera" + if self._device.type == "sdm.devices.types.DISPLAY": + return "Display" + if self._device.type == "sdm.devices.types.DOORBELL": + return "Doorbell" + if self._device.type == "sdm.devices.types.THERMOSTAT": + return "Thermostat" + return None + + async def async_added_to_hass(self): + """Run when entity is added to register update signal handler.""" + + async def async_update_state(): + """Update sensor state.""" + await self.async_update_ha_state(True) + + # Event messages trigger the SIGNAL_NEST_UPDATE, which is intercepted + # here to re-fresh the signals from _device. Unregister this callback + # when the entity is removed. + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) + ) + + +class TemperatureSensor(SensorBase): + """Representation of a Temperature Sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.device_name} Temperature" + + @property + def state(self): + """Return the state of the sensor.""" + trait = self._device.traits[TemperatureTrait.NAME] + return trait.ambient_temperature_celsius + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def device_class(self): + """Return the class of this device.""" + return DEVICE_CLASS_TEMPERATURE + + +class HumiditySensor(SensorBase): + """Representation of a Humidity Sensor.""" + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + # The API returns the identifier under the name field. + return f"{self._device.name}-humidity" + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.device_name} Humidity" + + @property + def state(self): + """Return the state of the sensor.""" + trait = self._device.traits[HumidityTrait.NAME] + return trait.ambient_humidity_percent + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return PERCENTAGE + + @property + def device_class(self): + """Return the class of this device.""" + return DEVICE_CLASS_HUMIDITY diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 84e7885a363..943afd254c8 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -1,6 +1,9 @@ { "config": { "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, "init": { "title": "Authentication Provider", "description": "[%key:common::config_flow::title::oauth2_pick_implementation%]", @@ -23,6 +26,9 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "authorize_url_fail": "Unknown error generating an authorize url." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 44cc49f484b..f899b56f85d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -680,6 +680,9 @@ google-cloud-pubsub==0.39.1 # homeassistant.components.google_cloud google-cloud-texttospeech==0.4.0 +# homeassistant.components.nest +google-nest-sdm==0.1.6 + # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8beb06b0057..923fa5b373f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -348,6 +348,9 @@ google-api-python-client==1.6.4 # homeassistant.components.google_pubsub google-cloud-pubsub==0.39.1 +# homeassistant.components.nest +google-nest-sdm==0.1.6 + # homeassistant.components.gree greeclimate==0.9.0 diff --git a/tests/components/nest/sensor_sdm_test.py b/tests/components/nest/sensor_sdm_test.py new file mode 100644 index 00000000000..fbd13701260 --- /dev/null +++ b/tests/components/nest/sensor_sdm_test.py @@ -0,0 +1,248 @@ +""" +Test for Nest sensors platform for the Smart Device Management API. + +These tests fake out the subscriber/devicemanager, and are not using a real +pubsub subscriber. +""" + +import time + +from google_nest_sdm.device import Device +from google_nest_sdm.device_manager import DeviceManager +from google_nest_sdm.event import EventCallback, EventMessage +from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber + +from homeassistant.components.nest import DOMAIN +from homeassistant.setup import async_setup_component + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +PLATFORM = "sensor" + +CONFIG = { + "nest": { + "client_id": "some-client-id", + "client_secret": "some-client-secret", + # Required fields for using SDM API + "project_id": "some-project-id", + "subscriber_id": "some-subscriber-id", + }, +} + +CONFIG_ENTRY_DATA = { + "sdm": {}, # Indicates new SDM API, not legacy API + "auth_implementation": "local", + "token": { + "expires_at": time.time() + 86400, + "access_token": { + "token": "some-token", + }, + }, +} + + +class FakeDeviceManager(DeviceManager): + """Fake DeviceManager that can supply a list of devices and structures.""" + + def __init__(self, devices: dict, structures: dict): + """Initialize FakeDeviceManager.""" + super().__init__() + self._devices = devices + + @property + def structures(self) -> dict: + """Override structures with fake result.""" + return self._structures + + @property + def devices(self) -> dict: + """Override devices with fake result.""" + return self._devices + + +class FakeSubscriber(GoogleNestSubscriber): + """Fake subscriber that supplies a FakeDeviceManager.""" + + def __init__(self, device_manager: FakeDeviceManager): + """Initialize Fake Subscriber.""" + self._device_manager = device_manager + self._callback = None + + def set_update_callback(self, callback: EventCallback): + """Capture the callback set by Home Assistant.""" + self._callback = callback + + async def start_async(self) -> DeviceManager: + """Return the fake device manager.""" + return self._device_manager + + @property + async def async_device_manager(self) -> DeviceManager: + """Return the fake device manager.""" + return self._device_manager + + def stop_async(self): + """No-op to stop the subscriber.""" + return None + + def receive_event(self, event_message: EventMessage): + """Simulate a received pubsub message, invoked by tests.""" + # Update device state, then invoke HomeAssistant to refresh + self._device_manager.handle_event(event_message) + self._callback.handle_event(event_message) + + +async def setup_sensor(hass, devices={}, structures={}): + """Set up the platform and prerequisites.""" + MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA).add_to_hass(hass) + device_manager = FakeDeviceManager(devices=devices, structures=structures) + subscriber = FakeSubscriber(device_manager) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( + "homeassistant.components.nest.GoogleNestSubscriber", return_value=subscriber + ): + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() + return subscriber + + +async def test_thermostat_device(hass): + """Test a thermostat with temperature and humidity sensors.""" + devices = { + "some-device-id": Device.MakeDevice( + { + "name": "some-device-id", + "type": "sdm.devices.types.Thermostat", + "traits": { + "sdm.devices.traits.Info": { + "customName": "My Sensor", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 35.0, + }, + }, + }, + auth=None, + ) + } + await setup_sensor(hass, devices) + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is not None + assert temperature.state == "25.1" + + humidity = hass.states.get("sensor.my_sensor_humidity") + assert humidity is not None + assert humidity.state == "35.0" + + +async def test_no_devices(hass): + """Test no devices returned by the api.""" + await setup_sensor(hass) + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is None + + humidity = hass.states.get("sensor.my_sensor_humidity") + assert humidity is None + + +async def test_device_no_sensor_traits(hass): + """Test a device with applicable sensor traits.""" + devices = { + "some-device-id": Device.MakeDevice( + { + "name": "some-device-id", + "type": "sdm.devices.types.Thermostat", + "traits": {}, + }, + auth=None, + ) + } + await setup_sensor(hass, devices) + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is None + + humidity = hass.states.get("sensor.my_sensor_humidity") + assert humidity is None + + +async def test_device_name_from_structure(hass): + """Test a device without a custom name, inferring name from structure.""" + devices = { + "some-device-id": Device.MakeDevice( + { + "name": "some-device-id", + "type": "sdm.devices.types.Thermostat", + "traits": { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.2, + }, + }, + "parentRelations": [ + {"parent": "some-structure-id", "displayName": "Some Room"} + ], + }, + auth=None, + ) + } + await setup_sensor(hass, devices) + + temperature = hass.states.get("sensor.some_room_temperature") + assert temperature is not None + assert temperature.state == "25.2" + + +async def test_event_updates_sensor(hass): + """Test a pubsub message received by subscriber to update temperature.""" + devices = { + "some-device-id": Device.MakeDevice( + { + "name": "some-device-id", + "type": "sdm.devices.types.Thermostat", + "traits": { + "sdm.devices.traits.Info": { + "customName": "My Sensor", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + }, + }, + auth=None, + ) + } + subscriber = await setup_sensor(hass, devices) + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is not None + assert temperature.state == "25.1" + + # Simulate a pubsub message received by the subscriber with a trait update + event = EventMessage( + { + "eventId": "some-event-id", + "timestamp": "2019-01-01T00:00:01Z", + "resourceUpdate": { + "name": "some-device-id", + "traits": { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 26.2, + }, + }, + }, + }, + auth=None, + ) + subscriber.receive_event(event) + await hass.async_block_till_done() # Process dispatch/update signal + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is not None + assert temperature.state == "26.2" diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow_legacy.py similarity index 100% rename from tests/components/nest/test_config_flow.py rename to tests/components/nest/test_config_flow_legacy.py diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py new file mode 100644 index 00000000000..1df751f3980 --- /dev/null +++ b/tests/components/nest/test_config_flow_sdm.py @@ -0,0 +1,66 @@ +"""Test the Google Nest Device Access config flow.""" +from homeassistant import config_entries, setup +from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.async_mock import patch + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +PROJECT_ID = "project-id-4321" +SUBSCRIBER_ID = "subscriber-id-9876" + + +async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "project_id": PROJECT_ID, + "subscriber_id": SUBSCRIBER_ID, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + "http": {"base_url": "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]}) + + oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) + assert result["url"] == ( + f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" + "+https://www.googleapis.com/auth/pubsub" + "&access_type=offline&prompt=consent" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.nest.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 -- GitLab