Update Nest integration to support Google Nest Device Access (new API) (#41689)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Allen Porter 2020-10-21 01:17:49 -07:00 committed by GitHub
parent 9bc0509f28
commit 52b66e88c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1001 additions and 230 deletions

View File

@ -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

View File

@ -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."""

View File

@ -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)

View File

@ -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__)

View File

@ -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")

View File

@ -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"

View File

@ -1,4 +1,4 @@
"""Local Nest authentication."""
"""Local Nest authentication for the legacy api."""
import asyncio
from functools import partial

View File

@ -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"
]
}

View File

@ -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
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__)
from .const import DATA_SDM
from .sensor_legacy import async_setup_legacy_entry
from .sensor_sdm import async_setup_sdm_entry
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)

View File

@ -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)

View File

@ -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

View File

@ -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%]"
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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