mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 14:57:09 +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/nederlandse_spoorwegen/* @YarmoM
|
||||||
homeassistant/components/nello/* @pschmitt
|
homeassistant/components/nello/* @pschmitt
|
||||||
homeassistant/components/ness_alarm/* @nickw444
|
homeassistant/components/ness_alarm/* @nickw444
|
||||||
homeassistant/components/nest/* @awarecan
|
homeassistant/components/nest/* @awarecan @allenporter
|
||||||
homeassistant/components/netatmo/* @cgtobi
|
homeassistant/components/netatmo/* @cgtobi
|
||||||
homeassistant/components/netdata/* @fabaff
|
homeassistant/components/netdata/* @fabaff
|
||||||
homeassistant/components/nexia/* @ryannazaretian @bdraco
|
homeassistant/components/nexia/* @ryannazaretian @bdraco
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
"""Support for Nest devices."""
|
"""Support for Nest devices."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import threading
|
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 import Nest
|
||||||
from nest.nest import APIError, AuthorizationError
|
from nest.nest import APIError, AuthorizationError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_BINARY_SENSORS,
|
CONF_BINARY_SENSORS,
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
@ -19,25 +24,38 @@ from homeassistant.const import (
|
|||||||
EVENT_HOMEASSISTANT_START,
|
EVENT_HOMEASSISTANT_START,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
)
|
)
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
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.dispatcher import async_dispatcher_connect, dispatcher_send
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
from . import local_auth
|
from . import api, config_flow, local_auth
|
||||||
from .const import DOMAIN
|
from .const import (
|
||||||
|
API_URL,
|
||||||
|
DATA_SDM,
|
||||||
|
DOMAIN,
|
||||||
|
OAUTH2_AUTHORIZE,
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
SIGNAL_NEST_UPDATE,
|
||||||
|
)
|
||||||
|
|
||||||
_CONFIGURING = {}
|
_CONFIGURING = {}
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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_CANCEL_ETA = "cancel_eta"
|
||||||
SERVICE_SET_ETA = "set_eta"
|
SERVICE_SET_ETA = "set_eta"
|
||||||
|
|
||||||
DATA_NEST = "nest"
|
DATA_NEST = "nest"
|
||||||
DATA_NEST_CONFIG = "nest_config"
|
DATA_NEST_CONFIG = "nest_config"
|
||||||
|
|
||||||
SIGNAL_NEST_UPDATE = "nest_update"
|
|
||||||
|
|
||||||
NEST_CONFIG_FILE = "nest.conf"
|
NEST_CONFIG_FILE = "nest.conf"
|
||||||
|
|
||||||
ATTR_ETA = "eta"
|
ATTR_ETA = "eta"
|
||||||
@ -61,6 +79,10 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
{
|
{
|
||||||
vol.Required(CONF_CLIENT_ID): cv.string,
|
vol.Required(CONF_CLIENT_ID): cv.string,
|
||||||
vol.Required(CONF_CLIENT_SECRET): 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_STRUCTURE): vol.All(cv.ensure_list, [cv.string]),
|
||||||
vol.Optional(CONF_SENSORS): SENSOR_SCHEMA,
|
vol.Optional(CONF_SENSORS): SENSOR_SCHEMA,
|
||||||
vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA,
|
vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA,
|
||||||
@ -70,6 +92,10 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PLATFORMS = ["sensor"]
|
||||||
|
|
||||||
|
# Services for the legacy API
|
||||||
|
|
||||||
SET_AWAY_MODE_SCHEMA = vol.Schema(
|
SET_AWAY_MODE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]),
|
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):
|
def nest_update_event_broker(hass, nest):
|
||||||
"""
|
"""
|
||||||
Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data.
|
Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data.
|
||||||
|
|
||||||
|
Used for the legacy nest API.
|
||||||
|
|
||||||
Runs in its own thread.
|
Runs in its own thread.
|
||||||
"""
|
"""
|
||||||
_LOGGER.debug("Listening for nest.update_event")
|
_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")
|
_LOGGER.debug("Stop listening for nest.update_event")
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass, config):
|
async def async_setup_legacy(hass, config):
|
||||||
"""Set up Nest components."""
|
"""Set up Nest components using the legacy nest API."""
|
||||||
if DOMAIN not in config:
|
if DOMAIN not in config:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -141,14 +285,14 @@ async def async_setup(hass, config):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass, entry):
|
async def async_setup_legacy_entry(hass, entry):
|
||||||
"""Set up Nest from a config entry."""
|
"""Set up Nest from legacy config entry."""
|
||||||
|
|
||||||
nest = Nest(access_token=entry.data["tokens"]["access_token"])
|
nest = Nest(access_token=entry.data["tokens"]["access_token"])
|
||||||
|
|
||||||
_LOGGER.debug("proceeding with setup")
|
_LOGGER.debug("proceeding with setup")
|
||||||
conf = hass.data.get(DATA_NEST_CONFIG, {})
|
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):
|
if not await hass.async_add_executor_job(hass.data[DATA_NEST].initialize):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -275,8 +419,8 @@ async def async_setup_entry(hass, entry):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class NestDevice:
|
class NestLegacyDevice:
|
||||||
"""Structure Nest functions for hass."""
|
"""Structure Nest functions for hass for legacy API."""
|
||||||
|
|
||||||
def __init__(self, hass, conf, nest):
|
def __init__(self, hass, conf, nest):
|
||||||
"""Init Nest Devices."""
|
"""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 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__)
|
_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
|
import asyncio
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
import async_timeout
|
import async_timeout
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@ -10,9 +24,10 @@ import voluptuous as vol
|
|||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
from homeassistant.util.json import load_json
|
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"
|
DATA_FLOW_IMPL = "nest_flow_implementation"
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -20,7 +35,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def register_flow_implementation(hass, domain, name, gen_authorize_url, convert_code):
|
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.
|
domain: Domain of the component responsible for the implementation.
|
||||||
name: Name of the component.
|
name: Name of the component.
|
||||||
@ -47,22 +62,57 @@ class CodeInvalid(NestAuthError):
|
|||||||
|
|
||||||
|
|
||||||
@config_entries.HANDLERS.register(DOMAIN)
|
@config_entries.HANDLERS.register(DOMAIN)
|
||||||
class NestFlowHandler(config_entries.ConfigFlow):
|
class NestFlowHandler(
|
||||||
"""Handle a Nest config flow."""
|
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||||
|
):
|
||||||
|
"""Config flow to handle authentication for both APIs."""
|
||||||
|
|
||||||
|
DOMAIN = DOMAIN
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
||||||
|
|
||||||
def __init__(self):
|
@classmethod
|
||||||
"""Initialize the Nest config flow."""
|
def register_sdm_api(cls, hass):
|
||||||
self.flow_impl = None
|
"""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):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Handle a flow initialized by the user."""
|
"""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)
|
return await self.async_step_init(user_input)
|
||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
async def async_step_init(self, user_input=None):
|
||||||
"""Handle a flow start."""
|
"""Handle a flow start."""
|
||||||
|
if self.is_sdm_api():
|
||||||
|
return None
|
||||||
|
|
||||||
flows = self.hass.data.get(DATA_FLOW_IMPL, {})
|
flows = self.hass.data.get(DATA_FLOW_IMPL, {})
|
||||||
|
|
||||||
if self.hass.config_entries.async_entries(DOMAIN):
|
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
|
implementation type we expect a pin or an external component to
|
||||||
deliver the authentication code.
|
deliver the authentication code.
|
||||||
"""
|
"""
|
||||||
|
if self.is_sdm_api():
|
||||||
|
return None
|
||||||
|
|
||||||
flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
|
flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
@ -131,6 +184,9 @@ class NestFlowHandler(config_entries.ConfigFlow):
|
|||||||
|
|
||||||
async def async_step_import(self, info):
|
async def async_step_import(self, info):
|
||||||
"""Import existing auth from Nest."""
|
"""Import existing auth from Nest."""
|
||||||
|
if self.is_sdm_api():
|
||||||
|
return None
|
||||||
|
|
||||||
if self.hass.config_entries.async_entries(DOMAIN):
|
if self.hass.config_entries.async_entries(DOMAIN):
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
@ -1,2 +1,17 @@
|
|||||||
"""Constants used by the Nest component."""
|
"""Constants used by the Nest component."""
|
||||||
|
|
||||||
DOMAIN = "nest"
|
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
|
import asyncio
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
|
@ -2,7 +2,14 @@
|
|||||||
"domain": "nest",
|
"domain": "nest",
|
||||||
"name": "Nest",
|
"name": "Nest",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
"dependencies": ["http"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||||
"requirements": ["python-nest==4.1.0"],
|
"requirements": [
|
||||||
"codeowners": ["@awarecan"]
|
"python-nest==4.1.0",
|
||||||
|
"google-nest-sdm==0.1.6"
|
||||||
|
],
|
||||||
|
"codeowners": [
|
||||||
|
"@awarecan",
|
||||||
|
"@allenporter"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,208 +1,17 @@
|
|||||||
"""Support for Nest Thermostat sensors."""
|
"""Support for Nest sensors that dispatches between API versions."""
|
||||||
import logging
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.config_entries import ConfigEntry
|
||||||
CONF_MONITORED_CONDITIONS,
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
DEVICE_CLASS_HUMIDITY,
|
|
||||||
DEVICE_CLASS_TEMPERATURE,
|
|
||||||
PERCENTAGE,
|
|
||||||
STATE_OFF,
|
|
||||||
TEMP_CELSIUS,
|
|
||||||
TEMP_FAHRENHEIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
from . import CONF_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice
|
from .const import DATA_SDM
|
||||||
|
from .sensor_legacy import async_setup_legacy_entry
|
||||||
SENSOR_TYPES = ["humidity", "operation_mode", "hvac_state"]
|
from .sensor_sdm import async_setup_sdm_entry
|
||||||
|
|
||||||
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):
|
async def async_setup_entry(
|
||||||
"""Set up the Nest Sensor.
|
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
|
||||||
|
) -> None:
|
||||||
No longer used.
|
"""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)
|
||||||
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)
|
|
||||||
|
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": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
|
"pick_implementation": {
|
||||||
|
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||||
|
},
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Authentication Provider",
|
"title": "Authentication Provider",
|
||||||
"description": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
"description": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
||||||
@ -23,6 +26,9 @@
|
|||||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||||
"authorize_url_fail": "Unknown error generating an authorize url."
|
"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
|
# homeassistant.components.google_cloud
|
||||||
google-cloud-texttospeech==0.4.0
|
google-cloud-texttospeech==0.4.0
|
||||||
|
|
||||||
|
# homeassistant.components.nest
|
||||||
|
google-nest-sdm==0.1.6
|
||||||
|
|
||||||
# homeassistant.components.google_travel_time
|
# homeassistant.components.google_travel_time
|
||||||
googlemaps==2.5.1
|
googlemaps==2.5.1
|
||||||
|
|
||||||
|
@ -348,6 +348,9 @@ google-api-python-client==1.6.4
|
|||||||
# homeassistant.components.google_pubsub
|
# homeassistant.components.google_pubsub
|
||||||
google-cloud-pubsub==0.39.1
|
google-cloud-pubsub==0.39.1
|
||||||
|
|
||||||
|
# homeassistant.components.nest
|
||||||
|
google-nest-sdm==0.1.6
|
||||||
|
|
||||||
# homeassistant.components.gree
|
# homeassistant.components.gree
|
||||||
greeclimate==0.9.0
|
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