310 lines
9.8 KiB
Python
310 lines
9.8 KiB
Python
"""
|
|
Component to integrate the Home Assistant cloud.
|
|
|
|
For more details about this component, please refer to the documentation at
|
|
https://home-assistant.io/components/cloud/
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime
|
|
import json
|
|
import logging
|
|
import os
|
|
|
|
import aiohttp
|
|
import async_timeout
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.const import (
|
|
EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME)
|
|
from homeassistant.helpers import entityfilter, config_validation as cv
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.util import dt as dt_util
|
|
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
|
|
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
|
|
|
REQUIREMENTS = ['warrant==0.6.1']
|
|
|
|
_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'
|
|
|
|
DEFAULT_MODE = 'production'
|
|
DEPENDENCIES = ['http']
|
|
|
|
MODE_DEV = 'development'
|
|
|
|
ALEXA_ENTITY_SCHEMA = vol.Schema({
|
|
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
|
|
vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string,
|
|
vol.Optional(alexa_sh.CONF_NAME): cv.string,
|
|
})
|
|
|
|
GOOGLE_ENTITY_SCHEMA = vol.Schema({
|
|
vol.Optional(CONF_NAME): cv.string,
|
|
vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]),
|
|
vol.Optional(ga_c.CONF_ROOM_HINT): cv.string,
|
|
})
|
|
|
|
ASSISTANT_SCHEMA = vol.Schema({
|
|
vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
|
|
})
|
|
|
|
ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
|
|
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA}
|
|
})
|
|
|
|
GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
|
|
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}
|
|
})
|
|
|
|
CONFIG_SCHEMA = vol.Schema({
|
|
DOMAIN: vol.Schema({
|
|
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
|
|
vol.In([MODE_DEV] + list(SERVERS)),
|
|
# 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_ALEXA): ALEXA_SCHEMA,
|
|
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
|
}),
|
|
}, extra=vol.ALLOW_EXTRA)
|
|
|
|
|
|
@asyncio.coroutine
|
|
def async_setup(hass, config):
|
|
"""Initialize the Home Assistant cloud."""
|
|
if DOMAIN in config:
|
|
kwargs = dict(config[DOMAIN])
|
|
else:
|
|
kwargs = {CONF_MODE: DEFAULT_MODE}
|
|
|
|
alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({})
|
|
|
|
if CONF_GOOGLE_ACTIONS not in kwargs:
|
|
kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({})
|
|
|
|
kwargs[CONF_ALEXA] = alexa_sh.Config(
|
|
should_expose=alexa_conf[CONF_FILTER],
|
|
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
|
|
)
|
|
|
|
cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs)
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start)
|
|
yield from 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):
|
|
"""Create an instance of Cloud."""
|
|
self.hass = hass
|
|
self.mode = mode
|
|
self.alexa_config = alexa
|
|
self._google_actions = google_actions
|
|
self._gactions_config = None
|
|
self.jwt_keyset = None
|
|
self.id_token = None
|
|
self.access_token = None
|
|
self.refresh_token = None
|
|
self.iot = iot.CloudIoT(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
|
|
|
|
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']
|
|
|
|
@property
|
|
def is_logged_in(self):
|
|
"""Get if cloud is logged in."""
|
|
return self.id_token is not None
|
|
|
|
@property
|
|
def subscription_expired(self):
|
|
"""Return a boolean if the subscription has expired."""
|
|
return dt_util.utcnow() > self.expiration_date
|
|
|
|
@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
|
|
|
|
def should_expose(entity):
|
|
"""If an entity should be exposed."""
|
|
return conf['filter'](entity.entity_id)
|
|
|
|
self._gactions_config = ga_h.Config(
|
|
should_expose=should_expose,
|
|
agent_user_id=self.claims['cognito:username'],
|
|
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)
|
|
|
|
@asyncio.coroutine
|
|
def logout(self):
|
|
"""Close connection and remove all credentials."""
|
|
yield from self.iot.disconnect()
|
|
|
|
self.id_token = None
|
|
self.access_token = None
|
|
self.refresh_token = None
|
|
self._gactions_config = None
|
|
|
|
yield from 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))
|
|
|
|
@asyncio.coroutine
|
|
def async_start(self, _):
|
|
"""Start the cloud component."""
|
|
success = yield from self._fetch_jwt_keyset()
|
|
|
|
# Fetching keyset can fail if internet is not up yet.
|
|
if not success:
|
|
self.hass.helpers.event.async_call_later(5, self.async_start)
|
|
return
|
|
|
|
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 = yield from self.hass.async_add_job(load_config)
|
|
|
|
if info is None:
|
|
return
|
|
|
|
# Validate tokens
|
|
try:
|
|
for token in 'id_token', 'access_token':
|
|
self._decode_claims(info[token])
|
|
except ValueError as err: # Raised when token is invalid
|
|
_LOGGER.warning("Found invalid token %s: %s", token, err)
|
|
return
|
|
|
|
self.id_token = info['id_token']
|
|
self.access_token = info['access_token']
|
|
self.refresh_token = info['refresh_token']
|
|
|
|
self.hass.add_job(self.iot.connect())
|
|
|
|
@asyncio.coroutine
|
|
def _fetch_jwt_keyset(self):
|
|
"""Fetch the JWT keyset for the Cognito instance."""
|
|
session = async_get_clientsession(self.hass)
|
|
url = ("https://cognito-idp.us-east-1.amazonaws.com/"
|
|
"{}/.well-known/jwks.json".format(self.user_pool_id))
|
|
|
|
try:
|
|
with async_timeout.timeout(10, loop=self.hass.loop):
|
|
req = yield from session.get(url)
|
|
self.jwt_keyset = yield from req.json()
|
|
|
|
return True
|
|
|
|
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
|
|
_LOGGER.error("Error fetching Cognito keyset: %s", err)
|
|
return False
|
|
|
|
def _decode_claims(self, token):
|
|
"""Decode the claims in a token."""
|
|
from jose import jwt, exceptions as jose_exceptions
|
|
try:
|
|
header = jwt.get_unverified_header(token)
|
|
except jose_exceptions.JWTError as err:
|
|
raise ValueError(str(err)) from None
|
|
kid = header.get('kid')
|
|
|
|
if kid is None:
|
|
raise ValueError("No kid in header")
|
|
|
|
# Locate the key for this kid
|
|
key = None
|
|
for key_dict in self.jwt_keyset['keys']:
|
|
if key_dict['kid'] == kid:
|
|
key = key_dict
|
|
break
|
|
if not key:
|
|
raise ValueError(
|
|
"Unable to locate kid ({}) in keyset".format(kid))
|
|
|
|
try:
|
|
return jwt.decode(
|
|
token, key, audience=self.cognito_client_id, options={
|
|
'verify_exp': False,
|
|
})
|
|
except jose_exceptions.JWTError as err:
|
|
raise ValueError(str(err)) from None
|