Compare commits
5 commits
dev
...
cloud-conn
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f09b9f52a9 | ||
![]() |
e8deedbfa0 | ||
![]() |
3c21bb7cd0 | ||
![]() |
88790f89ba | ||
![]() |
e5e5a30ade |
14 changed files with 614 additions and 256 deletions
|
@ -1,47 +1,149 @@
|
|||
"""Component to integrate the Home Assistant cloud."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from . import http_api, auth_api
|
||||
from .const import DOMAIN
|
||||
from . import http_api, cloud_api, iot
|
||||
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
||||
|
||||
|
||||
REQUIREMENTS = ['warrant==0.2.0']
|
||||
REQUIREMENTS = ['warrant==0.2.0', 'AWSIoTPythonSDK==1.2.0']
|
||||
DEPENDENCIES = ['http']
|
||||
CONF_MODE = 'mode'
|
||||
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
|
||||
CONF_USER_POOL_ID = 'user_pool_id'
|
||||
CONF_REGION = 'region'
|
||||
CONF_API_BASE = 'api_base'
|
||||
CONF_IOT_ENDPOINT = 'iot_endpoint'
|
||||
MODE_DEV = 'development'
|
||||
MODE_STAGING = 'staging'
|
||||
MODE_PRODUCTION = 'production'
|
||||
DEFAULT_MODE = MODE_DEV
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
|
||||
vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]),
|
||||
vol.In([MODE_DEV] + list(SERVERS)),
|
||||
# Change to optional when we include real servers
|
||||
vol.Required(CONF_COGNITO_CLIENT_ID): str,
|
||||
vol.Required(CONF_USER_POOL_ID): str,
|
||||
vol.Required(CONF_REGION): str,
|
||||
vol.Required(CONF_API_BASE): str,
|
||||
vol.Required(CONF_IOT_ENDPOINT): str,
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Initialize the Home Assistant cloud."""
|
||||
mode = MODE_PRODUCTION
|
||||
|
||||
if DOMAIN in config:
|
||||
mode = config[DOMAIN].get(CONF_MODE)
|
||||
|
||||
if mode != 'development':
|
||||
_LOGGER.error('Only development mode is currently allowed.')
|
||||
return False
|
||||
|
||||
data = hass.data[DOMAIN] = {
|
||||
'mode': mode
|
||||
}
|
||||
|
||||
data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass)
|
||||
kwargs = config[DOMAIN]
|
||||
else:
|
||||
kwargs = {CONF_MODE: DEFAULT_MODE}
|
||||
|
||||
cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs)
|
||||
yield from hass.async_add_job(cloud.initialize)
|
||||
yield from http_api.async_setup(hass)
|
||||
return True
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""Store the configuration of the cloud connection."""
|
||||
|
||||
def __init__(self, hass, mode, cognito_client_id=None, user_pool_id=None,
|
||||
region=None, api_base=None, iot_endpoint=None):
|
||||
"""Create an instance of Cloud."""
|
||||
self.hass = hass
|
||||
self.mode = mode
|
||||
self.email = None
|
||||
self.thing_name = None
|
||||
self.id_token = None
|
||||
self.refresh_token = None
|
||||
self.iot = iot.CloudIoT(self)
|
||||
self.api = cloud_api.CloudApi(self)
|
||||
|
||||
if mode == MODE_DEV:
|
||||
self.cognito_client_id = cognito_client_id
|
||||
self.user_pool_id = user_pool_id
|
||||
self.region = region
|
||||
self.api_base = api_base
|
||||
self.iot_endpoint = iot_endpoint
|
||||
|
||||
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.api_base = info['api_base']
|
||||
self.iot_endpoint = info['iot_endpoint']
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
"""Get if cloud is logged in."""
|
||||
return self.email is not None
|
||||
|
||||
@property
|
||||
def certificate_pem_path(self):
|
||||
"""Get path to certificate pem."""
|
||||
return self.path('{}_iot_certificate.pem'.format(self.mode))
|
||||
|
||||
@property
|
||||
def secret_key_path(self):
|
||||
"""Get path to public key."""
|
||||
return self.path('{}_iot_secret.key'.format(self.mode))
|
||||
|
||||
@property
|
||||
def user_info_path(self):
|
||||
"""Get path to the stored auth."""
|
||||
return self.path('{}_auth.json'.format(self.mode))
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize and load cloud info."""
|
||||
# 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 os.path.isfile(user_info):
|
||||
with open(user_info, 'rt') as file:
|
||||
info = json.loads(file.read())
|
||||
self.email = info['email']
|
||||
self.thing_name = info['thing_name']
|
||||
self.id_token = info['id_token']
|
||||
self.refresh_token = info['refresh_token']
|
||||
self.iot.connect()
|
||||
|
||||
def path(self, *parts):
|
||||
"""Get config path inside cloud dir."""
|
||||
return self.hass.config.path(CONFIG_DIR, *parts)
|
||||
|
||||
def logout(self):
|
||||
"""Close connection and remove all credentials."""
|
||||
self.iot.disconnect()
|
||||
|
||||
self.email = None
|
||||
self.thing_name = None
|
||||
self.id_token = None
|
||||
self.refresh_token = None
|
||||
|
||||
for file in (self.certificate_pem_path, self.secret_key_path,
|
||||
self.user_info_path):
|
||||
try:
|
||||
os.remove(file)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def write_user_info(self):
|
||||
"""Write user info to a file."""
|
||||
with open(self.user_info_path, 'wt') as file:
|
||||
file.write(json.dumps({
|
||||
'email': self.email,
|
||||
'thing_name': self.thing_name,
|
||||
'id_token': self.id_token,
|
||||
'refresh_token': self.refresh_token,
|
||||
}, indent=4))
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
"""Package to offer tools to authenticate with the cloud."""
|
||||
import json
|
||||
"""Package to communicate with the authentication API."""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .const import AUTH_FILE, SERVERS
|
||||
from .util import get_mode
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -61,210 +60,138 @@ def _map_aws_exception(err):
|
|||
return ex(err.response['Error']['Message'])
|
||||
|
||||
|
||||
def load_auth(hass):
|
||||
"""Load authentication from disk and verify it."""
|
||||
info = _read_info(hass)
|
||||
|
||||
if info is None:
|
||||
return Auth(hass)
|
||||
|
||||
auth = Auth(hass, _cognito(
|
||||
hass,
|
||||
id_token=info['id_token'],
|
||||
access_token=info['access_token'],
|
||||
refresh_token=info['refresh_token'],
|
||||
))
|
||||
|
||||
if auth.validate_auth():
|
||||
return auth
|
||||
|
||||
return Auth(hass)
|
||||
def _generate_username(email):
|
||||
"""Generate a username from an email address."""
|
||||
return hashlib.sha512(email.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def register(hass, email, password):
|
||||
def register(cloud, email, password):
|
||||
"""Register a new account."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud)
|
||||
try:
|
||||
cognito.register(email, password)
|
||||
cognito.register(_generate_username(email), password, email=email)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_register(hass, confirmation_code, email):
|
||||
def confirm_register(cloud, confirmation_code, email):
|
||||
"""Confirm confirmation code after registration."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud)
|
||||
try:
|
||||
cognito.confirm_sign_up(confirmation_code, email)
|
||||
cognito.confirm_sign_up(confirmation_code, _generate_username(email))
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def forgot_password(hass, email):
|
||||
def forgot_password(cloud, email):
|
||||
"""Initiate forgotten password flow."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud, username=_generate_username(email))
|
||||
try:
|
||||
cognito.initiate_forgot_password()
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_forgot_password(hass, confirmation_code, email, new_password):
|
||||
def confirm_forgot_password(cloud, confirmation_code, email, new_password):
|
||||
"""Confirm forgotten password code and change password."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud, username=_generate_username(email))
|
||||
try:
|
||||
cognito.confirm_forgot_password(confirmation_code, new_password)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
class Auth(object):
|
||||
"""Class that holds Cloud authentication."""
|
||||
def login(cloud, email, password):
|
||||
"""Log user in and fetch certificate."""
|
||||
cognito = _authenticate(cloud, email, password)
|
||||
cloud.id_token = cognito.id_token
|
||||
|
||||
def __init__(self, hass, cognito=None):
|
||||
"""Initialize Hass cloud info object."""
|
||||
self.hass = hass
|
||||
self.cognito = cognito
|
||||
self.account = None
|
||||
try:
|
||||
cert = cloud.api.retrieve_iot_certificate()
|
||||
except RequestException:
|
||||
cloud.id_token = None
|
||||
cognito.logout()
|
||||
raise UnknownError('Unable to fetch certificate.')
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
"""Return if user is logged in."""
|
||||
return self.account is not None
|
||||
|
||||
def validate_auth(self):
|
||||
"""Validate that the contained auth is valid."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self._refresh_account_info()
|
||||
except ClientError as err:
|
||||
if err.response['Error']['Code'] != 'NotAuthorizedException':
|
||||
_LOGGER.error('Unexpected error verifying auth: %s', err)
|
||||
return False
|
||||
|
||||
try:
|
||||
self.renew_access_token()
|
||||
self._refresh_account_info()
|
||||
except ClientError:
|
||||
_LOGGER.error('Unable to refresh auth token: %s', err)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def login(self, username, password):
|
||||
"""Login using a username and password."""
|
||||
from botocore.exceptions import ClientError
|
||||
from warrant.exceptions import ForceChangePasswordException
|
||||
|
||||
cognito = _cognito(self.hass, username=username)
|
||||
|
||||
try:
|
||||
cognito.authenticate(password=password)
|
||||
self.cognito = cognito
|
||||
self._refresh_account_info()
|
||||
_write_info(self.hass, self)
|
||||
|
||||
except ForceChangePasswordException as err:
|
||||
raise PasswordChangeRequired
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
def _refresh_account_info(self):
|
||||
"""Refresh the account info.
|
||||
|
||||
Raises boto3 exceptions.
|
||||
"""
|
||||
self.account = self.cognito.get_user()
|
||||
|
||||
def renew_access_token(self):
|
||||
"""Refresh token."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self.cognito.renew_access_token()
|
||||
_write_info(self.hass, self)
|
||||
return True
|
||||
except ClientError as err:
|
||||
_LOGGER.error('Error refreshing token: %s', err)
|
||||
return False
|
||||
|
||||
def logout(self):
|
||||
"""Invalidate token."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self.cognito.logout()
|
||||
self.account = None
|
||||
_write_info(self.hass, self)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
cloud.id_token = cognito.id_token
|
||||
cloud.refresh_token = cognito.refresh_token
|
||||
cloud.email = email
|
||||
cloud.thing_name = cert['thing_name']
|
||||
cloud.write_user_info()
|
||||
_write_certificate(cloud, cert)
|
||||
|
||||
|
||||
def _read_info(hass):
|
||||
"""Read auth file."""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
def check_token(cloud):
|
||||
"""Check that the token is valid and verify if needed."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
cognito = _cognito(
|
||||
cloud,
|
||||
# We're not storing access token but id token will work too
|
||||
access_token=cloud.id_token,
|
||||
refresh_token=cloud.refresh_token)
|
||||
|
||||
with open(path) as file:
|
||||
return json.load(file).get(get_mode(hass))
|
||||
try:
|
||||
if cognito.check_token():
|
||||
cloud.id_token = cognito.id_token
|
||||
cloud.write_user_info()
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def _write_info(hass, auth):
|
||||
"""Write auth info for specified mode.
|
||||
def _authenticate(cloud, email, password):
|
||||
"""Log in and return an authenticated Cognito instance."""
|
||||
from botocore.exceptions import ClientError
|
||||
from warrant.exceptions import ForceChangePasswordException
|
||||
|
||||
Pass in None for data to remove authentication for that mode.
|
||||
"""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
mode = get_mode(hass)
|
||||
assert not cloud.is_logged_in, 'Cannot login if already logged in.'
|
||||
|
||||
if os.path.isfile(path):
|
||||
with open(path) as file:
|
||||
content = json.load(file)
|
||||
else:
|
||||
content = {}
|
||||
cognito = _cognito(cloud, username=email)
|
||||
|
||||
if auth.is_logged_in:
|
||||
content[mode] = {
|
||||
'id_token': auth.cognito.id_token,
|
||||
'access_token': auth.cognito.access_token,
|
||||
'refresh_token': auth.cognito.refresh_token,
|
||||
}
|
||||
else:
|
||||
content.pop(mode, None)
|
||||
try:
|
||||
cognito.authenticate(password=password)
|
||||
return cognito
|
||||
|
||||
with open(path, 'wt') as file:
|
||||
file.write(json.dumps(content, indent=4, sort_keys=True))
|
||||
except ForceChangePasswordException as err:
|
||||
raise PasswordChangeRequired
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def _cognito(hass, **kwargs):
|
||||
def _write_certificate(cloud, cert):
|
||||
"""Write certificate."""
|
||||
with open(cloud.certificate_pem_path, 'wt') as file:
|
||||
file.write(cert['certificate_pem'])
|
||||
|
||||
with open(cloud.secret_key_path, 'wt') as file:
|
||||
file.write(cert['secret_key'])
|
||||
|
||||
|
||||
def _cognito(cloud, **kwargs):
|
||||
"""Get the client credentials."""
|
||||
import botocore
|
||||
import boto3
|
||||
from warrant import Cognito
|
||||
|
||||
mode = get_mode(hass)
|
||||
|
||||
info = SERVERS.get(mode)
|
||||
|
||||
if info is None:
|
||||
raise ValueError('Mode {} is not supported.'.format(mode))
|
||||
|
||||
cognito = Cognito(
|
||||
user_pool_id=info['identity_pool_id'],
|
||||
client_id=info['client_id'],
|
||||
user_pool_region=info['region'],
|
||||
access_key=info['access_key_id'],
|
||||
secret_key=info['secret_access_key'],
|
||||
user_pool_id=cloud.user_pool_id,
|
||||
client_id=cloud.cognito_client_id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
cognito.client = boto3.client(
|
||||
'cognito-idp',
|
||||
region_name=cloud.region,
|
||||
config=botocore.config.Config(
|
||||
signature_version=botocore.UNSIGNED
|
||||
)
|
||||
)
|
||||
return cognito
|
||||
|
|
28
homeassistant/components/cloud/aws_iot_root_cert.pem
Normal file
28
homeassistant/components/cloud/aws_iot_root_cert.pem
Normal file
|
@ -0,0 +1,28 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIE0zCCA7ugAwIBAgIQGNrRniZ96LtKIVjNzGs7SjANBgkqhkiG9w0BAQUFADCB
|
||||
yjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL
|
||||
ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJp
|
||||
U2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxW
|
||||
ZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0
|
||||
aG9yaXR5IC0gRzUwHhcNMDYxMTA4MDAwMDAwWhcNMzYwNzE2MjM1OTU5WjCByjEL
|
||||
MAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZW
|
||||
ZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJpU2ln
|
||||
biwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJp
|
||||
U2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9y
|
||||
aXR5IC0gRzUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvJAgIKXo1
|
||||
nmAMqudLO07cfLw8RRy7K+D+KQL5VwijZIUVJ/XxrcgxiV0i6CqqpkKzj/i5Vbex
|
||||
t0uz/o9+B1fs70PbZmIVYc9gDaTY3vjgw2IIPVQT60nKWVSFJuUrjxuf6/WhkcIz
|
||||
SdhDY2pSS9KP6HBRTdGJaXvHcPaz3BJ023tdS1bTlr8Vd6Gw9KIl8q8ckmcY5fQG
|
||||
BO+QueQA5N06tRn/Arr0PO7gi+s3i+z016zy9vA9r911kTMZHRxAy3QkGSGT2RT+
|
||||
rCpSx4/VBEnkjWNHiDxpg8v+R70rfk/Fla4OndTRQ8Bnc+MUCH7lP59zuDMKz10/
|
||||
NIeWiu5T6CUVAgMBAAGjgbIwga8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E
|
||||
BAMCAQYwbQYIKwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJaW1hZ2UvZ2lmMCEwHzAH
|
||||
BgUrDgMCGgQUj+XTGoasjY5rw8+AatRIGCx7GS4wJRYjaHR0cDovL2xvZ28udmVy
|
||||
aXNpZ24uY29tL3ZzbG9nby5naWYwHQYDVR0OBBYEFH/TZafC3ey78DAJ80M5+gKv
|
||||
MzEzMA0GCSqGSIb3DQEBBQUAA4IBAQCTJEowX2LP2BqYLz3q3JktvXf2pXkiOOzE
|
||||
p6B4Eq1iDkVwZMXnl2YtmAl+X6/WzChl8gGqCBpH3vn5fJJaCGkgDdk+bW48DW7Y
|
||||
5gaRQBi5+MHt39tBquCWIMnNZBU4gcmU7qKEKQsTb47bDN0lAtukixlE0kF6BWlK
|
||||
WE9gyn6CagsCqiUXObXbf+eEZSqVir2G3l6BFoMtEMze/aiCKm0oHw0LxOXnGiYZ
|
||||
4fQRbxC1lfznQgUy286dUV4otp6F01vvpX1FQHKOtw5rDgb7MzVIcbidJ4vEZV8N
|
||||
hnacRHr2lVz2XTIIM6RUthg/aFzyQkqFOFSDX9HoLPKsEdao7WNq
|
||||
-----END CERTIFICATE-----
|
35
homeassistant/components/cloud/cloud_api.py
Normal file
35
homeassistant/components/cloud/cloud_api.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
"""Code to communicate with the cloud API."""
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from . import auth_api
|
||||
from .const import REQUEST_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudApi:
|
||||
"""Interact with the cloud API."""
|
||||
|
||||
def __init__(self, cloud):
|
||||
"""Initialize the cloud API."""
|
||||
self.cloud = cloud
|
||||
|
||||
def make_api_call(self, path, method='POST'):
|
||||
"""Make a call to the Cloud API."""
|
||||
auth_api.check_token(self.cloud)
|
||||
|
||||
uri = urljoin(self.cloud.api_base, path)
|
||||
response = requests.request(
|
||||
method, uri, timeout=REQUEST_TIMEOUT,
|
||||
headers={"Authorization": self.cloud.id_token})
|
||||
|
||||
return response
|
||||
|
||||
def retrieve_iot_certificate(self):
|
||||
"""Retrieve the certificate to connect to IoT."""
|
||||
resp = self.make_api_call('device/create')
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
|
@ -1,14 +1,20 @@
|
|||
"""Constants for the cloud component."""
|
||||
DOMAIN = 'cloud'
|
||||
CONFIG_DIR = '.cloud'
|
||||
REQUEST_TIMEOUT = 10
|
||||
AUTH_FILE = '.cloud'
|
||||
IOT_KEEP_ALIVE = 300
|
||||
|
||||
SUBSCRIBE_TOPIC_FORMAT = "{}/i/#"
|
||||
PUBLISH_TOPIC_FORMAT = "{}/c/{}"
|
||||
ALEXA_PUBLISH_TOPIC = "alexa/{}"
|
||||
|
||||
SERVERS = {
|
||||
'development': {
|
||||
'client_id': '3k755iqfcgv8t12o4pl662mnos',
|
||||
'identity_pool_id': 'us-west-2_vDOfweDJo',
|
||||
'region': 'us-west-2',
|
||||
'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ',
|
||||
'secret_access_key': 'lscdYBApxrLWL0HKuVqVXWv3ou8ZVXgG7rZBu/Sz'
|
||||
}
|
||||
# Example entry:
|
||||
# 'production': {
|
||||
# 'cognito_client_id': '',
|
||||
# 'user_pool_id': '',
|
||||
# 'region': '',
|
||||
# 'api_base': '',
|
||||
# 'iot_endpoint': ''
|
||||
# }
|
||||
}
|
||||
|
|
|
@ -10,9 +10,11 @@ from homeassistant.components.http import (
|
|||
HomeAssistantView, RequestDataValidator)
|
||||
|
||||
from . import auth_api
|
||||
from .const import REQUEST_TIMEOUT
|
||||
from .const import DOMAIN, REQUEST_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
# Login makes multiple requests, give higher timeout
|
||||
LOGIN_TIMEOUT = 30
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
|
@ -74,13 +76,14 @@ class CloudLoginView(HomeAssistantView):
|
|||
def post(self, request, data):
|
||||
"""Handle login request."""
|
||||
hass = request.app['hass']
|
||||
auth = hass.data['cloud']['auth']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(auth.login, data['email'],
|
||||
with async_timeout.timeout(LOGIN_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(auth_api.login, cloud, data['email'],
|
||||
data['password'])
|
||||
yield from hass.async_add_job(cloud.iot.connect)
|
||||
|
||||
return self.json(_auth_data(auth))
|
||||
return self.json(_account_data(cloud))
|
||||
|
||||
|
||||
class CloudLogoutView(HomeAssistantView):
|
||||
|
@ -94,10 +97,10 @@ class CloudLogoutView(HomeAssistantView):
|
|||
def post(self, request):
|
||||
"""Handle logout request."""
|
||||
hass = request.app['hass']
|
||||
auth = hass.data['cloud']['auth']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(auth.logout)
|
||||
yield from hass.async_add_job(cloud.logout)
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
@ -112,12 +115,12 @@ class CloudAccountView(HomeAssistantView):
|
|||
def get(self, request):
|
||||
"""Get account info."""
|
||||
hass = request.app['hass']
|
||||
auth = hass.data['cloud']['auth']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
if not auth.is_logged_in:
|
||||
if not cloud.is_logged_in:
|
||||
return self.json_message('Not logged in', 400)
|
||||
|
||||
return self.json(_auth_data(auth))
|
||||
return self.json(_account_data(cloud))
|
||||
|
||||
|
||||
class CloudRegisterView(HomeAssistantView):
|
||||
|
@ -135,10 +138,11 @@ class CloudRegisterView(HomeAssistantView):
|
|||
def post(self, request, data):
|
||||
"""Handle registration request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.register, hass, data['email'], data['password'])
|
||||
auth_api.register, cloud, data['email'], data['password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
@ -158,10 +162,11 @@ class CloudConfirmRegisterView(HomeAssistantView):
|
|||
def post(self, request, data):
|
||||
"""Handle registration confirmation request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_register, hass, data['confirmation_code'],
|
||||
auth_api.confirm_register, cloud, data['confirmation_code'],
|
||||
data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
@ -181,10 +186,11 @@ class CloudForgotPasswordView(HomeAssistantView):
|
|||
def post(self, request, data):
|
||||
"""Handle forgot password request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.forgot_password, hass, data['email'])
|
||||
auth_api.forgot_password, cloud, data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
@ -205,18 +211,19 @@ class CloudConfirmForgotPasswordView(HomeAssistantView):
|
|||
def post(self, request, data):
|
||||
"""Handle forgot password confirm request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_forgot_password, hass,
|
||||
auth_api.confirm_forgot_password, cloud,
|
||||
data['confirmation_code'], data['email'],
|
||||
data['new_password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
def _auth_data(auth):
|
||||
def _account_data(cloud):
|
||||
"""Generate the auth data JSON response."""
|
||||
return {
|
||||
'email': auth.account.email
|
||||
'email': cloud.email
|
||||
}
|
||||
|
|
133
homeassistant/components/cloud/iot.py
Normal file
133
homeassistant/components/cloud/iot.py
Normal file
|
@ -0,0 +1,133 @@
|
|||
"""Module to handle messages from Home Assistant cloud."""
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.components.alexa import smart_home
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from .const import (
|
||||
PUBLISH_TOPIC_FORMAT, SUBSCRIBE_TOPIC_FORMAT,
|
||||
IOT_KEEP_ALIVE, ALEXA_PUBLISH_TOPIC)
|
||||
|
||||
HANDLERS = Registry()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudIoT:
|
||||
"""Class to manage the IoT connection."""
|
||||
|
||||
def __init__(self, cloud):
|
||||
"""Initialize the CloudIoT class."""
|
||||
self.cloud = cloud
|
||||
self.client = None
|
||||
self._remove_hass_stop_listener = None
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
"""Return if we are connected."""
|
||||
return self.client is not None
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the IoT broker."""
|
||||
from AWSIoTPythonSDK.exception.operationError import operationError
|
||||
from AWSIoTPythonSDK.exception.operationTimeoutException import \
|
||||
operationTimeoutException
|
||||
|
||||
assert self.client is None, 'Cloud already connected'
|
||||
|
||||
client = _client_factory(self.cloud)
|
||||
hass = self.cloud.hass
|
||||
|
||||
def message_callback(mqtt_client, userdata, msg):
|
||||
"""Handle IoT message."""
|
||||
_, handler, message_id = msg.topic.rsplit('/', 2)
|
||||
payload = gzip.decompress(msg.payload).decode('utf-8')
|
||||
_LOGGER.debug('Received message on %s: %s', msg.topic, payload)
|
||||
|
||||
self.cloud.hass.add_job(
|
||||
async_handle_message, hass, self.cloud, handler,
|
||||
message_id, payload)
|
||||
|
||||
try:
|
||||
if not client.connect(keepAliveIntervalSecond=IOT_KEEP_ALIVE):
|
||||
return
|
||||
|
||||
client.subscribe(
|
||||
SUBSCRIBE_TOPIC_FORMAT.format(self.cloud.thing_name), 1,
|
||||
message_callback)
|
||||
self.client = client
|
||||
except (OSError, operationError, operationTimeoutException):
|
||||
# SSL Error, connect error, timeout.
|
||||
pass
|
||||
|
||||
def _handle_hass_stop(event):
|
||||
"""Handle Home Assistant shutting down."""
|
||||
client.disconnect()
|
||||
|
||||
self._remove_hass_stop_listener = hass.bus.listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, _handle_hass_stop)
|
||||
|
||||
def publish(self, topic, payload):
|
||||
"""Publish a message to the cloud."""
|
||||
topic = PUBLISH_TOPIC_FORMAT.format(self.cloud.thing_name, topic)
|
||||
_LOGGER.debug('Publishing message to %s: %s', topic, payload)
|
||||
payload = bytearray(gzip.compress(payload.encode('utf-8')))
|
||||
self.client.publish(topic, payload, 1)
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect the client."""
|
||||
self.client.disconnect()
|
||||
self._remove_hass_stop_listener()
|
||||
self.client = None
|
||||
self._remove_hass_stop_listener = None
|
||||
|
||||
|
||||
def _client_factory(cloud):
|
||||
"""Create IoT client."""
|
||||
from AWSIoTPythonSDK.MQTTLib import AWSIoTMQTTClient
|
||||
root_ca = os.path.join(os.path.dirname(__file__), 'aws_iot_root_cert.pem')
|
||||
|
||||
client = AWSIoTMQTTClient(cloud.thing_name)
|
||||
client.configureEndpoint(cloud.iot_endpoint, 8883)
|
||||
client.configureCredentials(root_ca, cloud.secret_key_path,
|
||||
cloud.certificate_pem_path)
|
||||
|
||||
# Auto back-off reconnects up to 128 seconds. If connected over 20 seconds
|
||||
# reset the auto back-off.
|
||||
client.configureAutoReconnectBackoffTime(1, 128, 20)
|
||||
|
||||
# Wait 10 seconds for a CONNACK or a disconnect to complete.
|
||||
client.configureConnectDisconnectTimeout(10)
|
||||
|
||||
# Set timeout to 5 seconds for publish, subscribe and unsubscribe.
|
||||
client.configureMQTTOperationTimeout(5)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, cloud, handler_name, message_id, payload):
|
||||
"""Handle incoming IoT message."""
|
||||
handler = HANDLERS.get(handler_name)
|
||||
|
||||
if handler is None:
|
||||
_LOGGER.warning('Unable to handle message for %s', handler_name)
|
||||
return
|
||||
|
||||
yield from handler(hass, cloud, message_id, payload)
|
||||
|
||||
|
||||
@HANDLERS.register('alexa')
|
||||
@asyncio.coroutine
|
||||
def async_handle_alexa(hass, cloud, message_id, payload):
|
||||
"""Handle an incoming IoT message for Alexa."""
|
||||
message = json.loads(payload)
|
||||
response = yield from smart_home.async_handle_message(hass, message)
|
||||
|
||||
yield from hass.async_add_job(
|
||||
cloud.iot.publish, ALEXA_PUBLISH_TOPIC.format(message_id),
|
||||
json.dumps(response))
|
|
@ -1,10 +0,0 @@
|
|||
"""Utilities for the cloud integration."""
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def get_mode(hass):
|
||||
"""Return the current mode of the cloud component.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data[DOMAIN]['mode']
|
|
@ -14,6 +14,9 @@ astral==1.4
|
|||
# homeassistant.components.nuimo_controller
|
||||
--only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0
|
||||
|
||||
# homeassistant.components.cloud
|
||||
AWSIoTPythonSDK==1.2.0
|
||||
|
||||
# homeassistant.components.bbb_gpio
|
||||
# Adafruit_BBIO==1.0.0
|
||||
|
||||
|
|
|
@ -20,6 +20,9 @@ asynctest>=0.8.0
|
|||
freezegun>=0.3.8
|
||||
|
||||
|
||||
# homeassistant.components.cloud
|
||||
AWSIoTPythonSDK==1.2.0
|
||||
|
||||
# homeassistant.components.notify.html5
|
||||
PyJWT==1.5.3
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ TEST_REQUIREMENTS = (
|
|||
'aioautomatic',
|
||||
'aiohttp_cors',
|
||||
'apns2',
|
||||
'AWSIoTPythonSDK',
|
||||
'dsmr_parser',
|
||||
'ephem',
|
||||
'evohomeclient',
|
||||
|
|
|
@ -11,21 +11,20 @@ from homeassistant.components.cloud import DOMAIN, auth_api
|
|||
@pytest.fixture
|
||||
def cloud_client(hass, test_client):
|
||||
"""Fixture that can fetch from the cloud client."""
|
||||
hass.loop.run_until_complete(async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'mode': 'development'
|
||||
}
|
||||
}))
|
||||
with patch('homeassistant.components.cloud.Cloud.initialize'):
|
||||
hass.loop.run_until_complete(async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'mode': 'development',
|
||||
'cognito_client_id': 'cognito_client_id',
|
||||
'user_pool_id': 'user_pool_id',
|
||||
'region': 'region',
|
||||
'api_base': 'api_base',
|
||||
'iot_endpoint': 'iot_endpoint',
|
||||
}
|
||||
}))
|
||||
return hass.loop.run_until_complete(test_client(hass.http.app))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth(cloud_client, hass):
|
||||
"""Fixture to mock authentication."""
|
||||
auth = hass.data[DOMAIN]['auth'] = MagicMock()
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cognito():
|
||||
"""Mock warrant."""
|
||||
|
@ -41,9 +40,9 @@ def test_account_view_no_account(cloud_client):
|
|||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_account_view(mock_auth, cloud_client):
|
||||
def test_account_view(hass, cloud_client):
|
||||
"""Test fetching account if no account available."""
|
||||
mock_auth.account = MagicMock(email='hello@home-assistant.io')
|
||||
hass.data[DOMAIN].email = 'hello@home-assistant.io'
|
||||
req = yield from cloud_client.get('/api/cloud/account')
|
||||
assert req.status == 200
|
||||
result = yield from req.json()
|
||||
|
@ -51,49 +50,56 @@ def test_account_view(mock_auth, cloud_client):
|
|||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view(mock_auth, cloud_client):
|
||||
def test_login_view(hass, cloud_client):
|
||||
"""Test logging in."""
|
||||
mock_auth.account = MagicMock(email='hello@home-assistant.io')
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
hass.data[DOMAIN].email = 'hello@home-assistant.io'
|
||||
|
||||
with patch('homeassistant.components.cloud.iot.CloudIoT.connect'), \
|
||||
patch('homeassistant.components.cloud.'
|
||||
'auth_api.login') as mock_login:
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
||||
assert req.status == 200
|
||||
result = yield from req.json()
|
||||
assert result == {'email': 'hello@home-assistant.io'}
|
||||
assert len(mock_auth.login.mock_calls) == 1
|
||||
result_user, result_pass = mock_auth.login.mock_calls[0][1]
|
||||
assert len(mock_login.mock_calls) == 1
|
||||
cloud, result_user, result_pass = mock_login.mock_calls[0][1]
|
||||
assert result_user == 'my_username'
|
||||
assert result_pass == 'my_password'
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_invalid_json(mock_auth, cloud_client):
|
||||
def test_login_view_invalid_json(cloud_client):
|
||||
"""Try logging in with invalid JSON."""
|
||||
req = yield from cloud_client.post('/api/cloud/login', data='Not JSON')
|
||||
with patch('homeassistant.components.cloud.auth_api.login') as mock_login:
|
||||
req = yield from cloud_client.post('/api/cloud/login', data='Not JSON')
|
||||
assert req.status == 400
|
||||
assert len(mock_auth.mock_calls) == 0
|
||||
assert len(mock_login.mock_calls) == 0
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_invalid_schema(mock_auth, cloud_client):
|
||||
def test_login_view_invalid_schema(cloud_client):
|
||||
"""Try logging in with invalid schema."""
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'invalid': 'schema'
|
||||
})
|
||||
with patch('homeassistant.components.cloud.auth_api.login') as mock_login:
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'invalid': 'schema'
|
||||
})
|
||||
assert req.status == 400
|
||||
assert len(mock_auth.mock_calls) == 0
|
||||
assert len(mock_login.mock_calls) == 0
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_request_timeout(mock_auth, cloud_client):
|
||||
def test_login_view_request_timeout(cloud_client):
|
||||
"""Test request timeout while trying to log in."""
|
||||
mock_auth.login.side_effect = asyncio.TimeoutError
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
with patch('homeassistant.components.cloud.auth_api.login',
|
||||
side_effect=asyncio.TimeoutError):
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
||||
assert req.status == 502
|
||||
|
||||
|
@ -101,23 +107,25 @@ def test_login_view_request_timeout(mock_auth, cloud_client):
|
|||
@asyncio.coroutine
|
||||
def test_login_view_invalid_credentials(mock_auth, cloud_client):
|
||||
"""Test logging in with invalid credentials."""
|
||||
mock_auth.login.side_effect = auth_api.Unauthenticated
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
with patch('homeassistant.components.cloud.auth_api.login',
|
||||
side_effect=auth_api.Unauthenticated):
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
||||
assert req.status == 401
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_login_view_unknown_error(mock_auth, cloud_client):
|
||||
def test_login_view_unknown_error(cloud_client):
|
||||
"""Test unknown error while logging in."""
|
||||
mock_auth.login.side_effect = auth_api.UnknownError
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
with patch('homeassistant.components.cloud.auth_api.login',
|
||||
side_effect=auth_api.UnknownError):
|
||||
req = yield from cloud_client.post('/api/cloud/login', json={
|
||||
'email': 'my_username',
|
||||
'password': 'my_password'
|
||||
})
|
||||
|
||||
assert req.status == 502
|
||||
|
||||
|
|
27
tests/components/cloud/test_init.py
Normal file
27
tests/components/cloud/test_init.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
"""Test the cloud component."""
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import cloud
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_init_loads_info_from_servers():
|
||||
pass
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_initialize_loads_info():
|
||||
pass
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_logout_clears_info():
|
||||
pass
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_write_user_info():
|
||||
pass
|
88
tests/components/cloud/test_iot.py
Normal file
88
tests/components/cloud/test_iot.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
"""Test the cloud.iot module."""
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.alexa import smart_home
|
||||
from homeassistant.components.cloud import iot, Cloud, MODE_DEV
|
||||
from tests.common import mock_coro
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Mock the IoT client."""
|
||||
with patch('homeassistant.components.cloud.iot._client_factory') as client:
|
||||
client = client()
|
||||
client.connect.return_value = True
|
||||
yield client
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_cloud_calling_handler(mock_client):
|
||||
"""Test we call handle message with correct info."""
|
||||
hass = MagicMock()
|
||||
cloud = Cloud(hass, MODE_DEV)
|
||||
client = iot.CloudIoT(cloud)
|
||||
|
||||
client.connect()
|
||||
|
||||
assert len(mock_client.mock_calls) == 2
|
||||
callback = mock_client.mock_calls[1][1][2]
|
||||
|
||||
callback(None, None, MagicMock(
|
||||
topic='thing_id/i/alexa/123456',
|
||||
payload='hello payload'
|
||||
))
|
||||
|
||||
assert len(hass.mock_calls) == 2
|
||||
_, _, _, handler, message_id, payload = hass.mock_calls[1][1]
|
||||
assert handler == 'alexa'
|
||||
assert message_id == '123456'
|
||||
assert payload == 'hello payload'
|
||||
|
||||
|
||||
@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', '123456', 'payload')
|
||||
|
||||
assert len(handler.mock_calls) == 1
|
||||
r_hass, r_cloud, message_id, payload = handler.mock_calls[0][1]
|
||||
assert r_hass is hass
|
||||
assert r_cloud is cloud
|
||||
assert message_id == '123456'
|
||||
assert payload == 'payload'
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_handler_alexa():
|
||||
"""Test we handle Alexa messages correctly."""
|
||||
hass = MagicMock()
|
||||
hass.async_add_job.return_value = mock_coro()
|
||||
cloud = MagicMock()
|
||||
|
||||
with patch(
|
||||
'homeassistant.components.alexa.smart_home.async_handle_message',
|
||||
return_value=mock_coro({
|
||||
smart_home.ATTR_PAYLOAD: {'hello': 456}
|
||||
})) as mock_alexa:
|
||||
yield from iot.async_handle_alexa(
|
||||
hass, cloud, '123456', '{"test": 123}')
|
||||
|
||||
assert len(mock_alexa.mock_calls) == 1
|
||||
r_hass, message = mock_alexa.mock_calls[0][1]
|
||||
assert hass is r_hass
|
||||
assert message == {'test': 123}
|
||||
|
||||
assert len(hass.async_add_job.mock_calls) == 1
|
||||
publish, topic, payload = hass.async_add_job.mock_calls[0][1]
|
||||
assert publish is cloud.iot.publish
|
||||
assert topic == 'alexa/123456'
|
||||
assert payload == '{"hello": 456}'
|
Loading…
Add table
Add a link
Reference in a new issue