Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow managing cloud webhook #18672

Merged
merged 16 commits into from
Nov 26, 2018
9 changes: 7 additions & 2 deletions homeassistant/components/cloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
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
from . import http_api, iot, auth_api, prefs, cloudhooks
from .const import CONFIG_DIR, DOMAIN, SERVERS

REQUIREMENTS = ['warrant==0.6.1']
Expand All @@ -37,6 +37,7 @@
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']
Expand Down Expand Up @@ -78,6 +79,7 @@
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,
}),
Expand Down Expand Up @@ -113,7 +115,7 @@ class Cloud:
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):
subscription_info_url=None, cloudhook_create_url=None):
"""Create an instance of Cloud."""
self.hass = hass
self.mode = mode
Expand All @@ -125,6 +127,7 @@ def __init__(self, hass, mode, alexa, google_actions,
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
Expand All @@ -133,6 +136,7 @@ def __init__(self, hass, mode, alexa, google_actions,
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]
Expand All @@ -143,6 +147,7 @@ def __init__(self, hass, mode, alexa, google_actions,
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):
Expand Down
25 changes: 25 additions & 0 deletions homeassistant/components/cloud/cloud_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Cloud APIs."""
from functools import wraps

from . import auth_api


def _check_token(func):
"""Decorate a function to verify valid token."""
@wraps(func)
async def check_token(cloud, *args):
"""Validate token, then call func."""
await cloud.hass.async_add_executor_job(auth_api.check_token, cloud)
return await func(cloud, *args)

return check_token


@_check_token
async def async_create_cloudhook(cloud):
"""Create a cloudhook."""
websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession()
return await websession.post(
cloud.cloudhook_create_url, headers={
'authorization': cloud.id_token
})
66 changes: 66 additions & 0 deletions homeassistant/components/cloud/cloudhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Manage cloud cloudhooks."""
import async_timeout

from . import cloud_api


class Cloudhooks:
"""Class to help manage cloudhooks."""

def __init__(self, cloud):
"""Initialize cloudhooks."""
self.cloud = cloud
self.cloud.iot.register_on_connect(self.async_publish_cloudhooks)

async def async_publish_cloudhooks(self):
"""Inform the Relayer of the cloudhooks that we support."""
cloudhooks = self.cloud.prefs.cloudhooks
await self.cloud.iot.async_send_message('webhook-register', {
'cloudhook_ids': [info['cloudhook_id'] for info
in cloudhooks.values()]
})

async def async_create(self, webhook_id):
"""Create a cloud webhook."""
cloudhooks = self.cloud.prefs.cloudhooks

if webhook_id in cloudhooks:
raise ValueError('Hook is already enabled for the cloud.')

if not self.cloud.iot.connected:
raise ValueError("Cloud is not connected")

# Create cloud hook
with async_timeout.timeout(10):
resp = await cloud_api.async_create_cloudhook(self.cloud)

data = await resp.json()
cloudhook_id = data['cloudhook_id']
cloudhook_url = data['url']

# Store hook
cloudhooks = dict(cloudhooks)
hook = cloudhooks[webhook_id] = {
'webhook_id': webhook_id,
'cloudhook_id': cloudhook_id,
'cloudhook_url': cloudhook_url
}
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)

await self.async_publish_cloudhooks()

return hook

async def async_delete(self, webhook_id):
"""Delete a cloud webhook."""
cloudhooks = self.cloud.prefs.cloudhooks

if webhook_id not in cloudhooks:
raise ValueError('Hook is not enabled for the cloud.')

# Remove hook
cloudhooks = dict(cloudhooks)
cloudhooks.pop(webhook_id)
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)

await self.async_publish_cloudhooks()
4 changes: 3 additions & 1 deletion homeassistant/components/cloud/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
PREF_ENABLE_ALEXA = 'alexa_enabled'
PREF_ENABLE_GOOGLE = 'google_enabled'
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
PREF_CLOUDHOOKS = 'cloudhooks'

SERVERS = {
'production': {
Expand All @@ -16,7 +17,8 @@
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
'amazonaws.com/prod/smart_home_sync'),
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
'subscription_info')
'subscription_info'),
'cloudhook_create_url': 'https://webhook-api.nabucasa.com/generate'
}
}

Expand Down
98 changes: 83 additions & 15 deletions homeassistant/components/cloud/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from functools import wraps
import logging

import aiohttp
import async_timeout
import voluptuous as vol

