"""Component to integrate the Home Assistant cloud.""" from datetime import datetime, timedelta import json import logging import os import voluptuous as vol 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.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 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) class CloudNotAvailable(HomeAssistantError): """Raised when an action requires the cloud but it's not available.""" @bind_hass @callback def async_is_logged_in(hass): """Test if user is logged in.""" return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in @bind_hass async def async_create_cloudhook(hass, webhook_id): """Create a cloudhook.""" if not async_is_logged_in(hass): raise CloudNotAvailable return await hass.data[DOMAIN].cloudhooks.async_create(webhook_id) @bind_hass async def async_delete_cloudhook(hass, webhook_id): """Delete a cloudhook.""" if not async_is_logged_in(hass): raise CloudNotAvailable return await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) def is_cloudhook_request(request): """Test if a request came from a cloudhook. Async friendly. """ return isinstance(request, MockRequest) 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( endpoint=None, async_get_access_token=None, should_expose=alexa_conf[CONF_FILTER], entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), ) 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, 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.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)