Offload Cloud component (#21937)
* Offload Cloud component & Remote support * Make hound happy * Address commentspull/21945/head
parent
8bfbe3e085
commit
92ff49212b
|
@ -1,47 +1,38 @@
|
|||
"""Component to integrate the Home Assistant cloud."""
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import const as ga_c
|
||||
from homeassistant.const import (
|
||||
CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION,
|
||||
CONF_MODE, CONF_NAME)
|
||||
from homeassistant.helpers import entityfilter, config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, entityfilter
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.aiohttp import MockRequest
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import helpers as ga_h
|
||||
from homeassistant.components.google_assistant import const as ga_c
|
||||
|
||||
from . import http_api, iot, auth_api, prefs, cloudhooks
|
||||
from .const import CONFIG_DIR, DOMAIN, SERVERS, STATE_CONNECTED
|
||||
from . import http_api
|
||||
from .const import (
|
||||
CONF_ACME_DIRECTORY_SERVER, CONF_ALEXA, CONF_ALIASES,
|
||||
CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
|
||||
CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL,
|
||||
CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD)
|
||||
from .prefs import CloudPreferences
|
||||
|
||||
REQUIREMENTS = ['warrant==0.6.1']
|
||||
REQUIREMENTS = ['hass-nabucasa==0.3']
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ALEXA = 'alexa'
|
||||
CONF_ALIASES = 'aliases'
|
||||
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
|
||||
CONF_ENTITY_CONFIG = 'entity_config'
|
||||
CONF_FILTER = 'filter'
|
||||
CONF_GOOGLE_ACTIONS = 'google_actions'
|
||||
CONF_RELAYER = 'relayer'
|
||||
CONF_USER_POOL_ID = 'user_pool_id'
|
||||
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
|
||||
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
||||
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
||||
DEFAULT_MODE = MODE_PROD
|
||||
|
||||
DEFAULT_MODE = 'production'
|
||||
DEPENDENCIES = ['http']
|
||||
SERVICE_REMOTE_CONNECT = 'remote_connect'
|
||||
SERVICE_REMOTE_DISCONNECT = 'remote_disconnect'
|
||||
|
||||
MODE_DEV = 'development'
|
||||
|
||||
ALEXA_ENTITY_SCHEMA = vol.Schema({
|
||||
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
|
||||
|
@ -56,7 +47,7 @@ GOOGLE_ENTITY_SCHEMA = vol.Schema({
|
|||
})
|
||||
|
||||
ASSISTANT_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
|
||||
vol.Optional(CONF_FILTER, default=dict): entityfilter.FILTER_SCHEMA,
|
||||
})
|
||||
|
||||
ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
|
||||
|
@ -67,18 +58,21 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
|
|||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
|
||||
})
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
|
||||
vol.In([MODE_DEV] + list(SERVERS)),
|
||||
vol.In([MODE_DEV, MODE_PROD]),
|
||||
# Change to optional when we include real servers
|
||||
vol.Optional(CONF_COGNITO_CLIENT_ID): str,
|
||||
vol.Optional(CONF_USER_POOL_ID): str,
|
||||
vol.Optional(CONF_REGION): str,
|
||||
vol.Optional(CONF_RELAYER): str,
|
||||
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str,
|
||||
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str,
|
||||
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str,
|
||||
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(),
|
||||
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(),
|
||||
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): vol.Url(),
|
||||
vol.Optional(CONF_REMOTE_API_URL): vol.Url(),
|
||||
vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
|
||||
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
|
||||
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
||||
}),
|
||||
|
@ -133,189 +127,48 @@ def is_cloudhook_request(request):
|
|||
|
||||
async def async_setup(hass, config):
|
||||
"""Initialize the Home Assistant cloud."""
|
||||
from hass_nabucasa import Cloud
|
||||
from .client import CloudClient
|
||||
|
||||
# Process configs
|
||||
if DOMAIN in config:
|
||||
kwargs = dict(config[DOMAIN])
|
||||
else:
|
||||
kwargs = {CONF_MODE: DEFAULT_MODE}
|
||||
|
||||
alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({})
|
||||
google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({})
|
||||
|
||||
if CONF_GOOGLE_ACTIONS not in kwargs:
|
||||
kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({})
|
||||
prefs = CloudPreferences(hass)
|
||||
await prefs.async_initialize()
|
||||
|
||||
kwargs[CONF_ALEXA] = alexa_sh.Config(
|
||||
endpoint=None,
|
||||
async_get_access_token=None,
|
||||
should_expose=alexa_conf[CONF_FILTER],
|
||||
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
|
||||
)
|
||||
websession = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||
client = CloudClient(hass, prefs, websession, alexa_conf, google_conf)
|
||||
cloud = hass.data[DOMAIN] = Cloud(client, **kwargs)
|
||||
|
||||
async def _startup(event):
|
||||
"""Startup event."""
|
||||
await cloud.start()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _startup)
|
||||
|
||||
async def _shutdown(event):
|
||||
"""Shutdown event."""
|
||||
await cloud.stop()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||
|
||||
async def _service_handler(service):
|
||||
"""Handle service for cloud."""
|
||||
if service.service == SERVICE_REMOTE_CONNECT:
|
||||
await cloud.remote.connect()
|
||||
elif service.service == SERVICE_REMOTE_DISCONNECT:
|
||||
await cloud.remote.disconnect()
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler)
|
||||
|
||||
cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs)
|
||||
await auth_api.async_setup(hass, cloud)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start)
|
||||
await http_api.async_setup(hass)
|
||||
return True
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""Store the configuration of the cloud connection."""
|
||||
|
||||
def __init__(self, hass, mode, alexa, google_actions,
|
||||
cognito_client_id=None, user_pool_id=None, region=None,
|
||||
relayer=None, google_actions_sync_url=None,
|
||||
subscription_info_url=None, cloudhook_create_url=None):
|
||||
"""Create an instance of Cloud."""
|
||||
self.hass = hass
|
||||
self.mode = mode
|
||||
self.alexa_config = alexa
|
||||
self.google_actions_user_conf = google_actions
|
||||
self._gactions_config = None
|
||||
self.prefs = prefs.CloudPreferences(hass)
|
||||
self.id_token = None
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
self.iot = iot.CloudIoT(self)
|
||||
self.cloudhooks = cloudhooks.Cloudhooks(self)
|
||||
|
||||
if mode == MODE_DEV:
|
||||
self.cognito_client_id = cognito_client_id
|
||||
self.user_pool_id = user_pool_id
|
||||
self.region = region
|
||||
self.relayer = relayer
|
||||
self.google_actions_sync_url = google_actions_sync_url
|
||||
self.subscription_info_url = subscription_info_url
|
||||
self.cloudhook_create_url = cloudhook_create_url
|
||||
|
||||
else:
|
||||
info = SERVERS[mode]
|
||||
|
||||
self.cognito_client_id = info['cognito_client_id']
|
||||
self.user_pool_id = info['user_pool_id']
|
||||
self.region = info['region']
|
||||
self.relayer = info['relayer']
|
||||
self.google_actions_sync_url = info['google_actions_sync_url']
|
||||
self.subscription_info_url = info['subscription_info_url']
|
||||
self.cloudhook_create_url = info['cloudhook_create_url']
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
"""Get if cloud is logged in."""
|
||||
return self.id_token is not None
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
"""Get if cloud is connected."""
|
||||
return self.iot.state == STATE_CONNECTED
|
||||
|
||||
@property
|
||||
def subscription_expired(self):
|
||||
"""Return a boolean if the subscription has expired."""
|
||||
return dt_util.utcnow() > self.expiration_date + timedelta(days=7)
|
||||
|
||||
@property
|
||||
def expiration_date(self):
|
||||
"""Return the subscription expiration as a UTC datetime object."""
|
||||
return datetime.combine(
|
||||
dt_util.parse_date(self.claims['custom:sub-exp']),
|
||||
datetime.min.time()).replace(tzinfo=dt_util.UTC)
|
||||
|
||||
@property
|
||||
def claims(self):
|
||||
"""Return the claims from the id token."""
|
||||
return self._decode_claims(self.id_token)
|
||||
|
||||
@property
|
||||
def user_info_path(self):
|
||||
"""Get path to the stored auth."""
|
||||
return self.path('{}_auth.json'.format(self.mode))
|
||||
|
||||
@property
|
||||
def gactions_config(self):
|
||||
"""Return the Google Assistant config."""
|
||||
if self._gactions_config is None:
|
||||
conf = self.google_actions_user_conf
|
||||
|
||||
def should_expose(entity):
|
||||
"""If an entity should be exposed."""
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
return conf['filter'](entity.entity_id)
|
||||
|
||||
self._gactions_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
allow_unlock=self.prefs.google_allow_unlock,
|
||||
entity_config=conf.get(CONF_ENTITY_CONFIG),
|
||||
)
|
||||
|
||||
return self._gactions_config
|
||||
|
||||
def path(self, *parts):
|
||||
"""Get config path inside cloud dir.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return self.hass.config.path(CONFIG_DIR, *parts)
|
||||
|
||||
async def fetch_subscription_info(self):
|
||||
"""Fetch subscription info."""
|
||||
await self.hass.async_add_executor_job(auth_api.check_token, self)
|
||||
websession = self.hass.helpers.aiohttp_client.async_get_clientsession()
|
||||
return await websession.get(
|
||||
self.subscription_info_url, headers={
|
||||
'authorization': self.id_token
|
||||
})
|
||||
|
||||
async def logout(self):
|
||||
"""Close connection and remove all credentials."""
|
||||
await self.iot.disconnect()
|
||||
|
||||
self.id_token = None
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
self._gactions_config = None
|
||||
|
||||
await self.hass.async_add_job(
|
||||
lambda: os.remove(self.user_info_path))
|
||||
|
||||
def write_user_info(self):
|
||||
"""Write user info to a file."""
|
||||
with open(self.user_info_path, 'wt') as file:
|
||||
file.write(json.dumps({
|
||||
'id_token': self.id_token,
|
||||
'access_token': self.access_token,
|
||||
'refresh_token': self.refresh_token,
|
||||
}, indent=4))
|
||||
|
||||
async def async_start(self, _):
|
||||
"""Start the cloud component."""
|
||||
def load_config():
|
||||
"""Load config."""
|
||||
# Ensure config dir exists
|
||||
path = self.hass.config.path(CONFIG_DIR)
|
||||
if not os.path.isdir(path):
|
||||
os.mkdir(path)
|
||||
|
||||
user_info = self.user_info_path
|
||||
if not os.path.isfile(user_info):
|
||||
return None
|
||||
|
||||
with open(user_info, 'rt') as file:
|
||||
return json.loads(file.read())
|
||||
|
||||
info = await self.hass.async_add_job(load_config)
|
||||
await self.prefs.async_initialize()
|
||||
|
||||
if info is None:
|
||||
return
|
||||
|
||||
self.id_token = info['id_token']
|
||||
self.access_token = info['access_token']
|
||||
self.refresh_token = info['refresh_token']
|
||||
|
||||
self.hass.async_create_task(self.iot.connect())
|
||||
|
||||
def _decode_claims(self, token): # pylint: disable=no-self-use
|
||||
"""Decode the claims in a token."""
|
||||
from jose import jwt
|
||||
return jwt.get_unverified_claims(token)
|
||||
|
|
|
@ -1,232 +0,0 @@
|
|||
"""Package to communicate with the authentication API."""
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudError(Exception):
|
||||
"""Base class for cloud related errors."""
|
||||
|
||||
|
||||
class Unauthenticated(CloudError):
|
||||
"""Raised when authentication failed."""
|
||||
|
||||
|
||||
class UserNotFound(CloudError):
|
||||
"""Raised when a user is not found."""
|
||||
|
||||
|
||||
class UserNotConfirmed(CloudError):
|
||||
"""Raised when a user has not confirmed email yet."""
|
||||
|
||||
|
||||
class PasswordChangeRequired(CloudError):
|
||||
"""Raised when a password change is required."""
|
||||
|
||||
# https://github.com/PyCQA/pylint/issues/1085
|
||||
# pylint: disable=useless-super-delegation
|
||||
def __init__(self, message='Password change required.'):
|
||||
"""Initialize a password change required error."""
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UnknownError(CloudError):
|
||||
"""Raised when an unknown error occurs."""
|
||||
|
||||
|
||||
AWS_EXCEPTIONS = {
|
||||
'UserNotFoundException': UserNotFound,
|
||||
'NotAuthorizedException': Unauthenticated,
|
||||
'UserNotConfirmedException': UserNotConfirmed,
|
||||
'PasswordResetRequiredException': PasswordChangeRequired,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup(hass, cloud):
|
||||
"""Configure the auth api."""
|
||||
refresh_task = None
|
||||
|
||||
async def handle_token_refresh():
|
||||
"""Handle Cloud access token refresh."""
|
||||
sleep_time = 5
|
||||
sleep_time = random.randint(2400, 3600)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(sleep_time)
|
||||
await hass.async_add_executor_job(renew_access_token, cloud)
|
||||
except CloudError as err:
|
||||
_LOGGER.error("Can't refresh cloud token: %s", err)
|
||||
except asyncio.CancelledError:
|
||||
# Task is canceled, stop it.
|
||||
break
|
||||
|
||||
sleep_time = random.randint(3100, 3600)
|
||||
|
||||
async def on_connect():
|
||||
"""When the instance is connected."""
|
||||
nonlocal refresh_task
|
||||
refresh_task = hass.async_create_task(handle_token_refresh())
|
||||
|
||||
async def on_disconnect():
|
||||
"""When the instance is disconnected."""
|
||||
nonlocal refresh_task
|
||||
refresh_task.cancel()
|
||||
|
||||
cloud.iot.register_on_connect(on_connect)
|
||||
cloud.iot.register_on_disconnect(on_disconnect)
|
||||
|
||||
|
||||
def _map_aws_exception(err):
|
||||
"""Map AWS exception to our exceptions."""
|
||||
ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError)
|
||||
return ex(err.response['Error']['Message'])
|
||||
|
||||
|
||||
def register(cloud, email, password):
|
||||
"""Register a new account."""
|
||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||
|
||||
cognito = _cognito(cloud)
|
||||
# Workaround for bug in Warrant. PR with fix:
|
||||
# https://github.com/capless/warrant/pull/82
|
||||
cognito.add_base_attributes()
|
||||
try:
|
||||
cognito.register(email, password)
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
except EndpointConnectionError:
|
||||
raise UnknownError()
|
||||
|
||||
|
||||
def resend_email_confirm(cloud, email):
|
||||
"""Resend email confirmation."""
|
||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||
|
||||
cognito = _cognito(cloud, username=email)
|
||||
|
||||
try:
|
||||
cognito.client.resend_confirmation_code(
|
||||
Username=email,
|
||||
ClientId=cognito.client_id
|
||||
)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
except EndpointConnectionError:
|
||||
raise UnknownError()
|
||||
|
||||
|
||||
def forgot_password(cloud, email):
|
||||
"""Initialize forgotten password flow."""
|
||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||
|
||||
cognito = _cognito(cloud, username=email)
|
||||
|
||||
try:
|
||||
cognito.initiate_forgot_password()
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
except EndpointConnectionError:
|
||||
raise UnknownError()
|
||||
|
||||
|
||||
def login(cloud, email, password):
|
||||
"""Log user in and fetch certificate."""
|
||||
cognito = _authenticate(cloud, email, password)
|
||||
cloud.id_token = cognito.id_token
|
||||
cloud.access_token = cognito.access_token
|
||||
cloud.refresh_token = cognito.refresh_token
|
||||
cloud.write_user_info()
|
||||
|
||||
|
||||
def check_token(cloud):
|
||||
"""Check that the token is valid and verify if needed."""
|
||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||
|
||||
cognito = _cognito(
|
||||
cloud,
|
||||
access_token=cloud.access_token,
|
||||
refresh_token=cloud.refresh_token)
|
||||
|
||||
try:
|
||||
if cognito.check_token():
|
||||
cloud.id_token = cognito.id_token
|
||||
cloud.access_token = cognito.access_token
|
||||
cloud.write_user_info()
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
except EndpointConnectionError:
|
||||
raise UnknownError()
|
||||
|
||||
|
||||
def renew_access_token(cloud):
|
||||
"""Renew access token."""
|
||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||
|
||||
cognito = _cognito(
|
||||
cloud,
|
||||
access_token=cloud.access_token,
|
||||
refresh_token=cloud.refresh_token)
|
||||
|
||||
try:
|
||||
cognito.renew_access_token()
|
||||
cloud.id_token = cognito.id_token
|
||||
cloud.access_token = cognito.access_token
|
||||
cloud.write_user_info()
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
except EndpointConnectionError:
|
||||
raise UnknownError()
|
||||
|
||||
|
||||
def _authenticate(cloud, email, password):
|
||||
"""Log in and return an authenticated Cognito instance."""
|
||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||
from warrant.exceptions import ForceChangePasswordException
|
||||
|
||||
assert not cloud.is_logged_in, 'Cannot login if already logged in.'
|
||||
|
||||
cognito = _cognito(cloud, username=email)
|
||||
|
||||
try:
|
||||
cognito.authenticate(password=password)
|
||||
return cognito
|
||||
|
||||
except ForceChangePasswordException:
|
||||
raise PasswordChangeRequired()
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
except EndpointConnectionError:
|
||||
raise UnknownError()
|
||||
|
||||
|
||||
def _cognito(cloud, **kwargs):
|
||||
"""Get the client credentials."""
|
||||
import botocore
|
||||
import boto3
|
||||
from warrant import Cognito
|
||||
|
||||
cognito = Cognito(
|
||||
user_pool_id=cloud.user_pool_id,
|
||||
client_id=cloud.cognito_client_id,
|
||||
user_pool_region=cloud.region,
|
||||
**kwargs
|
||||
)
|
||||
cognito.client = boto3.client(
|
||||
'cognito-idp',
|
||||
region_name=cloud.region,
|
||||
config=botocore.config.Config(
|
||||
signature_version=botocore.UNSIGNED
|
||||
)
|
||||
)
|
||||
return cognito
|
|
@ -0,0 +1,180 @@
|
|||
"""Interface implementation for cloud client."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import aiohttp
|
||||
from hass_nabucasa.client import CloudClient as Interface
|
||||
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import (
|
||||
helpers as ga_h, smart_home as ga)
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.util.aiohttp import MockRequest
|
||||
|
||||
from . import utils
|
||||
from .const import CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN
|
||||
from .prefs import CloudPreferences
|
||||
|
||||
|
||||
class CloudClient(Interface):
|
||||
"""Interface class for Home Assistant Cloud."""
|
||||
|
||||
def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences,
|
||||
websession: aiohttp.ClientSession,
|
||||
alexa_config: Dict[str, Any], google_config: Dict[str, Any]):
|
||||
"""Initialize client interface to Cloud."""
|
||||
self._hass = hass
|
||||
self._prefs = prefs
|
||||
self._websession = websession
|
||||
self._alexa_user_config = alexa_config
|
||||
self._google_user_config = google_config
|
||||
|
||||
self._alexa_config = None
|
||||
self._google_config = None
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
"""Return path to base dir."""
|
||||
return Path(self._hass.config.config_dir)
|
||||
|
||||
@property
|
||||
def prefs(self) -> CloudPreferences:
|
||||
"""Return Cloud preferences."""
|
||||
return self._prefs
|
||||
|
||||
@property
|
||||
def loop(self) -> asyncio.BaseEventLoop:
|
||||
"""Return client loop."""
|
||||
return self._hass.loop
|
||||
|
||||
@property
|
||||
def websession(self) -> aiohttp.ClientSession:
|
||||
"""Return client session for aiohttp."""
|
||||
return self._websession
|
||||
|
||||
@property
|
||||
def aiohttp_runner(self) -> aiohttp.web.AppRunner:
|
||||
"""Return client webinterface aiohttp application."""
|
||||
return self._hass.http.runner
|
||||
|
||||
@property
|
||||
def cloudhooks(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Return list of cloudhooks."""
|
||||
return self._prefs.cloudhooks
|
||||
|
||||
@property
|
||||
def alexa_config(self) -> alexa_sh.Config:
|
||||
"""Return Alexa config."""
|
||||
if not self._alexa_config:
|
||||
alexa_conf = self._alexa_user_config
|
||||
|
||||
self._alexa_config = alexa_sh.Config(
|
||||
endpoint=None,
|
||||
async_get_access_token=None,
|
||||
should_expose=alexa_conf[CONF_FILTER],
|
||||
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
|
||||
)
|
||||
|
||||
return self._alexa_config
|
||||
|
||||
@property
|
||||
def google_config(self) -> ga_h.Config:
|
||||
"""Return Google config."""
|
||||
if not self._google_config:
|
||||
google_conf = self._google_user_config
|
||||
|
||||
def should_expose(entity):
|
||||
"""If an entity should be exposed."""
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
return google_conf['filter'](entity.entity_id)
|
||||
|
||||
self._google_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
allow_unlock=self._prefs.google_allow_unlock,
|
||||
entity_config=google_conf.get(CONF_ENTITY_CONFIG),
|
||||
)
|
||||
|
||||
return self._google_config
|
||||
|
||||
@property
|
||||
def google_user_config(self) -> Dict[str, Any]:
|
||||
"""Return google action user config."""
|
||||
return self._google_user_config
|
||||
|
||||
async def cleanups(self) -> None:
|
||||
"""Cleanup some stuff after logout."""
|
||||
self._alexa_config = None
|
||||
self._google_config = None
|
||||
|
||||
async def async_user_message(
|
||||
self, identifier: str, title: str, message: str) -> None:
|
||||
"""Create a message for user to UI."""
|
||||
self._hass.components.persistent_notification.async_create(
|
||||
message, title, identifier
|
||||
)
|
||||
|
||||
async def async_alexa_message(
|
||||
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""Process cloud alexa message to client."""
|
||||
return await alexa_sh.async_handle_message(
|
||||
self._hass, self.alexa_config, payload,
|
||||
enabled=self._prefs.alexa_enabled
|
||||
)
|
||||
|
||||
async def async_google_message(
|
||||
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""Process cloud google message to client."""
|
||||
if not self._prefs.google_enabled:
|
||||
return ga.turned_off_response(payload)
|
||||
|
||||
cloud = self._hass.data[DOMAIN]
|
||||
return await ga.async_handle_message(
|
||||
self._hass, self.google_config,
|
||||
cloud.claims['cognito:username'], payload
|
||||
)
|
||||
|
||||
async def async_webhook_message(
|
||||
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""Process cloud webhook message to client."""
|
||||
cloudhook_id = payload['cloudhook_id']
|
||||
|
||||
found = None
|
||||
for cloudhook in self._prefs.cloudhooks.values():
|
||||
if cloudhook['cloudhook_id'] == cloudhook_id:
|
||||
found = cloudhook
|
||||
break
|
||||
|
||||
if found is None:
|
||||
return {
|
||||
'status': 200
|
||||
}
|
||||
|
||||
request = MockRequest(
|
||||
content=payload['body'].encode('utf-8'),
|
||||
headers=payload['headers'],
|
||||
method=payload['method'],
|
||||
query_string=payload['query'],
|
||||
)
|
||||
|
||||
response = await self._hass.components.webhook.async_handle_webhook(
|
||||
found['webhook_id'], request)
|
||||
|
||||
response_dict = utils.aiohttp_serialize_response(response)
|
||||
body = response_dict.get('body')
|
||||
|
||||
return {
|
||||
'body': body,
|
||||
'status': response_dict['status'],
|
||||
'headers': {
|
||||
'Content-Type': response.content_type
|
||||
}
|
||||
}
|
||||
|
||||
async def async_cloudhooks_update(
|
||||
self, data: Dict[str, Dict[str, str]]) -> None:
|
||||
"""Update local list of cloudhooks."""
|
||||
await self._prefs.async_update(cloudhooks=data)
|
|
@ -1,42 +0,0 @@
|
|||
"""Cloud APIs."""
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
from . import auth_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_token(func):
|
||||
"""Decorate a function to verify valid token."""
|
||||
@wraps(func)
|
||||
async def check_token(cloud, *args):
|
||||
"""Validate token, then call func."""
|
||||
await cloud.hass.async_add_executor_job(auth_api.check_token, cloud)
|
||||
return await func(cloud, *args)
|
||||
|
||||
return check_token
|
||||
|
||||
|
||||
def _log_response(func):
|
||||
"""Decorate a function to log bad responses."""
|
||||
@wraps(func)
|
||||
async def log_response(*args):
|
||||
"""Log response if it's bad."""
|
||||
resp = await func(*args)
|
||||
meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning
|
||||
meth('Fetched %s (%s)', resp.url, resp.status)
|
||||
return resp
|
||||
|
||||
return log_response
|
||||
|
||||
|
||||
@_check_token
|
||||
@_log_response
|
||||
async def async_create_cloudhook(cloud):
|
||||
"""Create a cloudhook."""
|
||||
websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession()
|
||||
return await websession.post(
|
||||
cloud.cloudhook_create_url, headers={
|
||||
'authorization': cloud.id_token
|
||||
})
|
|
@ -1,69 +0,0 @@
|
|||
"""Manage cloud cloudhooks."""
|
||||
import async_timeout
|
||||
|
||||
from . import cloud_api
|
||||
|
||||
|
||||
class Cloudhooks:
|
||||
"""Class to help manage cloudhooks."""
|
||||
|
||||
def __init__(self, cloud):
|
||||
"""Initialize cloudhooks."""
|
||||
self.cloud = cloud
|
||||
self.cloud.iot.register_on_connect(self.async_publish_cloudhooks)
|
||||
|
||||
async def async_publish_cloudhooks(self):
|
||||
"""Inform the Relayer of the cloudhooks that we support."""
|
||||
if not self.cloud.is_connected:
|
||||
return
|
||||
|
||||
cloudhooks = self.cloud.prefs.cloudhooks
|
||||
await self.cloud.iot.async_send_message('webhook-register', {
|
||||
'cloudhook_ids': [info['cloudhook_id'] for info
|
||||
in cloudhooks.values()]
|
||||
}, expect_answer=False)
|
||||
|
||||
async def async_create(self, webhook_id):
|
||||
"""Create a cloud webhook."""
|
||||
cloudhooks = self.cloud.prefs.cloudhooks
|
||||
|
||||
if webhook_id in cloudhooks:
|
||||
raise ValueError('Hook is already enabled for the cloud.')
|
||||
|
||||
if not self.cloud.iot.connected:
|
||||
raise ValueError("Cloud is not connected")
|
||||
|
||||
# Create cloud hook
|
||||
with async_timeout.timeout(10):
|
||||
resp = await cloud_api.async_create_cloudhook(self.cloud)
|
||||
|
||||
data = await resp.json()
|
||||
cloudhook_id = data['cloudhook_id']
|
||||
cloudhook_url = data['url']
|
||||
|
||||
# Store hook
|
||||
cloudhooks = dict(cloudhooks)
|
||||
hook = cloudhooks[webhook_id] = {
|
||||
'webhook_id': webhook_id,
|
||||
'cloudhook_id': cloudhook_id,
|
||||
'cloudhook_url': cloudhook_url
|
||||
}
|
||||
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
|
||||
|
||||
await self.async_publish_cloudhooks()
|
||||
|
||||
return hook
|
||||
|
||||
async def async_delete(self, webhook_id):
|
||||
"""Delete a cloud webhook."""
|
||||
cloudhooks = self.cloud.prefs.cloudhooks
|
||||
|
||||
if webhook_id not in cloudhooks:
|
||||
raise ValueError('Hook is not enabled for the cloud.')
|
||||
|
||||
# Remove hook
|
||||
cloudhooks = dict(cloudhooks)
|
||||
cloudhooks.pop(webhook_id)
|
||||
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
|
||||
|
||||
await self.async_publish_cloudhooks()
|
|
@ -1,6 +1,5 @@
|
|||
"""Constants for the cloud component."""
|
||||
DOMAIN = 'cloud'
|
||||
CONFIG_DIR = '.cloud'
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
PREF_ENABLE_ALEXA = 'alexa_enabled'
|
||||
|
@ -8,31 +7,19 @@ PREF_ENABLE_GOOGLE = 'google_enabled'
|
|||
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
|
||||
PREF_CLOUDHOOKS = 'cloudhooks'
|
||||
|
||||
SERVERS = {
|
||||
'production': {
|
||||
'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u',
|
||||
'user_pool_id': 'us-east-1_87ll5WOP8',
|
||||
'region': 'us-east-1',
|
||||
'relayer': 'wss://cloud.hass.io:8000/websocket',
|
||||
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
|
||||
'amazonaws.com/prod/smart_home_sync'),
|
||||
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
|
||||
'subscription_info'),
|
||||
'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate'
|
||||
}
|
||||
}
|
||||
CONF_ALEXA = 'alexa'
|
||||
CONF_ALIASES = 'aliases'
|
||||
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
|
||||
CONF_ENTITY_CONFIG = 'entity_config'
|
||||
CONF_FILTER = 'filter'
|
||||
CONF_GOOGLE_ACTIONS = 'google_actions'
|
||||
CONF_RELAYER = 'relayer'
|
||||
CONF_USER_POOL_ID = 'user_pool_id'
|
||||
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
|
||||
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
||||
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
||||
CONF_REMOTE_API_URL = 'remote_api_url'
|
||||
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
|
||||
|
||||
MESSAGE_EXPIRATION = """
|
||||
It looks like your Home Assistant Cloud subscription has expired. Please check
|
||||
your [account page](/config/cloud/account) to continue using the service.
|
||||
"""
|
||||
|
||||
MESSAGE_AUTH_FAIL = """
|
||||
You have been logged out of Home Assistant Cloud because we have been unable
|
||||
to verify your credentials. Please [log in](/config/cloud) again to continue
|
||||
using the service.
|
||||
"""
|
||||
|
||||
STATE_CONNECTING = 'connecting'
|
||||
STATE_CONNECTED = 'connected'
|
||||
STATE_DISCONNECTED = 'disconnected'
|
||||
MODE_DEV = "development"
|
||||
MODE_PROD = "production"
|
||||
|
|
|
@ -15,11 +15,9 @@ from homeassistant.components import websocket_api
|
|||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import smart_home as google_sh
|
||||
|
||||
from . import auth_api
|
||||
from .const import (
|
||||
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK)
|
||||
from .iot import STATE_DISCONNECTED, STATE_CONNECTED
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -59,6 +57,9 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
|||
})
|
||||
|
||||
|
||||
_CLOUD_ERRORS = {}
|
||||
|
||||
|
||||
async def async_setup(hass):
|
||||
"""Initialize the HTTP API."""
|
||||
hass.components.websocket_api.async_register_command(
|
||||
|
@ -88,14 +89,20 @@ async def async_setup(hass):
|
|||
hass.http.register_view(CloudResendConfirmView)
|
||||
hass.http.register_view(CloudForgotPasswordView)
|
||||
|
||||
from hass_nabucasa import auth
|
||||
|
||||
_CLOUD_ERRORS = {
|
||||
auth_api.UserNotFound: (400, "User does not exist."),
|
||||
auth_api.UserNotConfirmed: (400, 'Email not confirmed.'),
|
||||
auth_api.Unauthenticated: (401, 'Authentication failed.'),
|
||||
auth_api.PasswordChangeRequired: (400, 'Password change required.'),
|
||||
asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.')
|
||||
}
|
||||
_CLOUD_ERRORS.update({
|
||||
auth.UserNotFound:
|
||||
(400, "User does not exist."),
|
||||
auth.UserNotConfirmed:
|
||||
(400, 'Email not confirmed.'),
|
||||
auth.Unauthenticated:
|
||||
(401, 'Authentication failed.'),
|
||||
auth.PasswordChangeRequired:
|
||||
(400, 'Password change required.'),
|
||||
asyncio.TimeoutError:
|
||||
(502, 'Unable to reach the Home Assistant cloud.')
|
||||
})
|
||||
|
||||
|
||||
def _handle_cloud_errors(handler):
|
||||
|
@ -135,7 +142,7 @@ class GoogleActionsSyncView(HomeAssistantView):
|
|||
websession = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
await hass.async_add_job(auth_api.check_token, cloud)
|
||||
await hass.async_add_job(cloud.auth.check_token)
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
req = await websession.post(
|
||||
|
@ -163,7 +170,7 @@ class CloudLoginView(HomeAssistantView):
|
|||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
await hass.async_add_job(auth_api.login, cloud, data['email'],
|
||||
await hass.async_add_job(cloud.auth.login, data['email'],
|
||||
data['password'])
|
||||
|
||||
hass.async_add_job(cloud.iot.connect)
|
||||
|
@ -206,7 +213,7 @@ class CloudRegisterView(HomeAssistantView):
|
|||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
await hass.async_add_job(
|
||||
auth_api.register, cloud, data['email'], data['password'])
|
||||
cloud.auth.register, data['email'], data['password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
@ -228,7 +235,7 @@ class CloudResendConfirmView(HomeAssistantView):
|
|||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
await hass.async_add_job(
|
||||
auth_api.resend_email_confirm, cloud, data['email'])
|
||||
cloud.auth.resend_email_confirm, data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
@ -250,7 +257,7 @@ class CloudForgotPasswordView(HomeAssistantView):
|
|||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
await hass.async_add_job(
|
||||
auth_api.forgot_password, cloud, data['email'])
|
||||
cloud.auth.forgot_password, data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
@ -307,6 +314,7 @@ def _handle_aiohttp_errors(handler):
|
|||
@websocket_api.async_response
|
||||
async def websocket_subscription(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
|
@ -320,11 +328,10 @@ async def websocket_subscription(hass, connection, msg):
|
|||
|
||||
# Check if a user is subscribed but local info is outdated
|
||||
# In that case, let's refresh and reconnect
|
||||
if data.get('provider') and cloud.iot.state != STATE_CONNECTED:
|
||||
if data.get('provider') and not cloud.is_connected:
|
||||
_LOGGER.debug(
|
||||
"Found disconnected account with valid subscriotion, connecting")
|
||||
await hass.async_add_executor_job(
|
||||
auth_api.renew_access_token, cloud)
|
||||
await hass.async_add_executor_job(cloud.auth.renew_access_token)
|
||||
|
||||
# Cancel reconnect in progress
|
||||
if cloud.iot.state != STATE_DISCONNECTED:
|
||||
|
@ -344,7 +351,7 @@ async def websocket_update_prefs(hass, connection, msg):
|
|||
changes = dict(msg)
|
||||
changes.pop('id')
|
||||
changes.pop('type')
|
||||
await cloud.prefs.async_update(**changes)
|
||||
await cloud.client.prefs.async_update(**changes)
|
||||
|
||||
connection.send_message(websocket_api.result_message(msg['id']))
|
||||
|
||||
|
@ -370,6 +377,8 @@ async def websocket_hook_delete(hass, connection, msg):
|
|||
|
||||
def _account_data(cloud):
|
||||
"""Generate the auth data JSON response."""
|
||||
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
|
||||
if not cloud.is_logged_in:
|
||||
return {
|
||||
'logged_in': False,
|
||||
|
@ -377,14 +386,15 @@ def _account_data(cloud):
|
|||
}
|
||||
|
||||
claims = cloud.claims
|
||||
client = cloud.client
|
||||
|
||||
return {
|
||||
'logged_in': True,
|
||||
'email': claims['email'],
|
||||
'cloud': cloud.iot.state,
|
||||
'prefs': cloud.prefs.as_dict(),
|
||||
'google_entities': cloud.google_actions_user_conf['filter'].config,
|
||||
'prefs': client.prefs.as_dict(),
|
||||
'google_entities': client.google_user_config['filter'].config,
|
||||
'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES),
|
||||
'alexa_entities': cloud.alexa_config.should_expose.config,
|
||||
'alexa_entities': client.alexa_config.should_expose.config,
|
||||
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
|
||||
}
|
||||
|
|
|
@ -1,392 +0,0 @@
|
|||
"""Module to handle messages from Home Assistant cloud."""
|
||||
import asyncio
|
||||
import logging
|
||||
import pprint
|
||||
import random
|
||||
import uuid
|
||||
|
||||
from aiohttp import hdrs, client_exceptions, WSMsgType
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.components.alexa import smart_home as alexa
|
||||
from homeassistant.components.google_assistant import smart_home as ga
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.util.aiohttp import MockRequest
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from . import auth_api
|
||||
from . import utils
|
||||
from .const import (
|
||||
MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL, STATE_CONNECTED, STATE_CONNECTING,
|
||||
STATE_DISCONNECTED
|
||||
)
|
||||
|
||||
HANDLERS = Registry()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnknownHandler(Exception):
|
||||
"""Exception raised when trying to handle unknown handler."""
|
||||
|
||||
|
||||
class NotConnected(Exception):
|
||||
"""Exception raised when trying to handle unknown handler."""
|
||||
|
||||
|
||||
class ErrorMessage(Exception):
|
||||
"""Exception raised when there was error handling message in the cloud."""
|
||||
|
||||
def __init__(self, error):
|
||||
"""Initialize Error Message."""
|
||||
super().__init__(self, "Error in Cloud")
|
||||
self.error = error
|
||||
|
||||
|
||||
class CloudIoT:
|
||||
"""Class to manage the IoT connection."""
|
||||
|
||||
def __init__(self, cloud):
|
||||
"""Initialize the CloudIoT class."""
|
||||
self.cloud = cloud
|
||||
# The WebSocket client
|
||||
self.client = None
|
||||
# Scheduled sleep task till next connection retry
|
||||
self.retry_task = None
|
||||
# Boolean to indicate if we wanted the connection to close
|
||||
self.close_requested = False
|
||||
# The current number of attempts to connect, impacts wait time
|
||||
self.tries = 0
|
||||
# Current state of the connection
|
||||
self.state = STATE_DISCONNECTED
|
||||
# Local code waiting for a response
|
||||
self._response_handler = {}
|
||||
self._on_connect = []
|
||||
self._on_disconnect = []
|
||||
|
||||
@callback
|
||||
def register_on_connect(self, on_connect_cb):
|
||||
"""Register an async on_connect callback."""
|
||||
self._on_connect.append(on_connect_cb)
|
||||
|
||||
@callback
|
||||
def register_on_disconnect(self, on_disconnect_cb):
|
||||
"""Register an async on_disconnect callback."""
|
||||
self._on_disconnect.append(on_disconnect_cb)
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Return if we're currently connected."""
|
||||
return self.state == STATE_CONNECTED
|
||||
|
||||
@asyncio.coroutine
|
||||
def connect(self):
|
||||
"""Connect to the IoT broker."""
|
||||
if self.state != STATE_DISCONNECTED:
|
||||
raise RuntimeError('Connect called while not disconnected')
|
||||
|
||||
hass = self.cloud.hass
|
||||
self.close_requested = False
|
||||
self.state = STATE_CONNECTING
|
||||
self.tries = 0
|
||||
|
||||
@asyncio.coroutine
|
||||
def _handle_hass_stop(event):
|
||||
"""Handle Home Assistant shutting down."""
|
||||
nonlocal remove_hass_stop_listener
|
||||
remove_hass_stop_listener = None
|
||||
yield from self.disconnect()
|
||||
|
||||
remove_hass_stop_listener = hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, _handle_hass_stop)
|
||||
|
||||
while True:
|
||||
try:
|
||||
yield from self._handle_connection()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Safety net. This should never hit.
|
||||
# Still adding it here to make sure we can always reconnect
|
||||
_LOGGER.exception("Unexpected error")
|
||||
|
||||
if self.state == STATE_CONNECTED and self._on_disconnect:
|
||||
try:
|
||||
yield from asyncio.wait([
|
||||
cb() for cb in self._on_disconnect
|
||||
])
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Safety net. This should never hit.
|
||||
# Still adding it here to make sure we don't break the flow
|
||||
_LOGGER.exception(
|
||||
"Unexpected error in on_disconnect callbacks")
|
||||
|
||||
if self.close_requested:
|
||||
break
|
||||
|
||||
self.state = STATE_CONNECTING
|
||||
self.tries += 1
|
||||
|
||||
try:
|
||||
# Sleep 2^tries + 0…tries*3 seconds between retries
|
||||
self.retry_task = hass.async_create_task(
|
||||
asyncio.sleep(2**min(9, self.tries) +
|
||||
random.randint(0, self.tries * 3),
|
||||
loop=hass.loop))
|
||||
yield from self.retry_task
|
||||
self.retry_task = None
|
||||
except asyncio.CancelledError:
|
||||
# Happens if disconnect called
|
||||
break
|
||||
|
||||
self.state = STATE_DISCONNECTED
|
||||
if remove_hass_stop_listener is not None:
|
||||
remove_hass_stop_listener()
|
||||
|
||||
async def async_send_message(self, handler, payload,
|
||||
expect_answer=True):
|
||||
"""Send a message."""
|
||||
if self.state != STATE_CONNECTED:
|
||||
raise NotConnected
|
||||
|
||||
msgid = uuid.uuid4().hex
|
||||
|
||||
if expect_answer:
|
||||
fut = self._response_handler[msgid] = asyncio.Future()
|
||||
|
||||
message = {
|
||||
'msgid': msgid,
|
||||
'handler': handler,
|
||||
'payload': payload,
|
||||
}
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("Publishing message:\n%s\n",
|
||||
pprint.pformat(message))
|
||||
await self.client.send_json(message)
|
||||
|
||||
if expect_answer:
|
||||
return await fut
|
||||
|
||||
@asyncio.coroutine
|
||||
def _handle_connection(self):
|
||||
"""Connect to the IoT broker."""
|
||||
hass = self.cloud.hass
|
||||
|
||||
try:
|
||||
yield from hass.async_add_job(auth_api.check_token, self.cloud)
|
||||
except auth_api.Unauthenticated as err:
|
||||
_LOGGER.error('Unable to refresh token: %s', err)
|
||||
|
||||
hass.components.persistent_notification.async_create(
|
||||
MESSAGE_AUTH_FAIL, 'Home Assistant Cloud',
|
||||
'cloud_subscription_expired')
|
||||
|
||||
# Don't await it because it will cancel this task
|
||||
hass.async_create_task(self.cloud.logout())
|
||||
return
|
||||
except auth_api.CloudError as err:
|
||||
_LOGGER.warning("Unable to refresh token: %s", err)
|
||||
return
|
||||
|
||||
if self.cloud.subscription_expired:
|
||||
hass.components.persistent_notification.async_create(
|
||||
MESSAGE_EXPIRATION, 'Home Assistant Cloud',
|
||||
'cloud_subscription_expired')
|
||||
self.close_requested = True
|
||||
return
|
||||
|
||||
session = async_get_clientsession(self.cloud.hass)
|
||||
client = None
|
||||
disconnect_warn = None
|
||||
|
||||
try:
|
||||
self.client = client = yield from session.ws_connect(
|
||||
self.cloud.relayer, heartbeat=55, headers={
|
||||
hdrs.AUTHORIZATION:
|
||||
'Bearer {}'.format(self.cloud.id_token)
|
||||
})
|
||||
self.tries = 0
|
||||
|
||||
_LOGGER.info("Connected")
|
||||
self.state = STATE_CONNECTED
|
||||
|
||||
if self._on_connect:
|
||||
try:
|
||||
yield from asyncio.wait([cb() for cb in self._on_connect])
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Safety net. This should never hit.
|
||||
# Still adding it here to make sure we don't break the flow
|
||||
_LOGGER.exception(
|
||||
"Unexpected error in on_connect callbacks")
|
||||
|
||||
while not client.closed:
|
||||
msg = yield from client.receive()
|
||||
|
||||
if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING):
|
||||
break
|
||||
|
||||
elif msg.type == WSMsgType.ERROR:
|
||||
disconnect_warn = 'Connection error'
|
||||
break
|
||||
|
||||
elif msg.type != WSMsgType.TEXT:
|
||||
disconnect_warn = 'Received non-Text message: {}'.format(
|
||||
msg.type)
|
||||
break
|
||||
|
||||
try:
|
||||
msg = msg.json()
|
||||
except ValueError:
|
||||
disconnect_warn = 'Received invalid JSON.'
|
||||
break
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("Received message:\n%s\n",
|
||||
pprint.pformat(msg))
|
||||
|
||||
response_handler = self._response_handler.pop(msg['msgid'],
|
||||
None)
|
||||
|
||||
if response_handler is not None:
|
||||
if 'payload' in msg:
|
||||
response_handler.set_result(msg["payload"])
|
||||
else:
|
||||
response_handler.set_exception(
|
||||
ErrorMessage(msg['error']))
|
||||
continue
|
||||
|
||||
response = {
|
||||
'msgid': msg['msgid'],
|
||||
}
|
||||
try:
|
||||
result = yield from async_handle_message(
|
||||
hass, self.cloud, msg['handler'], msg['payload'])
|
||||
|
||||
# No response from handler
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
response['payload'] = result
|
||||
|
||||
except UnknownHandler:
|
||||
response['error'] = 'unknown-handler'
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error handling message")
|
||||
response['error'] = 'exception'
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("Publishing message:\n%s\n",
|
||||
pprint.pformat(response))
|
||||
yield from client.send_json(response)
|
||||
|
||||
except client_exceptions.WSServerHandshakeError as err:
|
||||
if err.status == 401:
|
||||
disconnect_warn = 'Invalid auth.'
|
||||
self.close_requested = True
|
||||
# Should we notify user?
|
||||
else:
|
||||
_LOGGER.warning("Unable to connect: %s", err)
|
||||
|
||||
except client_exceptions.ClientError as err:
|
||||
_LOGGER.warning("Unable to connect: %s", err)
|
||||
|
||||
finally:
|
||||
if disconnect_warn is None:
|
||||
_LOGGER.info("Connection closed")
|
||||
else:
|
||||
_LOGGER.warning("Connection closed: %s", disconnect_warn)
|
||||
|
||||
@asyncio.coroutine
|
||||
def disconnect(self):
|
||||
"""Disconnect the client."""
|
||||
self.close_requested = True
|
||||
|
||||
if self.client is not None:
|
||||
yield from self.client.close()
|
||||
elif self.retry_task is not None:
|
||||
self.retry_task.cancel()
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, cloud, handler_name, payload):
|
||||
"""Handle incoming IoT message."""
|
||||
handler = HANDLERS.get(handler_name)
|
||||
|
||||
if handler is None:
|
||||
raise UnknownHandler()
|
||||
|
||||
return (yield from handler(hass, cloud, payload))
|
||||
|
||||
|
||||
@HANDLERS.register('alexa')
|
||||
@asyncio.coroutine
|
||||
def async_handle_alexa(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for Alexa."""
|
||||
result = yield from alexa.async_handle_message(
|
||||
hass, cloud.alexa_config, payload,
|
||||
enabled=cloud.prefs.alexa_enabled)
|
||||
return result
|
||||
|
||||
|
||||
@HANDLERS.register('google_actions')
|
||||
@asyncio.coroutine
|
||||
def async_handle_google_actions(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for Google Actions."""
|
||||
if not cloud.prefs.google_enabled:
|
||||
return ga.turned_off_response(payload)
|
||||
|
||||
result = yield from ga.async_handle_message(
|
||||
hass, cloud.gactions_config,
|
||||
cloud.claims['cognito:username'],
|
||||
payload)
|
||||
return result
|
||||
|
||||
|
||||
@HANDLERS.register('cloud')
|
||||
async def async_handle_cloud(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for cloud component."""
|
||||
action = payload['action']
|
||||
|
||||
if action == 'logout':
|
||||
# Log out of Home Assistant Cloud
|
||||
await cloud.logout()
|
||||
_LOGGER.error("You have been logged out from Home Assistant cloud: %s",
|
||||
payload['reason'])
|
||||
else:
|
||||
_LOGGER.warning("Received unknown cloud action: %s", action)
|
||||
|
||||
|
||||
@HANDLERS.register('webhook')
|
||||
async def async_handle_webhook(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for cloud webhooks."""
|
||||
cloudhook_id = payload['cloudhook_id']
|
||||
|
||||
found = None
|
||||
for cloudhook in cloud.prefs.cloudhooks.values():
|
||||
if cloudhook['cloudhook_id'] == cloudhook_id:
|
||||
found = cloudhook
|
||||
break
|
||||
|
||||
if found is None:
|
||||
return {
|
||||
'status': 200
|
||||
}
|
||||
|
||||
request = MockRequest(
|
||||
content=payload['body'].encode('utf-8'),
|
||||
headers=payload['headers'],
|
||||
method=payload['method'],
|
||||
query_string=payload['query'],
|
||||
)
|
||||
|
||||
response = await hass.components.webhook.async_handle_webhook(
|
||||
found['webhook_id'], request)
|
||||
|
||||
response_dict = utils.aiohttp_serialize_response(response)
|
||||
body = response_dict.get('body')
|
||||
|
||||
return {
|
||||
'body': body,
|
||||
'status': response_dict['status'],
|
||||
'headers': {
|
||||
'Content-Type': response.content_type
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
# Describes the format for available light services
|
||||
|
||||
remote_connect:
|
||||
description: Make instance UI available outside over NabuCasa cloud.
|
||||
|
||||
remote_disconnect:
|
||||
description: Disconnect UI from NabuCasa cloud.
|
|
@ -520,6 +520,9 @@ habitipy==0.2.0
|
|||
# homeassistant.components.hangouts
|
||||
hangups==0.4.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==0.3
|
||||
|
||||
# homeassistant.components.mqtt.server
|
||||
hbmqtt==0.9.4
|
||||
|
||||
|
@ -1763,9 +1766,6 @@ wakeonlan==1.1.6
|
|||
# homeassistant.components.sensor.waqi
|
||||
waqiasync==1.0.0
|
||||
|
||||
# homeassistant.components.cloud
|
||||
warrant==0.6.1
|
||||
|
||||
# homeassistant.components.folder_watcher
|
||||
watchdog==0.8.3
|
||||
|
||||
|
|
|
@ -110,6 +110,9 @@ ha-ffmpeg==1.11
|
|||
# homeassistant.components.hangouts
|
||||
hangups==0.4.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==0.3
|
||||
|
||||
# homeassistant.components.mqtt.server
|
||||
hbmqtt==0.9.4
|
||||
|
||||
|
@ -309,8 +312,5 @@ vultr==0.1.2
|
|||
# homeassistant.components.switch.wake_on_lan
|
||||
wakeonlan==1.1.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
warrant==0.6.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-homeassistant==0.3.0
|
||||
|
|
|
@ -62,6 +62,7 @@ TEST_REQUIREMENTS = (
|
|||
'ha-ffmpeg',
|
||||
'hangups',
|
||||
'HAP-python',
|
||||
'hass-nabucasa',
|
||||
'haversine',
|
||||
'hbmqtt',
|
||||
'hdate',
|
||||
|
@ -136,9 +137,10 @@ TEST_REQUIREMENTS = (
|
|||
)
|
||||
|
||||
IGNORE_PACKAGES = (
|
||||
'homeassistant.components.recorder.models',
|
||||
'homeassistant.components.hangouts.hangups_utils',
|
||||
'homeassistant.components.cloud.client',
|
||||
'homeassistant.components.homekit.*',
|
||||
'homeassistant.components.hangouts.hangups_utils'
|
||||
'homeassistant.components.recorder.models',
|
||||
)
|
||||
|
||||
IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3')
|
||||
|
|
|
@ -11,8 +11,7 @@ from tests.common import mock_coro
|
|||
|
||||
def mock_cloud(hass, config={}):
|
||||
"""Mock cloud."""
|
||||
with patch('homeassistant.components.cloud.Cloud.async_start',
|
||||
return_value=mock_coro()):
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
assert hass.loop.run_until_complete(async_setup_component(
|
||||
hass, cloud.DOMAIN, {
|
||||
'cloud': config
|
||||
|
@ -30,5 +29,5 @@ def mock_cloud_prefs(hass, prefs={}):
|
|||
const.PREF_GOOGLE_ALLOW_UNLOCK: True,
|
||||
}
|
||||
prefs_to_set.update(prefs)
|
||||
hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set
|
||||
hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set
|
||||
return prefs_to_set
|
||||
|
|
|
@ -1,9 +1,18 @@
|
|||
"""Fixtures for cloud tests."""
|
||||
import pytest
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from . import mock_cloud, mock_cloud_prefs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_user_data():
|
||||
"""Mock os module."""
|
||||
with patch('hass_nabucasa.Cloud.write_user_info') as writer:
|
||||
yield writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cloud_fixture(hass):
|
||||
"""Fixture for cloud component."""
|
||||
|
|
|
@ -1,196 +0,0 @@
|
|||
"""Tests for the tools to communicate with the cloud."""
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cloud import auth_api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cognito():
|
||||
"""Mock warrant."""
|
||||
with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog:
|
||||
yield mock_cog()
|
||||
|
||||
|
||||
def aws_error(code, message='Unknown', operation_name='fake_operation_name'):
|
||||
"""Generate AWS error response."""
|
||||
response = {
|
||||
'Error': {
|
||||
'Code': code,
|
||||
'Message': message
|
||||
}
|
||||
}
|
||||
return ClientError(response, operation_name)
|
||||
|
||||
|
||||
def test_login_invalid_auth(mock_cognito):
|
||||
"""Test trying to login with invalid credentials."""
|
||||
cloud = MagicMock(is_logged_in=False)
|
||||
mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException')
|
||||
|
||||
with pytest.raises(auth_api.Unauthenticated):
|
||||
auth_api.login(cloud, 'user', 'pass')
|
||||
|
||||
assert len(cloud.write_user_info.mock_calls) == 0
|
||||
|
||||
|
||||
def test_login_user_not_found(mock_cognito):
|
||||
"""Test trying to login with invalid credentials."""
|
||||
cloud = MagicMock(is_logged_in=False)
|
||||
mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException')
|
||||
|
||||
with pytest.raises(auth_api.UserNotFound):
|
||||
auth_api.login(cloud, 'user', 'pass')
|
||||
|
||||
assert len(cloud.write_user_info.mock_calls) == 0
|
||||
|
||||
|
||||
def test_login_user_not_confirmed(mock_cognito):
|
||||
"""Test trying to login without confirming account."""
|
||||
cloud = MagicMock(is_logged_in=False)
|
||||
mock_cognito.authenticate.side_effect = \
|
||||
aws_error('UserNotConfirmedException')
|
||||
|
||||
with pytest.raises(auth_api.UserNotConfirmed):
|
||||
auth_api.login(cloud, 'user', 'pass')
|
||||
|
||||
assert len(cloud.write_user_info.mock_calls) == 0
|
||||
|
||||
|
||||
def test_login(mock_cognito):
|
||||
"""Test trying to login without confirming account."""
|
||||
cloud = MagicMock(is_logged_in=False)
|
||||
mock_cognito.id_token = 'test_id_token'
|
||||
mock_cognito.access_token = 'test_access_token'
|
||||
mock_cognito.refresh_token = 'test_refresh_token'
|
||||
|
||||
auth_api.login(cloud, 'user', 'pass')
|
||||
|
||||
assert len(mock_cognito.authenticate.mock_calls) == 1
|
||||
assert cloud.id_token == 'test_id_token'
|
||||
assert cloud.access_token == 'test_access_token'
|
||||
assert cloud.refresh_token == 'test_refresh_token'
|
||||
assert len(cloud.write_user_info.mock_calls) == 1
|
||||
|
||||
|
||||
def test_register(mock_cognito):
|
||||
"""Test registering an account."""
|
||||
cloud = MagicMock()
|
||||
cloud = MagicMock()
|
||||
auth_api.register(cloud, 'email@home-assistant.io', 'password')
|
||||
assert len(mock_cognito.register.mock_calls) == 1
|
||||
result_user, result_password = mock_cognito.register.mock_calls[0][1]
|
||||
assert result_user == 'email@home-assistant.io'
|
||||
assert result_password == 'password'
|
||||
|
||||
|
||||
def test_register_fails(mock_cognito):
|
||||
"""Test registering an account."""
|
||||
cloud = MagicMock()
|
||||
mock_cognito.register.side_effect = aws_error('SomeError')
|
||||
with pytest.raises(auth_api.CloudError):
|
||||
auth_api.register(cloud, 'email@home-assistant.io', 'password')
|
||||
|
||||
|
||||
def test_resend_email_confirm(mock_cognito):
|
||||
"""Test starting forgot password flow."""
|
||||
cloud = MagicMock()
|
||||
auth_api.resend_email_confirm(cloud, 'email@home-assistant.io')
|
||||
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1
|
||||
|
||||
|
||||
def test_resend_email_confirm_fails(mock_cognito):
|
||||
"""Test failure when starting forgot password flow."""
|
||||
cloud = MagicMock()
|
||||
mock_cognito.client.resend_confirmation_code.side_effect = \
|
||||
aws_error('SomeError')
|
||||
with pytest.raises(auth_api.CloudError):
|
||||
auth_api.resend_email_confirm(cloud, 'email@home-assistant.io')
|
||||
|
||||
|
||||
def test_forgot_password(mock_cognito):
|
||||
"""Test starting forgot password flow."""
|
||||
cloud = MagicMock()
|
||||
auth_api.forgot_password(cloud, 'email@home-assistant.io')
|
||||
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1
|
||||
|
||||
|
||||
def test_forgot_password_fails(mock_cognito):
|
||||
"""Test failure when starting forgot password flow."""
|
||||
cloud = MagicMock()
|
||||
mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError')
|
||||
with pytest.raises(auth_api.CloudError):
|
||||
auth_api.forgot_password(cloud, 'email@home-assistant.io')
|
||||
|
||||
|
||||
def test_check_token_writes_new_token_on_refresh(mock_cognito):
|
||||
"""Test check_token writes new token if refreshed."""
|
||||
cloud = MagicMock()
|
||||
mock_cognito.check_token.return_value = True
|
||||
mock_cognito.id_token = 'new id token'
|
||||
mock_cognito.access_token = 'new access token'
|
||||
|
||||
auth_api.check_token(cloud)
|
||||
|
||||
assert len(mock_cognito.check_token.mock_calls) == 1
|
||||
assert cloud.id_token == 'new id token'
|
||||
assert cloud.access_token == 'new access token'
|
||||
assert len(cloud.write_user_info.mock_calls) == 1
|
||||
|
||||
|
||||
def test_check_token_does_not_write_existing_token(mock_cognito):
|
||||
"""Test check_token won't write new token if still valid."""
|
||||
cloud = MagicMock()
|
||||
mock_cognito.check_token.return_value = False
|
||||
|
||||
auth_api.check_token(cloud)
|
||||
|
||||
assert len(mock_cognito.check_token.mock_calls) == 1
|
||||
assert cloud.id_token != mock_cognito.id_token
|
||||
assert cloud.access_token != mock_cognito.access_token
|
||||
assert len(cloud.write_user_info.mock_calls) == 0
|
||||
|
||||
|
||||
def test_check_token_raises(mock_cognito):
|
||||
"""Test we raise correct error."""
|
||||
cloud = MagicMock()
|
||||
mock_cognito.check_token.side_effect = aws_error('SomeError')
|
||||
|
||||
with pytest.raises(auth_api.CloudError):
|
||||
auth_api.check_token(cloud)
|
||||
|
||||
assert len(mock_cognito.check_token.mock_calls) == 1
|
||||
assert cloud.id_token != mock_cognito.id_token
|
||||
assert cloud.access_token != mock_cognito.access_token
|
||||
assert len(cloud.write_user_info.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_async_setup(hass):
|
||||
"""Test async setup."""
|
||||
cloud = MagicMock()
|
||||
await auth_api.async_setup(hass, cloud)
|
||||
assert len(cloud.iot.mock_calls) == 2
|
||||
on_connect = cloud.iot.mock_calls[0][1][0]
|
||||
on_disconnect = cloud.iot.mock_calls[1][1][0]
|
||||
|
||||
with patch('random.randint', return_value=0), patch(
|
||||
'homeassistant.components.cloud.auth_api.renew_access_token'
|
||||
) as mock_renew:
|
||||
await on_connect()
|
||||
# Let handle token sleep once
|
||||
await asyncio.sleep(0)
|
||||
# Let handle token refresh token
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(mock_renew.mock_calls) == 1
|
||||
assert mock_renew.mock_calls[0][1][0] is cloud
|
||||
|
||||
await on_disconnect()
|
||||
|
||||
# Make sure task is no longer being called
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert len(mock_renew.mock_calls) == 1
|
|
@ -0,0 +1,199 @@
|
|||
"""Test the cloud.iot module."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from aiohttp import web
|
||||
import pytest
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components.cloud.const import (
|
||||
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
|
||||
from tests.components.alexa import test_smart_home as test_alexa
|
||||
from tests.common import mock_coro
|
||||
|
||||
from . import mock_cloud_prefs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cloud():
|
||||
"""Mock cloud class."""
|
||||
return MagicMock(subscription_expired=False)
|
||||
|
||||
|
||||
async def test_handler_alexa(hass):
|
||||
"""Test handler Alexa."""
|
||||
hass.states.async_set(
|
||||
'switch.test', 'on', {'friendly_name': "Test switch"})
|
||||
hass.states.async_set(
|
||||
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
|
||||
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
setup = await async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'alexa': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'description': 'Config description',
|
||||
'display_categories': 'LIGHT'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
assert setup
|
||||
|
||||
mock_cloud_prefs(hass)
|
||||
cloud = hass.data['cloud']
|
||||
|
||||
resp = await cloud.client.async_alexa_message(
|
||||
test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
|
||||
|
||||
endpoints = resp['event']['payload']['endpoints']
|
||||
|
||||
assert len(endpoints) == 1
|
||||
device = endpoints[0]
|
||||
|
||||
assert device['description'] == 'Config description'
|
||||
assert device['friendlyName'] == 'Config name'
|
||||
assert device['displayCategories'] == ['LIGHT']
|
||||
assert device['manufacturerName'] == 'Home Assistant'
|
||||
|
||||
|
||||
async def test_handler_alexa_disabled(hass, mock_cloud_fixture):
|
||||
"""Test handler Alexa when user has disabled it."""
|
||||
mock_cloud_fixture[PREF_ENABLE_ALEXA] = False
|
||||
cloud = hass.data['cloud']
|
||||
|
||||
resp = await cloud.client.async_alexa_message(
|
||||
test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
|
||||
|
||||
assert resp['event']['header']['namespace'] == 'Alexa'
|
||||
assert resp['event']['header']['name'] == 'ErrorResponse'
|
||||
assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE'
|
||||
|
||||
|
||||
async def test_handler_google_actions(hass):
|
||||
"""Test handler Google Actions."""
|
||||
hass.states.async_set(
|
||||
'switch.test', 'on', {'friendly_name': "Test switch"})
|
||||
hass.states.async_set(
|
||||
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
|
||||
hass.states.async_set(
|
||||
'group.all_locks', 'on', {'friendly_name': "Evil locks"})
|
||||
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
setup = await async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'google_actions': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'aliases': 'Config alias',
|
||||
'room': 'living room'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
assert setup
|
||||
|
||||
mock_cloud_prefs(hass)
|
||||
cloud = hass.data['cloud']
|
||||
|
||||
reqid = '5711642932632160983'
|
||||
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
|
||||
|
||||
with patch(
|
||||
'hass_nabucasa.Cloud._decode_claims',
|
||||
return_value={'cognito:username': 'myUserName'}
|
||||
):
|
||||
resp = await cloud.client.async_google_message(data)
|
||||
|
||||
assert resp['requestId'] == reqid
|
||||
payload = resp['payload']
|
||||
|
||||
assert payload['agentUserId'] == 'myUserName'
|
||||
|
||||
devices = payload['devices']
|
||||
assert len(devices) == 1
|
||||
|
||||
device = devices[0]
|
||||
assert device['id'] == 'switch.test'
|
||||
assert device['name']['name'] == 'Config name'
|
||||
assert device['name']['nicknames'] == ['Config alias']
|
||||
assert device['type'] == 'action.devices.types.SWITCH'
|
||||
assert device['roomHint'] == 'living room'
|
||||
|
||||
|
||||
async def test_handler_google_actions_disabled(hass, mock_cloud_fixture):
|
||||
"""Test handler Google Actions when user has disabled it."""
|
||||
mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False
|
||||
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
assert await async_setup_component(hass, 'cloud', {})
|
||||
|
||||
reqid = '5711642932632160983'
|
||||
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
|
||||
|
||||
cloud = hass.data['cloud']
|
||||
resp = await cloud.client.async_google_message(data)
|
||||
|
||||
assert resp['requestId'] == reqid
|
||||
assert resp['payload']['errorCode'] == 'deviceTurnedOff'
|
||||
|
||||
|
||||
async def test_webhook_msg(hass):
|
||||
"""Test webhook msg."""
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
setup = await async_setup_component(hass, 'cloud', {
|
||||
'cloud': {}
|
||||
})
|
||||
assert setup
|
||||
cloud = hass.data['cloud']
|
||||
|
||||
await cloud.client.prefs.async_initialize()
|
||||
await cloud.client.prefs.async_update(cloudhooks={
|
||||
'hello': {
|
||||
'webhook_id': 'mock-webhook-id',
|
||||
'cloudhook_id': 'mock-cloud-id'
|
||||
}
|
||||
})
|
||||
|
||||
received = []
|
||||
|
||||
async def handler(hass, webhook_id, request):
|
||||
"""Handle a webhook."""
|
||||
received.append(request)
|
||||
return web.json_response({'from': 'handler'})
|
||||
|
||||
hass.components.webhook.async_register(
|
||||
'test', 'Test', 'mock-webhook-id', handler)
|
||||
|
||||
response = await cloud.client.async_webhook_message({
|
||||
'cloudhook_id': 'mock-cloud-id',
|
||||
'body': '{"hello": "world"}',
|
||||
'headers': {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
'method': 'POST',
|
||||
'query': None,
|
||||
})
|
||||
|
||||
assert response == {
|
||||
'status': 200,
|
||||
'body': '{"from": "handler"}',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
assert len(received) == 1
|
||||
assert await received[0].json() == {
|
||||
'hello': 'world'
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
"""Test cloud API."""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cloud import cloud_api
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_check_token():
|
||||
"""Mock check token."""
|
||||
with patch('homeassistant.components.cloud.auth_api.'
|
||||
'check_token') as mock_check_token:
|
||||
yield mock_check_token
|
||||
|
||||
|
||||
async def test_create_cloudhook(hass, aioclient_mock):
|
||||
"""Test creating a cloudhook."""
|
||||
aioclient_mock.post('https://example.com/bla', json={
|
||||
'cloudhook_id': 'mock-webhook',
|
||||
'url': 'https://blabla'
|
||||
})
|
||||
cloud = Mock(
|
||||
hass=hass,
|
||||
id_token='mock-id-token',
|
||||
cloudhook_create_url='https://example.com/bla',
|
||||
)
|
||||
resp = await cloud_api.async_create_cloudhook(cloud)
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
||||
assert await resp.json() == {
|
||||
'cloudhook_id': 'mock-webhook',
|
||||
'url': 'https://blabla'
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
"""Test cloud cloudhooks."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cloud import prefs, cloudhooks
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cloudhooks(hass):
|
||||
"""Mock cloudhooks class."""
|
||||
cloud = Mock()
|
||||
cloud.hass = hass
|
||||
cloud.hass.async_add_executor_job = Mock(return_value=mock_coro())
|
||||
cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro()))
|
||||
cloud.cloudhook_create_url = 'https://webhook-create.url'
|
||||
cloud.prefs = prefs.CloudPreferences(hass)
|
||||
hass.loop.run_until_complete(cloud.prefs.async_initialize())
|
||||
return cloudhooks.Cloudhooks(cloud)
|
||||
|
||||
|
||||
async def test_enable(mock_cloudhooks, aioclient_mock):
|
||||
"""Test enabling cloudhooks."""
|
||||
aioclient_mock.post('https://webhook-create.url', json={
|
||||
'cloudhook_id': 'mock-cloud-id',
|
||||
'url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||
})
|
||||
|
||||
hook = {
|
||||
'webhook_id': 'mock-webhook-id',
|
||||
'cloudhook_id': 'mock-cloud-id',
|
||||
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||
}
|
||||
|
||||
assert hook == await mock_cloudhooks.async_create('mock-webhook-id')
|
||||
|
||||
assert mock_cloudhooks.cloud.prefs.cloudhooks == {
|
||||
'mock-webhook-id': hook
|
||||
}
|
||||
|
||||
publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls
|
||||
assert len(publish_calls) == 1
|
||||
assert publish_calls[0][1][0] == 'webhook-register'
|
||||
assert publish_calls[0][1][1] == {
|
||||
'cloudhook_ids': ['mock-cloud-id']
|
||||
}
|
||||
|
||||
|
||||
async def test_disable(mock_cloudhooks):
|
||||
"""Test disabling cloudhooks."""
|
||||
mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = {
|
||||
'mock-webhook-id': {
|
||||
'webhook_id': 'mock-webhook-id',
|
||||
'cloudhook_id': 'mock-cloud-id',
|
||||
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||
}
|
||||
}
|
||||
|
||||
await mock_cloudhooks.async_delete('mock-webhook-id')
|
||||
|
||||
assert mock_cloudhooks.cloud.prefs.cloudhooks == {}
|
||||
|
||||
publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls
|
||||
assert len(publish_calls) == 1
|
||||
assert publish_calls[0][1][0] == 'webhook-register'
|
||||
assert publish_calls[0][1][1] == {
|
||||
'cloudhook_ids': []
|
||||
}
|
||||
|
||||
|
||||
async def test_create_without_connected(mock_cloudhooks, aioclient_mock):
|
||||
"""Test we don't publish a hook if not connected."""
|
||||
mock_cloudhooks.cloud.is_connected = False
|
||||
# Make sure we fail test when we send a message.
|
||||
mock_cloudhooks.cloud.iot.async_send_message.side_effect = ValueError
|
||||
|
||||
aioclient_mock.post('https://webhook-create.url', json={
|
||||
'cloudhook_id': 'mock-cloud-id',
|
||||
'url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||
})
|
||||
|
||||
hook = {
|
||||
'webhook_id': 'mock-webhook-id',
|
||||
'cloudhook_id': 'mock-cloud-id',
|
||||
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||
}
|
||||
|
||||
assert hook == await mock_cloudhooks.async_create('mock-webhook-id')
|
||||
|
||||
assert mock_cloudhooks.cloud.prefs.cloudhooks == {
|
||||
'mock-webhook-id': hook
|
||||
}
|
||||
|
||||
assert len(mock_cloudhooks.cloud.iot.async_send_message.mock_calls) == 0
|
|
@ -4,11 +4,11 @@ from unittest.mock import patch, MagicMock
|
|||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
from hass_nabucasa.auth import Unauthenticated, UnknownError
|
||||
from hass_nabucasa.const import STATE_CONNECTED
|
||||
|
||||
from homeassistant.components.cloud import (
|
||||
DOMAIN, auth_api, iot)
|
||||
from homeassistant.components.cloud.const import (
|
||||
PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK)
|
||||
PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
@ -22,12 +22,12 @@ SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info'
|
|||
@pytest.fixture()
|
||||
def mock_auth():
|
||||
"""Mock check token."""
|
||||
with patch('homeassistant.components.cloud.auth_api.check_token'):
|
||||
with patch('hass_nabucasa.auth.CognitoAuth.check_token'):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_api(hass):
|
||||
def setup_api(hass, aioclient_mock):
|
||||
"""Initialize HTTP API."""
|
||||
mock_cloud(hass, {
|
||||
'mode': 'development',
|
||||
|
@ -54,14 +54,14 @@ def setup_api(hass):
|
|||
@pytest.fixture
|
||||
def cloud_client(hass, hass_client):
|
||||
"""Fixture that can fetch from the cloud client."""
|
||||
with patch('homeassistant.components.cloud.Cloud.write_user_info'):
|
||||
with patch('hass_nabucasa.Cloud.write_user_info'):
|
||||
yield hass.loop.run_until_complete(hass_client())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cognito():
|
||||
"""Mock warrant."""
|
||||
with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog:
|
||||
with patch('hass_nabucasa.auth.CognitoAuth._cognito') as mock_cog:
|
||||
yield mock_cog()
|
||||
|
||||
|
||||
|
@ -80,8 +80,7 @@ async def test_google_actions_sync_fails(mock_cognito, cloud_client,
|
|||
assert req.status == 403
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view(hass, cloud_client, mock_cognito):
|
||||
async def test_login_view(hass, cloud_client, mock_cognito):
|
||||
"""Test logging in."""
|
||||
mock_cognito.id_token = jwt.encode({
|
||||
'email': 'hello@home-assistant.io',
|
||||
|
@ -90,23 +89,22 @@ def test_login_view(hass, cloud_client, mock_cognito):
|
|||
mock_cognito.access_token = 'access_token'
|
||||
mock_cognito.refresh_token = 'refresh_token'
|
||||
|
||||
with patch('homeassistant.components.cloud.iot.CloudIoT.'
|
||||
'connect') as mock_connect, \
|
||||
patch('homeassistant.components.cloud.auth_api._authenticate',
|
||||
with patch('hass_nabucasa.iot.CloudIoT.connect') as mock_connect, \
|
||||
patch('hass_nabucasa.auth.CognitoAuth._authenticate',
|
||||
return_value=mock_cognito) as mock_auth:
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
req = await cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
||||
assert req.status == 200
|
||||
result = yield from req.json()
|
||||
result = await req.json()
|
||||
assert result == {'success': True}
|
||||
|
||||
assert len(mock_connect.mock_calls) == 1
|
||||
|
||||
assert len(mock_auth.mock_calls) == 1
|
||||
cloud, result_user, result_pass = mock_auth.mock_calls[0][1]
|
||||
result_user, result_pass = mock_auth.mock_calls[0][1]
|
||||
assert result_user == 'my_username'
|
||||
assert result_pass == 'my_password'
|
||||
|
||||
|
@ -123,32 +121,29 @@ async def test_login_view_random_exception(cloud_client):
|
|||
assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_invalid_json(cloud_client):
|
||||
async def test_login_view_invalid_json(cloud_client):
|
||||
"""Try logging in with invalid JSON."""
|
||||
with patch('homeassistant.components.cloud.auth_api.login') as mock_login:
|
||||
req = yield from cloud_client.post('/api/cloud/login', data='Not JSON')
|
||||
with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
|
||||
req = await cloud_client.post('/api/cloud/login', data='Not JSON')
|
||||
assert req.status == 400
|
||||
assert len(mock_login.mock_calls) == 0
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_invalid_schema(cloud_client):
|
||||
async def test_login_view_invalid_schema(cloud_client):
|
||||
"""Try logging in with invalid schema."""
|
||||
with patch('homeassistant.components.cloud.auth_api.login') as mock_login:
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
|
||||
req = await cloud_client.post('/api/cloud/login', json={
|
||||
'invalid': 'schema'
|
||||
})
|
||||
assert req.status == 400
|
||||
assert len(mock_login.mock_calls) == 0
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_request_timeout(cloud_client):
|
||||
async def test_login_view_request_timeout(cloud_client):
|
||||
"""Test request timeout while trying to log in."""
|
||||
with patch('homeassistant.components.cloud.auth_api.login',
|
||||
with patch('hass_nabucasa.auth.CognitoAuth.login',
|
||||
side_effect=asyncio.TimeoutError):
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
req = await cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
@ -156,12 +151,11 @@ def test_login_view_request_timeout(cloud_client):
|
|||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_invalid_credentials(cloud_client):
|
||||
async def test_login_view_invalid_credentials(cloud_client):
|
||||
"""Test logging in with invalid credentials."""
|
||||
with patch('homeassistant.components.cloud.auth_api.login',
|
||||
side_effect=auth_api.Unauthenticated):
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
with patch('hass_nabucasa.auth.CognitoAuth.login',
|
||||
side_effect=Unauthenticated):
|
||||
req = await cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
@ -169,12 +163,11 @@ def test_login_view_invalid_credentials(cloud_client):
|
|||
assert req.status == 401
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_unknown_error(cloud_client):
|
||||
async def test_login_view_unknown_error(cloud_client):
|
||||
"""Test unknown error while logging in."""
|
||||
with patch('homeassistant.components.cloud.auth_api.login',
|
||||
side_effect=auth_api.UnknownError):
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
with patch('hass_nabucasa.auth.CognitoAuth.login',
|
||||
side_effect=UnknownError):
|
||||
req = await cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
@ -182,40 +175,36 @@ def test_login_view_unknown_error(cloud_client):
|
|||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_logout_view(hass, cloud_client):
|
||||
async def test_logout_view(hass, cloud_client):
|
||||
"""Test logging out."""
|
||||
cloud = hass.data['cloud'] = MagicMock()
|
||||
cloud.logout.return_value = mock_coro()
|
||||
req = yield from cloud_client.post('/api/cloud/logout')
|
||||
req = await cloud_client.post('/api/cloud/logout')
|
||||
assert req.status == 200
|
||||
data = yield from req.json()
|
||||
data = await req.json()
|
||||
assert data == {'message': 'ok'}
|
||||
assert len(cloud.logout.mock_calls) == 1
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_logout_view_request_timeout(hass, cloud_client):
|
||||
async def test_logout_view_request_timeout(hass, cloud_client):
|
||||
"""Test timeout while logging out."""
|
||||
cloud = hass.data['cloud'] = MagicMock()
|
||||
cloud.logout.side_effect = asyncio.TimeoutError
|
||||
req = yield from cloud_client.post('/api/cloud/logout')
|
||||
req = await cloud_client.post('/api/cloud/logout')
|
||||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_logout_view_unknown_error(hass, cloud_client):
|
||||
async def test_logout_view_unknown_error(hass, cloud_client):
|
||||
"""Test unknown error while logging out."""
|
||||
cloud = hass.data['cloud'] = MagicMock()
|
||||
cloud.logout.side_effect = auth_api.UnknownError
|
||||
req = yield from cloud_client.post('/api/cloud/logout')
|
||||
cloud.logout.side_effect = UnknownError
|
||||
req = await cloud_client.post('/api/cloud/logout')
|
||||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_register_view(mock_cognito, cloud_client):
|
||||
async def test_register_view(mock_cognito, cloud_client):
|
||||
"""Test logging out."""
|
||||
req = yield from cloud_client.post('/api/cloud/register', json={
|
||||
req = await cloud_client.post('/api/cloud/register', json={
|
||||
'email': 'hello@bla.com',
|
||||
'password': 'falcon42'
|
||||
})
|
||||
|
@ -226,10 +215,9 @@ def test_register_view(mock_cognito, cloud_client):
|
|||
assert result_pass == 'falcon42'
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_register_view_bad_data(mock_cognito, cloud_client):
|
||||
async def test_register_view_bad_data(mock_cognito, cloud_client):
|
||||
"""Test logging out."""
|
||||
req = yield from cloud_client.post('/api/cloud/register', json={
|
||||
req = await cloud_client.post('/api/cloud/register', json={
|
||||
'email': 'hello@bla.com',
|
||||
'not_password': 'falcon'
|
||||
})
|
||||
|
@ -237,105 +225,95 @@ def test_register_view_bad_data(mock_cognito, cloud_client):
|
|||
assert len(mock_cognito.logout.mock_calls) == 0
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_register_view_request_timeout(mock_cognito, cloud_client):
|
||||
async def test_register_view_request_timeout(mock_cognito, cloud_client):
|
||||
"""Test timeout while logging out."""
|
||||
mock_cognito.register.side_effect = asyncio.TimeoutError
|
||||
req = yield from cloud_client.post('/api/cloud/register', json={
|
||||
req = await cloud_client.post('/api/cloud/register', json={
|
||||
'email': 'hello@bla.com',
|
||||
'password': 'falcon42'
|
||||
})
|
||||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_register_view_unknown_error(mock_cognito, cloud_client):
|
||||
async def test_register_view_unknown_error(mock_cognito, cloud_client):
|
||||
"""Test unknown error while logging out."""
|
||||
mock_cognito.register.side_effect = auth_api.UnknownError
|
||||
req = yield from cloud_client.post('/api/cloud/register', json={
|
||||
mock_cognito.register.side_effect = UnknownError
|
||||
req = await cloud_client.post('/api/cloud/register', json={
|
||||
'email': 'hello@bla.com',
|
||||
'password': 'falcon42'
|
||||
})
|
||||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_forgot_password_view(mock_cognito, cloud_client):
|
||||
async def test_forgot_password_view(mock_cognito, cloud_client):
|
||||
"""Test logging out."""
|
||||
req = yield from cloud_client.post('/api/cloud/forgot_password', json={
|
||||
req = await cloud_client.post('/api/cloud/forgot_password', json={
|
||||
'email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 200
|
||||
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_forgot_password_view_bad_data(mock_cognito, cloud_client):
|
||||
async def test_forgot_password_view_bad_data(mock_cognito, cloud_client):
|
||||
"""Test logging out."""
|
||||
req = yield from cloud_client.post('/api/cloud/forgot_password', json={
|
||||
req = await cloud_client.post('/api/cloud/forgot_password', json={
|
||||
'not_email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 400
|
||||
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_forgot_password_view_request_timeout(mock_cognito, cloud_client):
|
||||
async def test_forgot_password_view_request_timeout(mock_cognito,
|
||||
cloud_client):
|
||||
"""Test timeout while logging out."""
|
||||
mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError
|
||||
req = yield from cloud_client.post('/api/cloud/forgot_password', json={
|
||||
req = await cloud_client.post('/api/cloud/forgot_password', json={
|
||||
'email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_forgot_password_view_unknown_error(mock_cognito, cloud_client):
|
||||
async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client):
|
||||
"""Test unknown error while logging out."""
|
||||
mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError
|
||||
req = yield from cloud_client.post('/api/cloud/forgot_password', json={
|
||||
mock_cognito.initiate_forgot_password.side_effect = UnknownError
|
||||
req = await cloud_client.post('/api/cloud/forgot_password', json={
|
||||
'email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_resend_confirm_view(mock_cognito, cloud_client):
|
||||
async def test_resend_confirm_view(mock_cognito, cloud_client):
|
||||
"""Test logging out."""
|
||||
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
req = await cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
'email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 200
|
||||
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_resend_confirm_view_bad_data(mock_cognito, cloud_client):
|
||||
async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client):
|
||||
"""Test logging out."""
|
||||
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
req = await cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
'not_email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 400
|
||||
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client):
|
||||
async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client):
|
||||
"""Test timeout while logging out."""
|
||||
mock_cognito.client.resend_confirmation_code.side_effect = \
|
||||
asyncio.TimeoutError
|
||||
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
req = await cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
'email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 502
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client):
|
||||
async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client):
|
||||
"""Test unknown error while logging out."""
|
||||
mock_cognito.client.resend_confirmation_code.side_effect = \
|
||||
auth_api.UnknownError
|
||||
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
mock_cognito.client.resend_confirmation_code.side_effect = UnknownError
|
||||
req = await cloud_client.post('/api/cloud/resend_confirm', json={
|
||||
'email': 'hello@bla.com',
|
||||
})
|
||||
assert req.status == 502
|
||||
|
@ -347,7 +325,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture):
|
|||
'email': 'hello@home-assistant.io',
|
||||
'custom:sub-exp': '2018-01-03'
|
||||
}, 'test')
|
||||
hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED
|
||||
hass.data[DOMAIN].iot.state = STATE_CONNECTED
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
with patch.dict(
|
||||
|
@ -407,9 +385,9 @@ async def test_websocket_subscription_reconnect(
|
|||
client = await hass_ws_client(hass)
|
||||
|
||||
with patch(
|
||||
'homeassistant.components.cloud.auth_api.renew_access_token'
|
||||
'hass_nabucasa.auth.CognitoAuth.renew_access_token'
|
||||
) as mock_renew, patch(
|
||||
'homeassistant.components.cloud.iot.CloudIoT.connect'
|
||||
'hass_nabucasa.iot.CloudIoT.connect'
|
||||
) as mock_connect:
|
||||
await client.send_json({
|
||||
'id': 5,
|
||||
|
@ -428,7 +406,7 @@ async def test_websocket_subscription_no_reconnect_if_connected(
|
|||
hass, hass_ws_client, aioclient_mock, mock_auth):
|
||||
"""Test querying the status and not reconnecting because still expired."""
|
||||
aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'})
|
||||
hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED
|
||||
hass.data[DOMAIN].iot.state = STATE_CONNECTED
|
||||
hass.data[DOMAIN].id_token = jwt.encode({
|
||||
'email': 'hello@home-assistant.io',
|
||||
'custom:sub-exp': dt_util.utcnow().date().isoformat()
|
||||
|
@ -436,9 +414,9 @@ async def test_websocket_subscription_no_reconnect_if_connected(
|
|||
client = await hass_ws_client(hass)
|
||||
|
||||
with patch(
|
||||
'homeassistant.components.cloud.auth_api.renew_access_token'
|
||||
'hass_nabucasa.auth.CognitoAuth.renew_access_token'
|
||||
) as mock_renew, patch(
|
||||
'homeassistant.components.cloud.iot.CloudIoT.connect'
|
||||
'hass_nabucasa.iot.CloudIoT.connect'
|
||||
) as mock_connect:
|
||||
await client.send_json({
|
||||
'id': 5,
|
||||
|
@ -464,9 +442,9 @@ async def test_websocket_subscription_no_reconnect_if_expired(
|
|||
client = await hass_ws_client(hass)
|
||||
|
||||
with patch(
|
||||
'homeassistant.components.cloud.auth_api.renew_access_token'
|
||||
'hass_nabucasa.auth.CognitoAuth.renew_access_token'
|
||||
) as mock_renew, patch(
|
||||
'homeassistant.components.cloud.iot.CloudIoT.connect'
|
||||
'hass_nabucasa.iot.CloudIoT.connect'
|
||||
) as mock_connect:
|
||||
await client.send_json({
|
||||
'id': 5,
|
||||
|
@ -503,7 +481,7 @@ async def test_websocket_subscription_fail(hass, hass_ws_client,
|
|||
async def test_websocket_subscription_not_logged_in(hass, hass_ws_client):
|
||||
"""Test querying the status."""
|
||||
client = await hass_ws_client(hass)
|
||||
with patch('homeassistant.components.cloud.Cloud.fetch_subscription_info',
|
||||
with patch('hass_nabucasa.Cloud.fetch_subscription_info',
|
||||
return_value=mock_coro({'return': 'value'})):
|
||||
await client.send_json({
|
||||
'id': 5,
|
||||
|
@ -548,8 +526,10 @@ async def test_enabling_webhook(hass, hass_ws_client, setup_api):
|
|||
'custom:sub-exp': '2018-01-03'
|
||||
}, 'test')
|
||||
client = await hass_ws_client(hass)
|
||||
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks'
|
||||
'.async_create', return_value=mock_coro()) as mock_enable:
|
||||
with patch(
|
||||
'hass_nabucasa.cloudhooks.Cloudhooks.async_create',
|
||||
return_value=mock_coro()
|
||||
) as mock_enable:
|
||||
await client.send_json({
|
||||
'id': 5,
|
||||
'type': 'cloud/cloudhook/create',
|
||||
|
@ -569,8 +549,10 @@ async def test_disabling_webhook(hass, hass_ws_client, setup_api):
|
|||
'custom:sub-exp': '2018-01-03'
|
||||
}, 'test')
|
||||
client = await hass_ws_client(hass)
|
||||
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks'
|
||||
'.async_delete', return_value=mock_coro()) as mock_disable:
|
||||
with patch(
|
||||
'hass_nabucasa.cloudhooks.Cloudhooks.async_delete',
|
||||
return_value=mock_coro()
|
||||
) as mock_disable:
|
||||
await client.send_json({
|
||||
'id': 5,
|
||||
'type': 'cloud/cloudhook/delete',
|
||||
|
|
|
@ -1,72 +1,34 @@
|
|||
"""Test the cloud component."""
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
|
||||
from homeassistant.components import cloud
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.components.cloud.const import DOMAIN
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_os():
|
||||
"""Mock os module."""
|
||||
with patch('homeassistant.components.cloud.os') as os:
|
||||
os.path.isdir.return_value = True
|
||||
yield os
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_constructor_loads_info_from_constant():
|
||||
async def test_constructor_loads_info_from_config():
|
||||
"""Test non-dev mode loads info from SERVERS constant."""
|
||||
hass = MagicMock(data={})
|
||||
with patch.dict(cloud.SERVERS, {
|
||||
'beer': {
|
||||
'cognito_client_id': 'test-cognito_client_id',
|
||||
'user_pool_id': 'test-user_pool_id',
|
||||
'region': 'test-region',
|
||||
'relayer': 'test-relayer',
|
||||
'google_actions_sync_url': 'test-google_actions_sync_url',
|
||||
'subscription_info_url': 'test-subscription-info-url',
|
||||
'cloudhook_create_url': 'test-cloudhook_create_url',
|
||||
}
|
||||
}):
|
||||
result = yield from cloud.async_setup(hass, {
|
||||
'cloud': {cloud.CONF_MODE: 'beer'}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.cloud.prefs.CloudPreferences."
|
||||
"async_initialize",
|
||||
return_value=mock_coro()
|
||||
):
|
||||
result = await cloud.async_setup(hass, {
|
||||
'cloud': {
|
||||
cloud.CONF_MODE: cloud.MODE_DEV,
|
||||
'cognito_client_id': 'test-cognito_client_id',
|
||||
'user_pool_id': 'test-user_pool_id',
|
||||
'region': 'test-region',
|
||||
'relayer': 'test-relayer',
|
||||
}
|
||||
})
|
||||
assert result
|
||||
|
||||
cl = hass.data['cloud']
|
||||
assert cl.mode == 'beer'
|
||||
assert cl.cognito_client_id == 'test-cognito_client_id'
|
||||
assert cl.user_pool_id == 'test-user_pool_id'
|
||||
assert cl.region == 'test-region'
|
||||
assert cl.relayer == 'test-relayer'
|
||||
assert cl.google_actions_sync_url == 'test-google_actions_sync_url'
|
||||
assert cl.subscription_info_url == 'test-subscription-info-url'
|
||||
assert cl.cloudhook_create_url == 'test-cloudhook_create_url'
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_constructor_loads_info_from_config():
|
||||
"""Test non-dev mode loads info from SERVERS constant."""
|
||||
hass = MagicMock(data={})
|
||||
|
||||
result = yield from cloud.async_setup(hass, {
|
||||
'cloud': {
|
||||
cloud.CONF_MODE: cloud.MODE_DEV,
|
||||
'cognito_client_id': 'test-cognito_client_id',
|
||||
'user_pool_id': 'test-user_pool_id',
|
||||
'region': 'test-region',
|
||||
'relayer': 'test-relayer',
|
||||
}
|
||||
})
|
||||
assert result
|
||||
|
||||
cl = hass.data['cloud']
|
||||
assert cl.mode == cloud.MODE_DEV
|
||||
assert cl.cognito_client_id == 'test-cognito_client_id'
|
||||
|
@ -75,195 +37,41 @@ def test_constructor_loads_info_from_config():
|
|||
assert cl.relayer == 'test-relayer'
|
||||
|
||||
|
||||
async def test_initialize_loads_info(mock_os, hass):
|
||||
"""Test initialize will load info from config file."""
|
||||
mock_os.path.isfile.return_value = True
|
||||
mopen = mock_open(read_data=json.dumps({
|
||||
'id_token': 'test-id-token',
|
||||
'access_token': 'test-access-token',
|
||||
'refresh_token': 'test-refresh-token',
|
||||
}))
|
||||
async def test_remote_services(hass, mock_cloud_fixture):
|
||||
"""Setup cloud component and test services."""
|
||||
assert hass.services.has_service(DOMAIN, 'remote_connect')
|
||||
assert hass.services.has_service(DOMAIN, 'remote_disconnect')
|
||||
|
||||
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
|
||||
cl.iot = MagicMock()
|
||||
cl.iot.connect.return_value = mock_coro()
|
||||
with patch(
|
||||
"hass_nabucasa.remote.RemoteUI.connect", return_value=mock_coro()
|
||||
) as mock_connect:
|
||||
await hass.services.async_call(DOMAIN, "remote_connect", blocking=True)
|
||||
|
||||
with patch('homeassistant.components.cloud.open', mopen, create=True), \
|
||||
patch('homeassistant.components.cloud.Cloud._decode_claims'):
|
||||
await cl.async_start(None)
|
||||
assert mock_connect.called
|
||||
|
||||
assert cl.id_token == 'test-id-token'
|
||||
assert cl.access_token == 'test-access-token'
|
||||
assert cl.refresh_token == 'test-refresh-token'
|
||||
assert len(cl.iot.connect.mock_calls) == 1
|
||||
with patch(
|
||||
"hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro()
|
||||
) as mock_disconnect:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "remote_disconnect", blocking=True)
|
||||
|
||||
assert mock_disconnect.called
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_logout_clears_info(mock_os, hass):
|
||||
"""Test logging out disconnects and removes info."""
|
||||
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
|
||||
cl.iot = MagicMock()
|
||||
cl.iot.disconnect.return_value = mock_coro()
|
||||
async def test_startup_shutdown_events(hass, mock_cloud_fixture):
|
||||
"""Test if the cloud will start on startup event."""
|
||||
with patch(
|
||||
"hass_nabucasa.Cloud.start", return_value=mock_coro()
|
||||
) as mock_start:
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
yield from cl.logout()
|
||||
assert mock_start.called
|
||||
|
||||
assert len(cl.iot.disconnect.mock_calls) == 1
|
||||
assert cl.id_token is None
|
||||
assert cl.access_token is None
|
||||
assert cl.refresh_token is None
|
||||
assert len(mock_os.remove.mock_calls) == 1
|
||||
with patch(
|
||||
"hass_nabucasa.Cloud.stop", return_value=mock_coro()
|
||||
) as mock_stop:
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_write_user_info():
|
||||
"""Test writing user info works."""
|
||||
mopen = mock_open()
|
||||
|
||||
cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV, None, None)
|
||||
cl.id_token = 'test-id-token'
|
||||
cl.access_token = 'test-access-token'
|
||||
cl.refresh_token = 'test-refresh-token'
|
||||
|
||||
with patch('homeassistant.components.cloud.open', mopen, create=True):
|
||||
cl.write_user_info()
|
||||
|
||||
handle = mopen()
|
||||
|
||||
assert len(handle.write.mock_calls) == 1
|
||||
data = json.loads(handle.write.mock_calls[0][1][0])
|
||||
assert data == {
|
||||
'access_token': 'test-access-token',
|
||||
'id_token': 'test-id-token',
|
||||
'refresh_token': 'test-refresh-token',
|
||||
}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_subscription_expired(hass):
|
||||
"""Test subscription being expired after 3 days of expiration."""
|
||||
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
|
||||
token_val = {
|
||||
'custom:sub-exp': '2017-11-13'
|
||||
}
|
||||
with patch.object(cl, '_decode_claims', return_value=token_val), \
|
||||
patch('homeassistant.util.dt.utcnow',
|
||||
return_value=utcnow().replace(year=2017, month=11, day=13)):
|
||||
assert not cl.subscription_expired
|
||||
|
||||
with patch.object(cl, '_decode_claims', return_value=token_val), \
|
||||
patch('homeassistant.util.dt.utcnow',
|
||||
return_value=utcnow().replace(
|
||||
year=2017, month=11, day=19, hour=23, minute=59,
|
||||
second=59)):
|
||||
assert not cl.subscription_expired
|
||||
|
||||
with patch.object(cl, '_decode_claims', return_value=token_val), \
|
||||
patch('homeassistant.util.dt.utcnow',
|
||||
return_value=utcnow().replace(
|
||||
year=2017, month=11, day=20, hour=0, minute=0,
|
||||
second=0)):
|
||||
assert cl.subscription_expired
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_subscription_not_expired(hass):
|
||||
"""Test subscription not being expired."""
|
||||
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
|
||||
token_val = {
|
||||
'custom:sub-exp': '2017-11-13'
|
||||
}
|
||||
with patch.object(cl, '_decode_claims', return_value=token_val), \
|
||||
patch('homeassistant.util.dt.utcnow',
|
||||
return_value=utcnow().replace(year=2017, month=11, day=9)):
|
||||
assert not cl.subscription_expired
|
||||
|
||||
|
||||
async def test_create_cloudhook_no_login(hass):
|
||||
"""Test create cloudhook when not logged in."""
|
||||
assert await async_setup_component(hass, 'cloud', {})
|
||||
coro = mock_coro({'yo': 'hey'})
|
||||
with patch('homeassistant.components.cloud.cloudhooks.'
|
||||
'Cloudhooks.async_create', return_value=coro) as mock_create, \
|
||||
pytest.raises(cloud.CloudNotAvailable):
|
||||
await hass.components.cloud.async_create_cloudhook('hello')
|
||||
|
||||
assert len(mock_create.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_delete_cloudhook_no_setup(hass):
|
||||
"""Test delete cloudhook when not logged in."""
|
||||
coro = mock_coro()
|
||||
with patch('homeassistant.components.cloud.cloudhooks.'
|
||||
'Cloudhooks.async_delete', return_value=coro) as mock_delete, \
|
||||
pytest.raises(cloud.CloudNotAvailable):
|
||||
await hass.components.cloud.async_delete_cloudhook('hello')
|
||||
|
||||
assert len(mock_delete.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_create_cloudhook(hass):
|
||||
"""Test create cloudhook."""
|
||||
assert await async_setup_component(hass, 'cloud', {})
|
||||
coro = mock_coro({'cloudhook_url': 'hello'})
|
||||
with patch('homeassistant.components.cloud.cloudhooks.'
|
||||
'Cloudhooks.async_create', return_value=coro) as mock_create, \
|
||||
patch('homeassistant.components.cloud.async_is_logged_in',
|
||||
return_value=True):
|
||||
result = await hass.components.cloud.async_create_cloudhook('hello')
|
||||
|
||||
assert result == 'hello'
|
||||
assert len(mock_create.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_delete_cloudhook(hass):
|
||||
"""Test delete cloudhook."""
|
||||
assert await async_setup_component(hass, 'cloud', {})
|
||||
coro = mock_coro()
|
||||
with patch('homeassistant.components.cloud.cloudhooks.'
|
||||
'Cloudhooks.async_delete', return_value=coro) as mock_delete, \
|
||||
patch('homeassistant.components.cloud.async_is_logged_in',
|
||||
return_value=True):
|
||||
await hass.components.cloud.async_delete_cloudhook('hello')
|
||||
|
||||
assert len(mock_delete.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_async_logged_in(hass):
|
||||
"""Test if is_logged_in works."""
|
||||
# Cloud not loaded
|
||||
assert hass.components.cloud.async_is_logged_in() is False
|
||||
|
||||
assert await async_setup_component(hass, 'cloud', {})
|
||||
|
||||
# Cloud loaded, not logged in
|
||||
assert hass.components.cloud.async_is_logged_in() is False
|
||||
|
||||
hass.data['cloud'].id_token = "some token"
|
||||
|
||||
# Cloud loaded, logged in
|
||||
assert hass.components.cloud.async_is_logged_in() is True
|
||||
|
||||
|
||||
async def test_async_active_subscription(hass):
|
||||
"""Test if is_logged_in works."""
|
||||
# Cloud not loaded
|
||||
assert hass.components.cloud.async_active_subscription() is False
|
||||
|
||||
assert await async_setup_component(hass, 'cloud', {})
|
||||
|
||||
# Cloud loaded, not logged in
|
||||
assert hass.components.cloud.async_active_subscription() is False
|
||||
|
||||
hass.data['cloud'].id_token = "some token"
|
||||
|
||||
# Cloud loaded, logged in, invalid sub
|
||||
with patch('jose.jwt.get_unverified_claims', return_value={
|
||||
'custom:sub-exp': '{}-12-31'.format(utcnow().year - 1)
|
||||
}):
|
||||
assert hass.components.cloud.async_active_subscription() is False
|
||||
|
||||
# Cloud loaded, logged in, valid sub
|
||||
with patch('jose.jwt.get_unverified_claims', return_value={
|
||||
'custom:sub-exp': '{}-01-01'.format(utcnow().year + 1)
|
||||
}):
|
||||
assert hass.components.cloud.async_active_subscription() is True
|
||||
assert mock_stop.called
|
||||
|
|
|
@ -1,500 +0,0 @@
|
|||
"""Test the cloud.iot module."""
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock, PropertyMock
|
||||
|
||||
from aiohttp import WSMsgType, client_exceptions, web
|
||||
import pytest
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components.cloud import (
|
||||
Cloud, iot, auth_api, MODE_DEV)
|
||||
from homeassistant.components.cloud.const import (
|
||||
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
|
||||
from tests.components.alexa import test_smart_home as test_alexa
|
||||
from tests.common import mock_coro
|
||||
|
||||
from . import mock_cloud_prefs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Mock the IoT client."""
|
||||
client = MagicMock()
|
||||
type(client).closed = PropertyMock(side_effect=[False, True])
|
||||
|
||||
# Trigger cancelled error to avoid reconnect.
|
||||
with patch('asyncio.sleep', side_effect=asyncio.CancelledError), \
|
||||
patch('homeassistant.components.cloud.iot'
|
||||
'.async_get_clientsession') as session:
|
||||
session().ws_connect.return_value = mock_coro(client)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_handle_message():
|
||||
"""Mock handle message."""
|
||||
with patch('homeassistant.components.cloud.iot'
|
||||
'.async_handle_message') as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cloud():
|
||||
"""Mock cloud class."""
|
||||
return MagicMock(subscription_expired=False)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud):
|
||||
"""Test we call handle message with correct info."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.return_value = mock_coro(MagicMock(
|
||||
type=WSMsgType.text,
|
||||
json=MagicMock(return_value={
|
||||
'msgid': 'test-msg-id',
|
||||
'handler': 'test-handler',
|
||||
'payload': 'test-payload'
|
||||
})
|
||||
))
|
||||
mock_handle_message.return_value = mock_coro('response')
|
||||
mock_client.send_json.return_value = mock_coro(None)
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
# Check that we sent message to handler correctly
|
||||
assert len(mock_handle_message.mock_calls) == 1
|
||||
p_hass, p_cloud, handler_name, payload = \
|
||||
mock_handle_message.mock_calls[0][1]
|
||||
|
||||
assert p_hass is mock_cloud.hass
|
||||
assert p_cloud is mock_cloud
|
||||
assert handler_name == 'test-handler'
|
||||
assert payload == 'test-payload'
|
||||
|
||||
# Check that we forwarded response from handler to cloud
|
||||
assert len(mock_client.send_json.mock_calls) == 1
|
||||
assert mock_client.send_json.mock_calls[0][1][0] == {
|
||||
'msgid': 'test-msg-id',
|
||||
'payload': 'response'
|
||||
}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_connection_msg_for_unknown_handler(mock_client, mock_cloud):
|
||||
"""Test a msg for an unknown handler."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.return_value = mock_coro(MagicMock(
|
||||
type=WSMsgType.text,
|
||||
json=MagicMock(return_value={
|
||||
'msgid': 'test-msg-id',
|
||||
'handler': 'non-existing-handler',
|
||||
'payload': 'test-payload'
|
||||
})
|
||||
))
|
||||
mock_client.send_json.return_value = mock_coro(None)
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
# Check that we sent the correct error
|
||||
assert len(mock_client.send_json.mock_calls) == 1
|
||||
assert mock_client.send_json.mock_calls[0][1][0] == {
|
||||
'msgid': 'test-msg-id',
|
||||
'error': 'unknown-handler',
|
||||
}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_connection_msg_for_handler_raising(mock_client, mock_handle_message,
|
||||
mock_cloud):
|
||||
"""Test we sent error when handler raises exception."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.return_value = mock_coro(MagicMock(
|
||||
type=WSMsgType.text,
|
||||
json=MagicMock(return_value={
|
||||
'msgid': 'test-msg-id',
|
||||
'handler': 'test-handler',
|
||||
'payload': 'test-payload'
|
||||
})
|
||||
))
|
||||
mock_handle_message.side_effect = Exception('Broken')
|
||||
mock_client.send_json.return_value = mock_coro(None)
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
# Check that we sent the correct error
|
||||
assert len(mock_client.send_json.mock_calls) == 1
|
||||
assert mock_client.send_json.mock_calls[0][1][0] == {
|
||||
'msgid': 'test-msg-id',
|
||||
'error': 'exception',
|
||||
}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_handler_forwarding():
|
||||
"""Test we forward messages to correct handler."""
|
||||
handler = MagicMock()
|
||||
handler.return_value = mock_coro()
|
||||
hass = object()
|
||||
cloud = object()
|
||||
with patch.dict(iot.HANDLERS, {'test': handler}):
|
||||
yield from iot.async_handle_message(
|
||||
hass, cloud, 'test', 'payload')
|
||||
|
||||
assert len(handler.mock_calls) == 1
|
||||
r_hass, r_cloud, payload = handler.mock_calls[0][1]
|
||||
assert r_hass is hass
|
||||
assert r_cloud is cloud
|
||||
assert payload == 'payload'
|
||||
|
||||
|
||||
async def test_handling_core_messages_logout(hass, mock_cloud):
|
||||
"""Test handling core messages."""
|
||||
mock_cloud.logout.return_value = mock_coro()
|
||||
await iot.async_handle_cloud(hass, mock_cloud, {
|
||||
'action': 'logout',
|
||||
'reason': 'Logged in at two places.'
|
||||
})
|
||||
assert len(mock_cloud.logout.mock_calls) == 1
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud):
|
||||
"""Test server disconnecting instance."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.return_value = mock_coro(MagicMock(
|
||||
type=WSMsgType.CLOSING,
|
||||
))
|
||||
|
||||
with patch('asyncio.sleep', side_effect=[None, asyncio.CancelledError]):
|
||||
yield from conn.connect()
|
||||
|
||||
assert 'Connection closed' in caplog.text
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud):
|
||||
"""Test server disconnecting instance."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.return_value = mock_coro(MagicMock(
|
||||
type=WSMsgType.BINARY,
|
||||
))
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
assert 'Connection closed: Received non-Text message' in caplog.text
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud):
|
||||
"""Test cloud sending invalid JSON."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.return_value = mock_coro(MagicMock(
|
||||
type=WSMsgType.TEXT,
|
||||
json=MagicMock(side_effect=ValueError)
|
||||
))
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
assert 'Connection closed: Received invalid JSON.' in caplog.text
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_check_token_raising(mock_client, caplog, mock_cloud):
|
||||
"""Test cloud unable to check token."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_cloud.hass.async_add_job.side_effect = auth_api.CloudError("BLA")
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
assert 'Unable to refresh token: BLA' in caplog.text
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_connect_invalid_auth(mock_client, caplog, mock_cloud):
|
||||
"""Test invalid auth detected by server."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.side_effect = \
|
||||
client_exceptions.WSServerHandshakeError(None, None, status=401)
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
assert 'Connection closed: Invalid auth.' in caplog.text
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_unable_to_connect(mock_client, caplog, mock_cloud):
|
||||
"""Test unable to connect error."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.side_effect = client_exceptions.ClientError(None, None)
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
assert 'Unable to connect:' in caplog.text
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_random_exception(mock_client, caplog, mock_cloud):
|
||||
"""Test random exception."""
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
mock_client.receive.side_effect = Exception
|
||||
|
||||
yield from conn.connect()
|
||||
|
||||
assert 'Unexpected error' in caplog.text
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_refresh_token_before_expiration_fails(hass, mock_cloud):
|
||||
"""Test that we don't connect if token is expired."""
|
||||
mock_cloud.subscription_expired = True
|
||||
mock_cloud.hass = hass
|
||||
conn = iot.CloudIoT(mock_cloud)
|
||||
|
||||
with patch('homeassistant.components.cloud.auth_api.check_token',
|
||||
return_value=mock_coro()) as mock_check_token, \
|
||||
patch.object(hass.components.persistent_notification,
|
||||
'async_create') as mock_create:
|
||||
yield from conn.connect()
|
||||
|
||||
assert len(mock_check_token.mock_calls) == 1
|
||||
assert len(mock_create.mock_calls) == 1
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_handler_alexa(hass):
|
||||
"""Test handler Alexa."""
|
||||
hass.states.async_set(
|
||||
'switch.test', 'on', {'friendly_name': "Test switch"})
|
||||
hass.states.async_set(
|
||||
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
|
||||
|
||||
with patch('homeassistant.components.cloud.Cloud.async_start',
|
||||
return_value=mock_coro()):
|
||||
setup = yield from async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'alexa': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'description': 'Config description',
|
||||
'display_categories': 'LIGHT'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
assert setup
|
||||
|
||||
mock_cloud_prefs(hass)
|
||||
|
||||
resp = yield from iot.async_handle_alexa(
|
||||
hass, hass.data['cloud'],
|
||||
test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
|
||||
|
||||
endpoints = resp['event']['payload']['endpoints']
|
||||
|
||||
assert len(endpoints) == 1
|
||||
device = endpoints[0]
|
||||
|
||||
assert device['description'] == 'Config description'
|
||||
assert device['friendlyName'] == 'Config name'
|
||||
assert device['displayCategories'] == ['LIGHT']
|
||||
assert device['manufacturerName'] == 'Home Assistant'
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_handler_alexa_disabled(hass, mock_cloud_fixture):
|
||||
"""Test handler Alexa when user has disabled it."""
|
||||
mock_cloud_fixture[PREF_ENABLE_ALEXA] = False
|
||||
|
||||
resp = yield from iot.async_handle_alexa(
|
||||
hass, hass.data['cloud'],
|
||||
test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
|
||||
|
||||
assert resp['event']['header']['namespace'] == 'Alexa'
|
||||
assert resp['event']['header']['name'] == 'ErrorResponse'
|
||||
assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE'
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_handler_google_actions(hass):
|
||||
"""Test handler Google Actions."""
|
||||
hass.states.async_set(
|
||||
'switch.test', 'on', {'friendly_name': "Test switch"})
|
||||
hass.states.async_set(
|
||||
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
|
||||
hass.states.async_set(
|
||||
'group.all_locks', 'on', {'friendly_name': "Evil locks"})
|
||||
|
||||
with patch('homeassistant.components.cloud.Cloud.async_start',
|
||||
return_value=mock_coro()):
|
||||
setup = yield from async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'google_actions': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'aliases': 'Config alias',
|
||||
'room': 'living room'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
assert setup
|
||||
|
||||
mock_cloud_prefs(hass)
|
||||
|
||||
reqid = '5711642932632160983'
|
||||
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
|
||||
|
||||
with patch('homeassistant.components.cloud.Cloud._decode_claims',
|
||||
return_value={'cognito:username': 'myUserName'}):
|
||||
resp = yield from iot.async_handle_google_actions(
|
||||
hass, hass.data['cloud'], data)
|
||||
|
||||
assert resp['requestId'] == reqid
|
||||
payload = resp['payload']
|
||||
|
||||
assert payload['agentUserId'] == 'myUserName'
|
||||
|
||||
devices = payload['devices']
|
||||
assert len(devices) == 1
|
||||
|
||||
device = devices[0]
|
||||
assert device['id'] == 'switch.test'
|
||||
assert device['name']['name'] == 'Config name'
|
||||
assert device['name']['nicknames'] == ['Config alias']
|
||||
assert device['type'] == 'action.devices.types.SWITCH'
|
||||
assert device['roomHint'] == 'living room'
|
||||
|
||||
|
||||
async def test_handler_google_actions_disabled(hass, mock_cloud_fixture):
|
||||
"""Test handler Google Actions when user has disabled it."""
|
||||
mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False
|
||||
|
||||
with patch('homeassistant.components.cloud.Cloud.async_start',
|
||||
return_value=mock_coro()):
|
||||
assert await async_setup_component(hass, 'cloud', {})
|
||||
|
||||
reqid = '5711642932632160983'
|
||||
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
|
||||
|
||||
resp = await iot.async_handle_google_actions(
|
||||
hass, hass.data['cloud'], data)
|
||||
|
||||
assert resp['requestId'] == reqid
|
||||
assert resp['payload']['errorCode'] == 'deviceTurnedOff'
|
||||
|
||||
|
||||
async def test_refresh_token_expired(hass):
|
||||
"""Test handling Unauthenticated error raised if refresh token expired."""
|
||||
cloud = Cloud(hass, MODE_DEV, None, None)
|
||||
|
||||
with patch('homeassistant.components.cloud.auth_api.check_token',
|
||||
side_effect=auth_api.Unauthenticated) as mock_check_token, \
|
||||
patch.object(hass.components.persistent_notification,
|
||||
'async_create') as mock_create:
|
||||
await cloud.iot.connect()
|
||||
|
||||
assert len(mock_check_token.mock_calls) == 1
|
||||
assert len(mock_create.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_webhook_msg(hass):
|
||||
"""Test webhook msg."""
|
||||
cloud = Cloud(hass, MODE_DEV, None, None)
|
||||
await cloud.prefs.async_initialize()
|
||||
await cloud.prefs.async_update(cloudhooks={
|
||||
'hello': {
|
||||
'webhook_id': 'mock-webhook-id',
|
||||
'cloudhook_id': 'mock-cloud-id'
|
||||
}
|
||||
})
|
||||
|
||||
received = []
|
||||
|
||||
async def handler(hass, webhook_id, request):
|
||||
"""Handle a webhook."""
|
||||
received.append(request)
|
||||
return web.json_response({'from': 'handler'})
|
||||
|
||||
hass.components.webhook.async_register(
|
||||
'test', 'Test', 'mock-webhook-id', handler)
|
||||
|
||||
response = await iot.async_handle_webhook(hass, cloud, {
|
||||
'cloudhook_id': 'mock-cloud-id',
|
||||
'body': '{"hello": "world"}',
|
||||
'headers': {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
'method': 'POST',
|
||||
'query': None,
|
||||
})
|
||||
|
||||
assert response == {
|
||||
'status': 200,
|
||||
'body': '{"from": "handler"}',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
assert len(received) == 1
|
||||
assert await received[0].json() == {
|
||||
'hello': 'world'
|
||||
}
|
||||
|
||||
|
||||
async def test_send_message_not_connected(mock_cloud):
|
||||
"""Test sending a message that expects no answer."""
|
||||
cloud_iot = iot.CloudIoT(mock_cloud)
|
||||
|
||||
with pytest.raises(iot.NotConnected):
|
||||
await cloud_iot.async_send_message('webhook', {'msg': 'yo'})
|
||||
|
||||
|
||||
async def test_send_message_no_answer(mock_cloud):
|
||||
"""Test sending a message that expects no answer."""
|
||||
cloud_iot = iot.CloudIoT(mock_cloud)
|
||||
cloud_iot.state = iot.STATE_CONNECTED
|
||||
cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro()))
|
||||
|
||||
await cloud_iot.async_send_message('webhook', {'msg': 'yo'},
|
||||
expect_answer=False)
|
||||
assert not cloud_iot._response_handler
|
||||
assert len(cloud_iot.client.send_json.mock_calls) == 1
|
||||
msg = cloud_iot.client.send_json.mock_calls[0][1][0]
|
||||
assert msg['handler'] == 'webhook'
|
||||
assert msg['payload'] == {'msg': 'yo'}
|
||||
|
||||
|
||||
async def test_send_message_answer(loop, mock_cloud):
|
||||
"""Test sending a message that expects no answer."""
|
||||
cloud_iot = iot.CloudIoT(mock_cloud)
|
||||
cloud_iot.state = iot.STATE_CONNECTED
|
||||
cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro()))
|
||||
|
||||
uuid = 5
|
||||
|
||||
with patch('homeassistant.components.cloud.iot.uuid.uuid4',
|
||||
return_value=MagicMock(hex=uuid)):
|
||||
send_task = loop.create_task(cloud_iot.async_send_message(
|
||||
'webhook', {'msg': 'yo'}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(cloud_iot.client.send_json.mock_calls) == 1
|
||||
assert len(cloud_iot._response_handler) == 1
|
||||
msg = cloud_iot.client.send_json.mock_calls[0][1][0]
|
||||
assert msg['handler'] == 'webhook'
|
||||
assert msg['payload'] == {'msg': 'yo'}
|
||||
|
||||
cloud_iot._response_handler[uuid].set_result({'response': True})
|
||||
response = await send_task
|
||||
assert response == {'response': True}
|
Loading…
Reference in New Issue