core/homeassistant/components/cloud/__init__.py

270 lines
8.9 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/
"""
from datetime import datetime, timedelta
import json
import logging
import os
import voluptuous as vol
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.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, auth_api, prefs, cloudhooks
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'
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_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_SUBSCRIPTION_INFO_URL): str,
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str,
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
}),
}, extra=vol.ALLOW_EXTRA)
async 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)
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 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,
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)
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.add_job(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)