mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
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:
parent
9bc0509f28
commit
52b66e88c7
@ -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
|
||||
|
@ -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."""
|
||||
|
35
homeassistant/components/nest/api.py
Normal file
35
homeassistant/components/nest/api.py
Normal 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)
|
@ -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__)
|
||||
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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"
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Local Nest authentication."""
|
||||
"""Local Nest authentication for the legacy api."""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
|
||||
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
@ -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)
|
||||
|
208
homeassistant/components/nest/sensor_legacy.py
Normal file
208
homeassistant/components/nest/sensor_legacy.py
Normal 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)
|
170
homeassistant/components/nest/sensor_sdm.py
Normal file
170
homeassistant/components/nest/sensor_sdm.py
Normal 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
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
248
tests/components/nest/sensor_sdm_test.py
Normal file
248
tests/components/nest/sensor_sdm_test.py
Normal 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"
|
66
tests/components/nest/test_config_flow_sdm.py
Normal file
66
tests/components/nest/test_config_flow_sdm.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user