Expand Down Expand Up @@ -44,6 +45,20 @@
})


WS_TYPE_HOOK_CREATE = 'cloud/cloudhook/create'
SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
vol.Required('type'): WS_TYPE_HOOK_CREATE,
vol.Required('webhook_id'): str
})


WS_TYPE_HOOK_DELETE = 'cloud/cloudhook/delete'
SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
vol.Required('type'): WS_TYPE_HOOK_DELETE,
vol.Required('webhook_id'): str
})


async def async_setup(hass):
"""Initialize the HTTP API."""
hass.components.websocket_api.async_register_command(
Expand All @@ -58,6 +73,14 @@ async def async_setup(hass):
WS_TYPE_UPDATE_PREFS, websocket_update_prefs,
SCHEMA_WS_UPDATE_PREFS
)
hass.components.websocket_api.async_register_command(
WS_TYPE_HOOK_CREATE, websocket_hook_create,
SCHEMA_WS_HOOK_CREATE
)
hass.components.websocket_api.async_register_command(
WS_TYPE_HOOK_DELETE, websocket_hook_delete,
SCHEMA_WS_HOOK_DELETE
)
hass.http.register_view(GoogleActionsSyncView)
hass.http.register_view(CloudLoginView)
hass.http.register_view(CloudLogoutView)
Expand All @@ -76,7 +99,7 @@ async def async_setup(hass):


def _handle_cloud_errors(handler):
"""Handle auth errors."""
"""Webview decorator to handle auth errors."""
@wraps(handler)
async def error_handler(view, request, *args, **kwargs):
"""Handle exceptions that raise from the wrapped request handler."""
Expand Down Expand Up @@ -240,17 +263,49 @@ def websocket_cloud_status(hass, connection, msg):
websocket_api.result_message(msg['id'], _account_data(cloud)))


def _require_cloud_login(handler):
"""Websocket decorator that requires cloud to be logged in."""
@wraps(handler)
def with_cloud_auth(hass, connection, msg):
"""Require to be logged into the cloud."""
cloud = hass.data[DOMAIN]
if not cloud.is_logged_in:
connection.send_message(websocket_api.error_message(
msg['id'], 'not_logged_in',
'You need to be logged in to the cloud.'))
return

handler(hass, connection, msg)

return with_cloud_auth


def _handle_aiohttp_errors(handler):
"""Websocket decorator that handlers aiohttp errors.

Can only wrap async handlers.
"""
@wraps(handler)
async def with_error_handling(hass, connection, msg):
"""Handle aiohttp errors."""
try:
await handler(hass, connection, msg)
except asyncio.TimeoutError:
connection.send_message(websocket_api.error_message(
msg['id'], 'timeout', 'Command timed out.'))
except aiohttp.ClientError:
connection.send_message(websocket_api.error_message(
msg['id'], 'unknown', 'Error making request.'))

return with_error_handling


@_require_cloud_login
@websocket_api.async_response
async def websocket_subscription(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]

if not cloud.is_logged_in:
connection.send_message(websocket_api.error_message(
msg['id'], 'not_logged_in',
'You need to be logged in to the cloud.'))
return

with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
response = await cloud.fetch_subscription_info()

Expand All @@ -277,24 +332,37 @@ async def websocket_subscription(hass, connection, msg):
connection.send_message(websocket_api.result_message(msg['id'], data))


@_require_cloud_login
@websocket_api.async_response
async def websocket_update_prefs(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]

if not cloud.is_logged_in:
connection.send_message(websocket_api.error_message(
msg['id'], 'not_logged_in',
'You need to be logged in to the cloud.'))
return

changes = dict(msg)
changes.pop('id')
changes.pop('type')
await cloud.prefs.async_update(**changes)

connection.send_message(websocket_api.result_message(
msg['id'], {'success': True}))
connection.send_message(websocket_api.result_message(msg['id']))


@_require_cloud_login
@websocket_api.async_response
@_handle_aiohttp_errors
async def websocket_hook_create(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
hook = await cloud.cloudhooks.async_create(msg['webhook_id'])
connection.send_message(websocket_api.result_message(msg['id'], hook))


@_require_cloud_login
@websocket_api.async_response
async def websocket_hook_delete(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
await cloud.cloudhooks.async_delete(msg['webhook_id'])
connection.send_message(websocket_api.result_message(msg['id']))


def _account_data(cloud):
Expand Down
Loading