Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Paulus Schoutsen
f09b9f52a9 Add gzip compression 2017-10-07 13:32:11 -07:00
Paulus Schoutsen
e8deedbfa0 return full response 2017-10-07 13:32:11 -07:00
Paulus Schoutsen
3c21bb7cd0 Add debug statements 2017-10-07 13:32:11 -07:00
Paulus Schoutsen
88790f89ba Fix some tests 2017-10-07 13:32:11 -07:00
Paulus Schoutsen
e5e5a30ade Cloud: get certificate and connect to cloud 2017-10-07 13:32:11 -07:00
14 changed files with 614 additions and 256 deletions

View file

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

View file

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

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

View 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()

View file

@ -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': ''
# }
}

View file

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

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

View file

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

View file

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

View file

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

View file

@ -36,6 +36,7 @@ TEST_REQUIREMENTS = (
'aioautomatic',
'aiohttp_cors',
'apns2',
'AWSIoTPythonSDK',
'dsmr_parser',
'ephem',
'evohomeclient',

View file

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

View 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

View 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}'