Allow managing cloud webhook (#18672)
* Add cloud webhook support * Simplify payload * Add cloud http api tests * Fix tests * Lint * Handle cloud webhooks * Fix things * Fix name * Rename it to cloudhook * Final rename * Final final rename? * Fix docstring * More tests * Lint * Add types * Fix things
This commit is contained in:
parent
4a661e351f
commit
7848381f43
15 changed files with 611 additions and 39 deletions
|
@ -20,7 +20,7 @@ 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 helpers as ga_h
|
||||||
from homeassistant.components.google_assistant import const as ga_c
|
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
|
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
||||||
|
|
||||||
REQUIREMENTS = ['warrant==0.6.1']
|
REQUIREMENTS = ['warrant==0.6.1']
|
||||||
|
@ -37,6 +37,7 @@ CONF_RELAYER = 'relayer'
|
||||||
CONF_USER_POOL_ID = 'user_pool_id'
|
CONF_USER_POOL_ID = 'user_pool_id'
|
||||||
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
|
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
|
||||||
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
||||||
|
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
||||||
|
|
||||||
DEFAULT_MODE = 'production'
|
DEFAULT_MODE = 'production'
|
||||||
DEPENDENCIES = ['http']
|
DEPENDENCIES = ['http']
|
||||||
|
@ -78,6 +79,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||||
vol.Optional(CONF_RELAYER): str,
|
vol.Optional(CONF_RELAYER): str,
|
||||||
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str,
|
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str,
|
||||||
vol.Optional(CONF_SUBSCRIPTION_INFO_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_ALEXA): ALEXA_SCHEMA,
|
||||||
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
||||||
}),
|
}),
|
||||||
|
@ -113,7 +115,7 @@ class Cloud:
|
||||||
def __init__(self, hass, mode, alexa, google_actions,
|
def __init__(self, hass, mode, alexa, google_actions,
|
||||||
cognito_client_id=None, user_pool_id=None, region=None,
|
cognito_client_id=None, user_pool_id=None, region=None,
|
||||||
relayer=None, google_actions_sync_url=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."""
|
"""Create an instance of Cloud."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
@ -125,6 +127,7 @@ class Cloud:
|
||||||
self.access_token = None
|
self.access_token = None
|
||||||
self.refresh_token = None
|
self.refresh_token = None
|
||||||
self.iot = iot.CloudIoT(self)
|
self.iot = iot.CloudIoT(self)
|
||||||
|
self.cloudhooks = cloudhooks.Cloudhooks(self)
|
||||||
|
|
||||||
if mode == MODE_DEV:
|
if mode == MODE_DEV:
|
||||||
self.cognito_client_id = cognito_client_id
|
self.cognito_client_id = cognito_client_id
|
||||||
|
@ -133,6 +136,7 @@ class Cloud:
|
||||||
self.relayer = relayer
|
self.relayer = relayer
|
||||||
self.google_actions_sync_url = google_actions_sync_url
|
self.google_actions_sync_url = google_actions_sync_url
|
||||||
self.subscription_info_url = subscription_info_url
|
self.subscription_info_url = subscription_info_url
|
||||||
|
self.cloudhook_create_url = cloudhook_create_url
|
||||||
|
|
||||||
else:
|
else:
|
||||||
info = SERVERS[mode]
|
info = SERVERS[mode]
|
||||||
|
@ -143,6 +147,7 @@ class Cloud:
|
||||||
self.relayer = info['relayer']
|
self.relayer = info['relayer']
|
||||||
self.google_actions_sync_url = info['google_actions_sync_url']
|
self.google_actions_sync_url = info['google_actions_sync_url']
|
||||||
self.subscription_info_url = info['subscription_info_url']
|
self.subscription_info_url = info['subscription_info_url']
|
||||||
|
self.cloudhook_create_url = info['cloudhook_create_url']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_logged_in(self):
|
def is_logged_in(self):
|
||||||
|
|
25
homeassistant/components/cloud/cloud_api.py
Normal file
25
homeassistant/components/cloud/cloud_api.py
Normal file
|
@ -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
homeassistant/components/cloud/cloudhooks.py
Normal file
66
homeassistant/components/cloud/cloudhooks.py
Normal file
|
@ -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()
|
|
@ -6,6 +6,7 @@ REQUEST_TIMEOUT = 10
|
||||||
PREF_ENABLE_ALEXA = 'alexa_enabled'
|
PREF_ENABLE_ALEXA = 'alexa_enabled'
|
||||||
PREF_ENABLE_GOOGLE = 'google_enabled'
|
PREF_ENABLE_GOOGLE = 'google_enabled'
|
||||||
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
|
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
|
||||||
|
PREF_CLOUDHOOKS = 'cloudhooks'
|
||||||
|
|
||||||
SERVERS = {
|
SERVERS = {
|
||||||
'production': {
|
'production': {
|
||||||
|
@ -16,7 +17,8 @@ SERVERS = {
|
||||||
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
|
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
|
||||||
'amazonaws.com/prod/smart_home_sync'),
|
'amazonaws.com/prod/smart_home_sync'),
|
||||||
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
|
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
|
||||||
'subscription_info')
|
'subscription_info'),
|
||||||
|
'cloudhook_create_url': 'https://webhook-api.nabucasa.com/generate'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import asyncio
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
import async_timeout
|
import async_timeout
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
@ -44,6 +45,20 @@ SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
async def async_setup(hass):
|
||||||
"""Initialize the HTTP API."""
|
"""Initialize the HTTP API."""
|
||||||
hass.components.websocket_api.async_register_command(
|
hass.components.websocket_api.async_register_command(
|
||||||
|
@ -58,6 +73,14 @@ async def async_setup(hass):
|
||||||
WS_TYPE_UPDATE_PREFS, websocket_update_prefs,
|
WS_TYPE_UPDATE_PREFS, websocket_update_prefs,
|
||||||
SCHEMA_WS_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(GoogleActionsSyncView)
|
||||||
hass.http.register_view(CloudLoginView)
|
hass.http.register_view(CloudLoginView)
|
||||||
hass.http.register_view(CloudLogoutView)
|
hass.http.register_view(CloudLogoutView)
|
||||||
|
@ -76,7 +99,7 @@ _CLOUD_ERRORS = {
|
||||||
|
|
||||||
|
|
||||||
def _handle_cloud_errors(handler):
|
def _handle_cloud_errors(handler):
|
||||||
"""Handle auth errors."""
|
"""Webview decorator to handle auth errors."""
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def error_handler(view, request, *args, **kwargs):
|
async def error_handler(view, request, *args, **kwargs):
|
||||||
"""Handle exceptions that raise from the wrapped request handler."""
|
"""Handle exceptions that raise from the wrapped request handler."""
|
||||||
|
@ -240,17 +263,49 @@ def websocket_cloud_status(hass, connection, msg):
|
||||||
websocket_api.result_message(msg['id'], _account_data(cloud)))
|
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
|
@websocket_api.async_response
|
||||||
async def websocket_subscription(hass, connection, msg):
|
async def websocket_subscription(hass, connection, msg):
|
||||||
"""Handle request for account info."""
|
"""Handle request for account info."""
|
||||||
cloud = hass.data[DOMAIN]
|
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):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
response = await cloud.fetch_subscription_info()
|
response = await cloud.fetch_subscription_info()
|
||||||
|
|
||||||
|
@ -277,24 +332,37 @@ async def websocket_subscription(hass, connection, msg):
|
||||||
connection.send_message(websocket_api.result_message(msg['id'], data))
|
connection.send_message(websocket_api.result_message(msg['id'], data))
|
||||||
|
|
||||||
|
|
||||||
|
@_require_cloud_login
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
async def websocket_update_prefs(hass, connection, msg):
|
async def websocket_update_prefs(hass, connection, msg):
|
||||||
"""Handle request for account info."""
|
"""Handle request for account info."""
|
||||||
cloud = hass.data[DOMAIN]
|
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 = dict(msg)
|
||||||
changes.pop('id')
|
changes.pop('id')
|
||||||
changes.pop('type')
|
changes.pop('type')
|
||||||
await cloud.prefs.async_update(**changes)
|
await cloud.prefs.async_update(**changes)
|
||||||
|
|
||||||
connection.send_message(websocket_api.result_message(
|
connection.send_message(websocket_api.result_message(msg['id']))
|
||||||
msg['id'], {'success': True}))
|
|
||||||
|
|
||||||
|
@_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):
|
def _account_data(cloud):
|
||||||
|
|
|
@ -2,13 +2,16 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import pprint
|
import pprint
|
||||||
|
import uuid
|
||||||
|
|
||||||
from aiohttp import hdrs, client_exceptions, WSMsgType
|
from aiohttp import hdrs, client_exceptions, WSMsgType
|
||||||
|
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.components.alexa import smart_home as alexa
|
from homeassistant.components.alexa import smart_home as alexa
|
||||||
from homeassistant.components.google_assistant import smart_home as ga
|
from homeassistant.components.google_assistant import smart_home as ga
|
||||||
|
from homeassistant.core import callback
|
||||||
from homeassistant.util.decorator import Registry
|
from homeassistant.util.decorator import Registry
|
||||||
|
from homeassistant.util.aiohttp import MockRequest, serialize_response
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from . import auth_api
|
from . import auth_api
|
||||||
from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL
|
from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL
|
||||||
|
@ -25,6 +28,15 @@ class UnknownHandler(Exception):
|
||||||
"""Exception raised when trying to handle unknown handler."""
|
"""Exception raised when trying to handle unknown handler."""
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorMessage(Exception):
|
||||||
|
"""Exception raised when there was error handling message in the cloud."""
|
||||||
|
|
||||||
|
def __init__(self, error):
|
||||||
|
"""Initialize Error Message."""
|
||||||
|
super().__init__(self, "Error in Cloud")
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
class CloudIoT:
|
class CloudIoT:
|
||||||
"""Class to manage the IoT connection."""
|
"""Class to manage the IoT connection."""
|
||||||
|
|
||||||
|
@ -41,6 +53,19 @@ class CloudIoT:
|
||||||
self.tries = 0
|
self.tries = 0
|
||||||
# Current state of the connection
|
# Current state of the connection
|
||||||
self.state = STATE_DISCONNECTED
|
self.state = STATE_DISCONNECTED
|
||||||
|
# Local code waiting for a response
|
||||||
|
self._response_handler = {}
|
||||||
|
self._on_connect = []
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def register_on_connect(self, on_connect_cb):
|
||||||
|
"""Register an async on_connect callback."""
|
||||||
|
self._on_connect.append(on_connect_cb)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self):
|
||||||
|
"""Return if we're currently connected."""
|
||||||
|
return self.state == STATE_CONNECTED
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
def connect(self):
|
def connect(self):
|
||||||
|
@ -91,6 +116,20 @@ class CloudIoT:
|
||||||
if remove_hass_stop_listener is not None:
|
if remove_hass_stop_listener is not None:
|
||||||
remove_hass_stop_listener()
|
remove_hass_stop_listener()
|
||||||
|
|
||||||
|
async def async_send_message(self, handler, payload):
|
||||||
|
"""Send a message."""
|
||||||
|
msgid = uuid.uuid4().hex
|
||||||
|
self._response_handler[msgid] = asyncio.Future()
|
||||||
|
message = {
|
||||||
|
'msgid': msgid,
|
||||||
|
'handler': handler,
|
||||||
|
'payload': payload,
|
||||||
|
}
|
||||||
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||||
|
_LOGGER.debug("Publishing message:\n%s\n",
|
||||||
|
pprint.pformat(message))
|
||||||
|
await self.client.send_json(message)
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
def _handle_connection(self):
|
def _handle_connection(self):
|
||||||
"""Connect to the IoT broker."""
|
"""Connect to the IoT broker."""
|
||||||
|
@ -134,6 +173,9 @@ class CloudIoT:
|
||||||
_LOGGER.info("Connected")
|
_LOGGER.info("Connected")
|
||||||
self.state = STATE_CONNECTED
|
self.state = STATE_CONNECTED
|
||||||
|
|
||||||
|
if self._on_connect:
|
||||||
|
yield from asyncio.wait([cb() for cb in self._on_connect])
|
||||||
|
|
||||||
while not client.closed:
|
while not client.closed:
|
||||||
msg = yield from client.receive()
|
msg = yield from client.receive()
|
||||||
|
|
||||||
|
@ -159,6 +201,17 @@ class CloudIoT:
|
||||||
_LOGGER.debug("Received message:\n%s\n",
|
_LOGGER.debug("Received message:\n%s\n",
|
||||||
pprint.pformat(msg))
|
pprint.pformat(msg))
|
||||||
|
|
||||||
|
response_handler = self._response_handler.pop(msg['msgid'],
|
||||||
|
None)
|
||||||
|
|
||||||
|
if response_handler is not None:
|
||||||
|
if 'payload' in msg:
|
||||||
|
response_handler.set_result(msg["payload"])
|
||||||
|
else:
|
||||||
|
response_handler.set_exception(
|
||||||
|
ErrorMessage(msg['error']))
|
||||||
|
continue
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
'msgid': msg['msgid'],
|
'msgid': msg['msgid'],
|
||||||
}
|
}
|
||||||
|
@ -257,3 +310,43 @@ def async_handle_cloud(hass, cloud, payload):
|
||||||
payload['reason'])
|
payload['reason'])
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning("Received unknown cloud action: %s", action)
|
_LOGGER.warning("Received unknown cloud action: %s", action)
|
||||||
|
|
||||||
|
|
||||||
|
@HANDLERS.register('webhook')
|
||||||
|
async def async_handle_webhook(hass, cloud, payload):
|
||||||
|
"""Handle an incoming IoT message for cloud webhooks."""
|
||||||
|
cloudhook_id = payload['cloudhook_id']
|
||||||
|
|
||||||
|
found = None
|
||||||
|
for cloudhook in cloud.prefs.cloudhooks.values():
|
||||||
|
if cloudhook['cloudhook_id'] == cloudhook_id:
|
||||||
|
found = cloudhook
|
||||||
|
break
|
||||||
|
|
||||||
|
if found is None:
|
||||||
|
return {
|
||||||
|
'status': 200
|
||||||
|
}
|
||||||
|
|
||||||
|
request = MockRequest(
|
||||||
|
content=payload['body'].encode('utf-8'),
|
||||||
|
headers=payload['headers'],
|
||||||
|
method=payload['method'],
|
||||||
|
query_string=payload['query'],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await hass.components.webhook.async_handle_webhook(
|
||||||
|
found['webhook_id'], request)
|
||||||
|
|
||||||
|
response_dict = serialize_response(response)
|
||||||
|
body = response_dict.get('body')
|
||||||
|
if body:
|
||||||
|
body = body.decode('utf-8')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'body': body,
|
||||||
|
'status': response_dict['status'],
|
||||||
|
'headers': {
|
||||||
|
'Content-Type': response.content_type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""Preference management for cloud."""
|
"""Preference management for cloud."""
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||||
PREF_GOOGLE_ALLOW_UNLOCK)
|
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS)
|
||||||
|
|
||||||
STORAGE_KEY = DOMAIN
|
STORAGE_KEY = DOMAIN
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
|
@ -26,17 +26,20 @@ class CloudPreferences:
|
||||||
PREF_ENABLE_ALEXA: logged_in,
|
PREF_ENABLE_ALEXA: logged_in,
|
||||||
PREF_ENABLE_GOOGLE: logged_in,
|
PREF_ENABLE_GOOGLE: logged_in,
|
||||||
PREF_GOOGLE_ALLOW_UNLOCK: False,
|
PREF_GOOGLE_ALLOW_UNLOCK: False,
|
||||||
|
PREF_CLOUDHOOKS: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
self._prefs = prefs
|
self._prefs = prefs
|
||||||
|
|
||||||
async def async_update(self, *, google_enabled=_UNDEF,
|
async def async_update(self, *, google_enabled=_UNDEF,
|
||||||
alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF):
|
alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF,
|
||||||
|
cloudhooks=_UNDEF):
|
||||||
"""Update user preferences."""
|
"""Update user preferences."""
|
||||||
for key, value in (
|
for key, value in (
|
||||||
(PREF_ENABLE_GOOGLE, google_enabled),
|
(PREF_ENABLE_GOOGLE, google_enabled),
|
||||||
(PREF_ENABLE_ALEXA, alexa_enabled),
|
(PREF_ENABLE_ALEXA, alexa_enabled),
|
||||||
(PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock),
|
(PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock),
|
||||||
|
(PREF_CLOUDHOOKS, cloudhooks),
|
||||||
):
|
):
|
||||||
if value is not _UNDEF:
|
if value is not _UNDEF:
|
||||||
self._prefs[key] = value
|
self._prefs[key] = value
|
||||||
|
@ -61,3 +64,8 @@ class CloudPreferences:
|
||||||
def google_allow_unlock(self):
|
def google_allow_unlock(self):
|
||||||
"""Return if Google is allowed to unlock locks."""
|
"""Return if Google is allowed to unlock locks."""
|
||||||
return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False)
|
return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cloudhooks(self):
|
||||||
|
"""Return the published cloud webhooks."""
|
||||||
|
return self._prefs.get(PREF_CLOUDHOOKS, {})
|
||||||
|
|
|
@ -62,6 +62,28 @@ def async_generate_url(hass, webhook_id):
|
||||||
return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id)
|
return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bind_hass
|
||||||
|
async def async_handle_webhook(hass, webhook_id, request):
|
||||||
|
"""Handle a webhook."""
|
||||||
|
handlers = hass.data.setdefault(DOMAIN, {})
|
||||||
|
webhook = handlers.get(webhook_id)
|
||||||
|
|
||||||
|
# Always respond successfully to not give away if a hook exists or not.
|
||||||
|
if webhook is None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
'Received message for unregistered webhook %s', webhook_id)
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await webhook['handler'](hass, webhook_id, request)
|
||||||
|
if response is None:
|
||||||
|
response = Response(status=200)
|
||||||
|
return response
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Error processing webhook %s", webhook_id)
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass, config):
|
async def async_setup(hass, config):
|
||||||
"""Initialize the webhook component."""
|
"""Initialize the webhook component."""
|
||||||
hass.http.register_view(WebhookView)
|
hass.http.register_view(WebhookView)
|
||||||
|
@ -82,23 +104,7 @@ class WebhookView(HomeAssistantView):
|
||||||
async def post(self, request, webhook_id):
|
async def post(self, request, webhook_id):
|
||||||
"""Handle webhook call."""
|
"""Handle webhook call."""
|
||||||
hass = request.app['hass']
|
hass = request.app['hass']
|
||||||
handlers = hass.data.setdefault(DOMAIN, {})
|
return await async_handle_webhook(hass, webhook_id, request)
|
||||||
webhook = handlers.get(webhook_id)
|
|
||||||
|
|
||||||
# Always respond successfully to not give away if a hook exists or not.
|
|
||||||
if webhook is None:
|
|
||||||
_LOGGER.warning(
|
|
||||||
'Received message for unregistered webhook %s', webhook_id)
|
|
||||||
return Response(status=200)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await webhook['handler'](hass, webhook_id, request)
|
|
||||||
if response is None:
|
|
||||||
response = Response(status=200)
|
|
||||||
return response
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
_LOGGER.exception("Error processing webhook %s", webhook_id)
|
|
||||||
return Response(status=200)
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|
53
homeassistant/util/aiohttp.py
Normal file
53
homeassistant/util/aiohttp.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
"""Utilities to help with aiohttp."""
|
||||||
|
import json
|
||||||
|
from urllib.parse import parse_qsl
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from multidict import CIMultiDict, MultiDict
|
||||||
|
|
||||||
|
|
||||||
|
class MockRequest:
|
||||||
|
"""Mock an aiohttp request."""
|
||||||
|
|
||||||
|
def __init__(self, content: bytes, method: str = 'GET',
|
||||||
|
status: int = 200, headers: Optional[Dict[str, str]] = None,
|
||||||
|
query_string: Optional[str] = None, url: str = '') -> None:
|
||||||
|
"""Initialize a request."""
|
||||||
|
self.method = method
|
||||||
|
self.url = url
|
||||||
|
self.status = status
|
||||||
|
self.headers = CIMultiDict(headers or {}) # type: CIMultiDict[str]
|
||||||
|
self.query_string = query_string or ''
|
||||||
|
self._content = content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query(self) -> 'MultiDict[str]':
|
||||||
|
"""Return a dictionary with the query variables."""
|
||||||
|
return MultiDict(parse_qsl(self.query_string, keep_blank_values=True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _text(self) -> str:
|
||||||
|
"""Return the body as text."""
|
||||||
|
return self._content.decode('utf-8')
|
||||||
|
|
||||||
|
async def json(self) -> Any:
|
||||||
|
"""Return the body as JSON."""
|
||||||
|
return json.loads(self._text)
|
||||||
|
|
||||||
|
async def post(self) -> 'MultiDict[str]':
|
||||||
|
"""Return POST parameters."""
|
||||||
|
return MultiDict(parse_qsl(self._text, keep_blank_values=True))
|
||||||
|
|
||||||
|
async def text(self) -> str:
|
||||||
|
"""Return the body as text."""
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_response(response: web.Response) -> Dict[str, Any]:
|
||||||
|
"""Serialize an aiohttp response to a dictionary."""
|
||||||
|
return {
|
||||||
|
'status': response.status,
|
||||||
|
'body': response.body,
|
||||||
|
'headers': dict(response.headers),
|
||||||
|
}
|
33
tests/components/cloud/test_cloud_api.py
Normal file
33
tests/components/cloud/test_cloud_api.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
"""Test cloud API."""
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.cloud import cloud_api
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_check_token():
|
||||||
|
"""Mock check token."""
|
||||||
|
with patch('homeassistant.components.cloud.auth_api.'
|
||||||
|
'check_token') as mock_check_token:
|
||||||
|
yield mock_check_token
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_cloudhook(hass, aioclient_mock):
|
||||||
|
"""Test creating a cloudhook."""
|
||||||
|
aioclient_mock.post('https://example.com/bla', json={
|
||||||
|
'cloudhook_id': 'mock-webhook',
|
||||||
|
'url': 'https://blabla'
|
||||||
|
})
|
||||||
|
cloud = Mock(
|
||||||
|
hass=hass,
|
||||||
|
id_token='mock-id-token',
|
||||||
|
cloudhook_create_url='https://example.com/bla',
|
||||||
|
)
|
||||||
|
resp = await cloud_api.async_create_cloudhook(cloud)
|
||||||
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
|
assert await resp.json() == {
|
||||||
|
'cloudhook_id': 'mock-webhook',
|
||||||
|
'url': 'https://blabla'
|
||||||
|
}
|
70
tests/components/cloud/test_cloudhooks.py
Normal file
70
tests/components/cloud/test_cloudhooks.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
"""Test cloud cloudhooks."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.cloud import prefs, cloudhooks
|
||||||
|
|
||||||
|
from tests.common import mock_coro
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_cloudhooks(hass):
|
||||||
|
"""Mock cloudhooks class."""
|
||||||
|
cloud = Mock()
|
||||||
|
cloud.hass = hass
|
||||||
|
cloud.hass.async_add_executor_job = Mock(return_value=mock_coro())
|
||||||
|
cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro()))
|
||||||
|
cloud.cloudhook_create_url = 'https://webhook-create.url'
|
||||||
|
cloud.prefs = prefs.CloudPreferences(hass)
|
||||||
|
hass.loop.run_until_complete(cloud.prefs.async_initialize(True))
|
||||||
|
return cloudhooks.Cloudhooks(cloud)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enable(mock_cloudhooks, aioclient_mock):
|
||||||
|
"""Test enabling cloudhooks."""
|
||||||
|
aioclient_mock.post('https://webhook-create.url', json={
|
||||||
|
'cloudhook_id': 'mock-cloud-id',
|
||||||
|
'url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||||
|
})
|
||||||
|
|
||||||
|
hook = {
|
||||||
|
'webhook_id': 'mock-webhook-id',
|
||||||
|
'cloudhook_id': 'mock-cloud-id',
|
||||||
|
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||||
|
}
|
||||||
|
|
||||||
|
assert hook == await mock_cloudhooks.async_create('mock-webhook-id')
|
||||||
|
|
||||||
|
assert mock_cloudhooks.cloud.prefs.cloudhooks == {
|
||||||
|
'mock-webhook-id': hook
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls
|
||||||
|
assert len(publish_calls) == 1
|
||||||
|
assert publish_calls[0][1][0] == 'webhook-register'
|
||||||
|
assert publish_calls[0][1][1] == {
|
||||||
|
'cloudhook_ids': ['mock-cloud-id']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disable(mock_cloudhooks):
|
||||||
|
"""Test disabling cloudhooks."""
|
||||||
|
mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = {
|
||||||
|
'mock-webhook-id': {
|
||||||
|
'webhook_id': 'mock-webhook-id',
|
||||||
|
'cloudhook_id': 'mock-cloud-id',
|
||||||
|
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mock_cloudhooks.async_delete('mock-webhook-id')
|
||||||
|
|
||||||
|
assert mock_cloudhooks.cloud.prefs.cloudhooks == {}
|
||||||
|
|
||||||
|
publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls
|
||||||
|
assert len(publish_calls) == 1
|
||||||
|
assert publish_calls[0][1][0] == 'webhook-register'
|
||||||
|
assert publish_calls[0][1][1] == {
|
||||||
|
'cloudhook_ids': []
|
||||||
|
}
|
|
@ -527,3 +527,45 @@ async def test_websocket_update_preferences(hass, hass_ws_client,
|
||||||
assert not setup_api[PREF_ENABLE_GOOGLE]
|
assert not setup_api[PREF_ENABLE_GOOGLE]
|
||||||
assert not setup_api[PREF_ENABLE_ALEXA]
|
assert not setup_api[PREF_ENABLE_ALEXA]
|
||||||
assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK]
|
assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enabling_webhook(hass, hass_ws_client, setup_api):
|
||||||
|
"""Test we call right code to enable webhooks."""
|
||||||
|
hass.data[DOMAIN].id_token = jwt.encode({
|
||||||
|
'email': 'hello@home-assistant.io',
|
||||||
|
'custom:sub-exp': '2018-01-03'
|
||||||
|
}, 'test')
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks'
|
||||||
|
'.async_create', return_value=mock_coro()) as mock_enable:
|
||||||
|
await client.send_json({
|
||||||
|
'id': 5,
|
||||||
|
'type': 'cloud/cloudhook/create',
|
||||||
|
'webhook_id': 'mock-webhook-id',
|
||||||
|
})
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response['success']
|
||||||
|
|
||||||
|
assert len(mock_enable.mock_calls) == 1
|
||||||
|
assert mock_enable.mock_calls[0][1][0] == 'mock-webhook-id'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disabling_webhook(hass, hass_ws_client, setup_api):
|
||||||
|
"""Test we call right code to disable webhooks."""
|
||||||
|
hass.data[DOMAIN].id_token = jwt.encode({
|
||||||
|
'email': 'hello@home-assistant.io',
|
||||||
|
'custom:sub-exp': '2018-01-03'
|
||||||
|
}, 'test')
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks'
|
||||||
|
'.async_delete', return_value=mock_coro()) as mock_disable:
|
||||||
|
await client.send_json({
|
||||||
|
'id': 5,
|
||||||
|
'type': 'cloud/cloudhook/delete',
|
||||||
|
'webhook_id': 'mock-webhook-id',
|
||||||
|
})
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response['success']
|
||||||
|
|
||||||
|
assert len(mock_disable.mock_calls) == 1
|
||||||
|
assert mock_disable.mock_calls[0][1][0] == 'mock-webhook-id'
|
||||||
|
|
|
@ -30,7 +30,8 @@ def test_constructor_loads_info_from_constant():
|
||||||
'region': 'test-region',
|
'region': 'test-region',
|
||||||
'relayer': 'test-relayer',
|
'relayer': 'test-relayer',
|
||||||
'google_actions_sync_url': 'test-google_actions_sync_url',
|
'google_actions_sync_url': 'test-google_actions_sync_url',
|
||||||
'subscription_info_url': 'test-subscription-info-url'
|
'subscription_info_url': 'test-subscription-info-url',
|
||||||
|
'cloudhook_create_url': 'test-cloudhook_create_url',
|
||||||
}
|
}
|
||||||
}):
|
}):
|
||||||
result = yield from cloud.async_setup(hass, {
|
result = yield from cloud.async_setup(hass, {
|
||||||
|
@ -46,6 +47,7 @@ def test_constructor_loads_info_from_constant():
|
||||||
assert cl.relayer == 'test-relayer'
|
assert cl.relayer == 'test-relayer'
|
||||||
assert cl.google_actions_sync_url == 'test-google_actions_sync_url'
|
assert cl.google_actions_sync_url == 'test-google_actions_sync_url'
|
||||||
assert cl.subscription_info_url == 'test-subscription-info-url'
|
assert cl.subscription_info_url == 'test-subscription-info-url'
|
||||||
|
assert cl.cloudhook_create_url == 'test-cloudhook_create_url'
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import patch, MagicMock, PropertyMock
|
from unittest.mock import patch, MagicMock, PropertyMock
|
||||||
|
|
||||||
from aiohttp import WSMsgType, client_exceptions
|
from aiohttp import WSMsgType, client_exceptions, web
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
@ -406,3 +406,48 @@ async def test_refresh_token_expired(hass):
|
||||||
|
|
||||||
assert len(mock_check_token.mock_calls) == 1
|
assert len(mock_check_token.mock_calls) == 1
|
||||||
assert len(mock_create.mock_calls) == 1
|
assert len(mock_create.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_msg(hass):
|
||||||
|
"""Test webhook msg."""
|
||||||
|
cloud = Cloud(hass, MODE_DEV, None, None)
|
||||||
|
await cloud.prefs.async_initialize(True)
|
||||||
|
await cloud.prefs.async_update(cloudhooks={
|
||||||
|
'hello': {
|
||||||
|
'webhook_id': 'mock-webhook-id',
|
||||||
|
'cloudhook_id': 'mock-cloud-id'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
received = []
|
||||||
|
|
||||||
|
async def handler(hass, webhook_id, request):
|
||||||
|
"""Handle a webhook."""
|
||||||
|
received.append(request)
|
||||||
|
return web.json_response({'from': 'handler'})
|
||||||
|
|
||||||
|
hass.components.webhook.async_register(
|
||||||
|
'test', 'Test', 'mock-webhook-id', handler)
|
||||||
|
|
||||||
|
response = await iot.async_handle_webhook(hass, cloud, {
|
||||||
|
'cloudhook_id': 'mock-cloud-id',
|
||||||
|
'body': '{"hello": "world"}',
|
||||||
|
'headers': {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
'method': 'POST',
|
||||||
|
'query': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
'status': 200,
|
||||||
|
'body': '{"from": "handler"}',
|
||||||
|
'headers': {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert await received[0].json() == {
|
||||||
|
'hello': 'world'
|
||||||
|
}
|
||||||
|
|
54
tests/util/test_aiohttp.py
Normal file
54
tests/util/test_aiohttp.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
"""Test aiohttp request helper."""
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from homeassistant.util import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_json():
|
||||||
|
"""Test a JSON request."""
|
||||||
|
request = aiohttp.MockRequest(b'{"hello": 2}')
|
||||||
|
assert request.status == 200
|
||||||
|
assert await request.json() == {
|
||||||
|
'hello': 2
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_text():
|
||||||
|
"""Test a JSON request."""
|
||||||
|
request = aiohttp.MockRequest(b'hello', status=201)
|
||||||
|
assert request.status == 201
|
||||||
|
assert await request.text() == 'hello'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_post_query():
|
||||||
|
"""Test a JSON request."""
|
||||||
|
request = aiohttp.MockRequest(
|
||||||
|
b'hello=2&post=true', query_string='get=true', method='POST')
|
||||||
|
assert request.method == 'POST'
|
||||||
|
assert await request.post() == {
|
||||||
|
'hello': '2',
|
||||||
|
'post': 'true'
|
||||||
|
}
|
||||||
|
assert request.query == {
|
||||||
|
'get': 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_text():
|
||||||
|
"""Test serializing a text response."""
|
||||||
|
response = web.Response(status=201, text='Hello')
|
||||||
|
assert aiohttp.serialize_response(response) == {
|
||||||
|
'status': 201,
|
||||||
|
'body': b'Hello',
|
||||||
|
'headers': {'Content-Type': 'text/plain; charset=utf-8'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_json():
|
||||||
|
"""Test serializing a JSON response."""
|
||||||
|
response = web.json_response({"how": "what"})
|
||||||
|
assert aiohttp.serialize_response(response) == {
|
||||||
|
'status': 200,
|
||||||
|
'body': b'{"how": "what"}',
|
||||||
|
'headers': {'Content-Type': 'application/json; charset=utf-8'},
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue