Add websocket API (#4582)

* Add websocket API

* Add identifiers to interactions

* Allow unsubscribing event listeners

* Add support for fetching data

* Clean up handling code websockets api

* Lint

* Add Home Assistant version to auth messages

* Py.test be less verbose in tox
This commit is contained in:
Paulus Schoutsen 2016-11-26 18:23:28 -08:00 committed by GitHub
parent 03e0c7c71c
commit 914a868fbd
7 changed files with 831 additions and 14 deletions

View File

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket debug</title>
<style>
.controls {
display: flex;
flex-direction: row;
}
.controls textarea {
height: 160px;
min-width: 400px;
margin-right: 24px;
}
</style>
</head>
<body>
<div class='controls'>
<textarea id="messageinput">
{
"id": 1, "type": "subscribe_events", "event_type": "state_changed"
}
</textarea>
<pre>
Examples:
{
"id": 2, "type": "subscribe_events", "event_type": "state_changed"
}
{
"id": 3, "type": "call_service", "domain": "light", "service": "turn_off"
}
{
"id": 4, "type": "unsubscribe_events", "subscription": 2
}
{
"id": 5, "type": "get_states"
}
{
"id": 6, "type": "get_config"
}
{
"id": 7, "type": "get_services"
}
{
"id": 8, "type": "get_panels"
}
</pre>
</div>
<div>
<button type="button" onclick="openSocket();" >Open</button>
<button type="button" onclick="send();" >Send</button>
<button type="button" onclick="closeSocket();" >Close</button>
</div>
<!-- Server responses get written here -->
<pre id="messages"></pre>
<!-- Script to utilise the WebSocket -->
<script type="text/javascript">
var webSocket;
var messages = document.getElementById("messages");
function openSocket(){
var isOpen = false;
// Ensures only one connection is open at a time
if(webSocket !== undefined && webSocket.readyState !== WebSocket.CLOSED){
writeResponse("WebSocket is already opened.");
return;
}
// Create a new instance of the websocket
webSocket = new WebSocket("ws://localhost:8123/api/websocket");
/**
* Binds functions to the listeners for the websocket.
*/
webSocket.onopen = function(event){
if (!isOpen) {
isOpen = true;
writeResponse('Connection opened');
}
// For reasons I can't determine, onopen gets called twice
// and the first time event.data is undefined.
// Leave a comment if you know the answer.
if(event.data === undefined)
return;
writeResponse(event.data);
};
webSocket.onmessage = function(event){
writeResponse(event.data);
};
webSocket.onclose = function(event){
writeResponse("Connection closed");
};
}
/**
* Sends the value of the text input to the server
*/
function send(){
var text = document.getElementById("messageinput").value;
webSocket.send(text);
}
function closeSocket(){
webSocket.close();
}
function writeResponse(text){
messages.innerHTML += "\n" + text;
}
openSocket();
</script>
</body>
</html>

View File

@ -28,18 +28,17 @@ def auth_middleware(app, handler):
@asyncio.coroutine
def auth_middleware_handler(request):
"""Auth middleware to check authentication."""
hass = app['hass']
# Auth code verbose on purpose
authenticated = False
if hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''),
hass.http.api_password):
if (HTTP_HEADER_HA_AUTH in request.headers and
validate_password(request,
request.headers[HTTP_HEADER_HA_AUTH])):
# A valid auth header has been set
authenticated = True
elif hmac.compare_digest(request.GET.get(DATA_API_PASSWORD, ''),
hass.http.api_password):
elif (DATA_API_PASSWORD in request.GET and
validate_password(request, request.GET[DATA_API_PASSWORD])):
authenticated = True
elif is_trusted_ip(request):
@ -59,3 +58,9 @@ def is_trusted_ip(request):
return ip_addr and any(
ip_addr in trusted_network for trusted_network
in request.app[KEY_TRUSTED_NETWORKS])
def validate_password(request, api_password):
"""Test if password is valid."""
return hmac.compare_digest(api_password,
request.app['hass'].http.api_password)

View File

@ -0,0 +1,401 @@
"""Websocket based API for Home Assistant."""
import asyncio
from functools import partial
import json
import logging
from aiohttp import web
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.const import (
MATCH_ALL, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP,
__version__)
from homeassistant.components import api, frontend
from homeassistant.core import callback
from homeassistant.remote import JSONEncoder
from homeassistant.helpers import config_validation as cv
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.auth import validate_password
from homeassistant.components.http.const import KEY_AUTHENTICATED
DOMAIN = 'websocket_api'
URL = "/api/websocket"
DEPENDENCIES = 'http',
ERR_ID_REUSE = 1
ERR_INVALID_FORMAT = 2
ERR_NOT_FOUND = 3
TYPE_AUTH = 'auth'
TYPE_AUTH_OK = 'auth_ok'
TYPE_AUTH_REQUIRED = 'auth_required'
TYPE_AUTH_INVALID = 'auth_invalid'
TYPE_EVENT = 'event'
TYPE_SUBSCRIBE_EVENTS = 'subscribe_events'
TYPE_UNSUBSCRIBE_EVENTS = 'unsubscribe_events'
TYPE_CALL_SERVICE = 'call_service'
TYPE_GET_STATES = 'get_states'
TYPE_GET_SERVICES = 'get_services'
TYPE_GET_CONFIG = 'get_config'
TYPE_GET_PANELS = 'get_panels'
TYPE_RESULT = 'result'
_LOGGER = logging.getLogger(__name__)
JSON_DUMP = partial(json.dumps, cls=JSONEncoder)
AUTH_MESSAGE_SCHEMA = vol.Schema({
vol.Required('type'): TYPE_AUTH,
vol.Required('api_password'): str,
})
SUBSCRIBE_EVENTS_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): TYPE_SUBSCRIBE_EVENTS,
vol.Optional('event_type', default=MATCH_ALL): str,
})
UNSUBSCRIBE_EVENTS_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): TYPE_UNSUBSCRIBE_EVENTS,
vol.Required('subscription'): cv.positive_int,
})
CALL_SERVICE_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): TYPE_CALL_SERVICE,
vol.Required('domain'): str,
vol.Required('service'): str,
vol.Optional('service_data', default=None): dict
})
GET_STATES_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): TYPE_GET_STATES,
})
GET_SERVICES_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): TYPE_GET_SERVICES,
})
GET_CONFIG_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): TYPE_GET_CONFIG,
})
GET_PANELS_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): TYPE_GET_PANELS,
})
BASE_COMMAND_MESSAGE_SCHEMA = vol.Schema({
vol.Required('id'): cv.positive_int,
vol.Required('type'): vol.Any(TYPE_CALL_SERVICE,
TYPE_SUBSCRIBE_EVENTS,
TYPE_UNSUBSCRIBE_EVENTS,
TYPE_GET_STATES,
TYPE_GET_SERVICES,
TYPE_GET_CONFIG,
TYPE_GET_PANELS)
}, extra=vol.ALLOW_EXTRA)
def auth_ok_message():
"""Return an auth_ok message."""
return {
'type': TYPE_AUTH_OK,
'ha_version': __version__,
}
def auth_required_message():
"""Return an auth_required message."""
return {
'type': TYPE_AUTH_REQUIRED,
'ha_version': __version__,
}
def auth_invalid_message(message):
"""Return an auth_invalid message."""
return {
'type': TYPE_AUTH_INVALID,
'message': message,
}
def event_message(iden, event):
"""Return an event message."""
return {
'id': iden,
'type': TYPE_EVENT,
'event': event.as_dict(),
}
def error_message(iden, code, message):
"""Return an error result message."""
return {
'id': iden,
'type': TYPE_RESULT,
'success': False,
'error': {
'code': code,
'message': message,
},
}
def result_message(iden, result=None):
"""Return a success result message."""
return {
'id': iden,
'type': TYPE_RESULT,
'success': True,
'result': result,
}
@asyncio.coroutine
def async_setup(hass, config):
"""Initialize the websocket API."""
hass.http.register_view(WebsocketAPIView)
return True
class WebsocketAPIView(HomeAssistantView):
"""View to serve a websockets endpoint."""
name = "websocketapi"
url = URL
requires_auth = False
@asyncio.coroutine
def get(self, request):
"""Handle an incoming websocket connection."""
# pylint: disable=no-self-use
return ActiveConnection(request.app['hass'], request).handle()
class ActiveConnection:
"""Handle an active websocket client connection."""
def __init__(self, hass, request):
"""Initialize an active connection."""
self.hass = hass
self.request = request
self.wsock = None
self.socket_task = None
self.event_listeners = {}
def debug(self, message1, message2=''):
"""Print a debug message."""
_LOGGER.debug('WS %s: %s %s', id(self.wsock), message1, message2)
def log_error(self, message1, message2=''):
"""Print an error message."""
_LOGGER.error('WS %s: %s %s', id(self.wsock), message1, message2)
def send_message(self, message):
"""Helper method to send messages."""
self.debug('Sending', message)
self.wsock.send_json(message, dumps=JSON_DUMP)
@callback
def _cancel_connection(self, event):
"""Cancel this connection."""
self.socket_task.cancel()
@asyncio.coroutine
def _call_service_helper(self, msg):
"""Helper to call a service and fire complete message."""
yield from self.hass.services.async_call(msg['domain'], msg['service'],
msg['service_data'], True)
try:
self.send_message(result_message(msg['id']))
except RuntimeError:
# Socket has been closed.
pass
@callback
def _forward_event(self, iden, event):
"""Helper to forward events to websocket."""
if event.event_type == EVENT_TIME_CHANGED:
return
try:
self.send_message(event_message(iden, event))
except RuntimeError:
# Socket has been closed.
pass
@asyncio.coroutine
def handle(self):
"""Handle the websocket connection."""
wsock = self.wsock = web.WebSocketResponse()
yield from wsock.prepare(self.request)
# Set up to cancel this connection when Home Assistant shuts down
self.socket_task = asyncio.Task.current_task(loop=self.hass.loop)
self.hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP,
self._cancel_connection)
self.debug('Connected')
msg = None
authenticated = False
try:
if self.request[KEY_AUTHENTICATED]:
authenticated = True
else:
self.send_message(auth_required_message())
msg = yield from wsock.receive_json()
msg = AUTH_MESSAGE_SCHEMA(msg)
if validate_password(self.request, msg['api_password']):
authenticated = True
else:
self.debug('Invalid password')
self.send_message(auth_invalid_message('Invalid password'))
return wsock
if not authenticated:
return wsock
self.send_message(auth_ok_message())
msg = yield from wsock.receive_json()
last_id = 0
while msg:
self.debug('Received', msg)
msg = BASE_COMMAND_MESSAGE_SCHEMA(msg)
cur_id = msg['id']
if cur_id <= last_id:
self.send_message(error_message(
cur_id, ERR_ID_REUSE,
'Identifier values have to increase.'))
else:
handler_name = 'handle_{}'.format(msg['type'])
getattr(self, handler_name)(msg)
last_id = cur_id
msg = yield from wsock.receive_json()
except vol.Invalid as err:
error_msg = 'Message incorrectly formatted: '
if msg:
error_msg += humanize_error(msg, err)
else:
error_msg += str(err)
self.log_error(error_msg)
if not authenticated:
self.send_message(auth_invalid_message(error_msg))
else:
if isinstance(msg, dict):
iden = msg.get('id')
else:
iden = None
self.send_message(error_message(iden, ERR_INVALID_FORMAT,
error_msg))
except TypeError as err:
if wsock.closed:
self.debug('Connection closed by client')
else:
self.log_error('Unexpected TypeError', msg)
except ValueError as err:
msg = 'Received invalid JSON'
value = getattr(err, 'doc', None) # Py3.5+ only
if value:
msg += ': {}'.format(value)
self.log_error(msg)
except asyncio.CancelledError:
self.debug('Connection cancelled by server')
except Exception: # pylint: disable=broad-except
error = 'Unexpected error inside websocket API. '
if msg is not None:
error += str(msg)
_LOGGER.exception(error)
finally:
for unsub in self.event_listeners.values():
unsub()
yield from wsock.close()
self.debug('Closed connection')
return wsock
def handle_subscribe_events(self, msg):
"""Handle subscribe events command."""
msg = SUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg)
self.event_listeners[msg['id']] = self.hass.bus.async_listen(
msg['event_type'], partial(self._forward_event, msg['id']))
self.send_message(result_message(msg['id']))
def handle_unsubscribe_events(self, msg):
"""Handle unsubscribe events command."""
msg = UNSUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg)
subscription = msg['subscription']
if subscription not in self.event_listeners:
self.send_message(error_message(
msg['id'], ERR_NOT_FOUND,
'Subscription not found.'))
else:
self.event_listeners.pop(subscription)()
self.send_message(result_message(msg['id']))
def handle_call_service(self, msg):
"""Handle call service command."""
msg = CALL_SERVICE_MESSAGE_SCHEMA(msg)
self.hass.async_add_job(self._call_service_helper(msg))
def handle_get_states(self, msg):
"""Handle get states command."""
msg = GET_STATES_MESSAGE_SCHEMA(msg)
self.send_message(result_message(msg['id'],
self.hass.states.async_all()))
def handle_get_services(self, msg):
"""Handle get services command."""
msg = GET_SERVICES_MESSAGE_SCHEMA(msg)
self.send_message(result_message(msg['id'],
api.async_services_json(self.hass)))
def handle_get_config(self, msg):
"""Handle get config command."""
msg = GET_CONFIG_MESSAGE_SCHEMA(msg)
self.send_message(result_message(msg['id'],
self.hass.config.as_dict()))
def handle_get_panels(self, msg):
"""Handle get panels command."""
msg = GET_PANELS_MESSAGE_SCHEMA(msg)
self.send_message(result_message(
msg['id'], self.hass.data[frontend.DATA_PANELS]))

View File

@ -12,5 +12,6 @@ pytest-asyncio>=0.5.0
pytest-cov>=2.3.1
pytest-timeout>=1.2.0
pytest-catchlog>=1.2.2
pytest-sugar>=0.7.1
requests_mock>=1.0
mock-open>=1.3.1

View File

@ -3,8 +3,7 @@ import asyncio
import os
import sys
from datetime import timedelta
from unittest import mock
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from io import StringIO
import logging
import threading
@ -26,7 +25,7 @@ from homeassistant.const import (
from homeassistant.components import sun, mqtt
from homeassistant.components.http.auth import auth_middleware
from homeassistant.components.http.const import (
KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED)
KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS)
_TEST_INSTANCE_PORT = SERVER_PORT
_LOGGER = logging.getLogger(__name__)
@ -207,7 +206,7 @@ def mock_state_change_event(hass, new_state, old_state=None):
def mock_http_component(hass):
"""Mock the HTTP component."""
hass.http = mock.MagicMock()
hass.http = MagicMock()
hass.config.components.append('http')
hass.http.views = {}
@ -222,19 +221,20 @@ def mock_http_component(hass):
hass.http.register_view = mock_register_view
def mock_http_component_app(hass):
def mock_http_component_app(hass, api_password=None):
"""Create an aiohttp.web.Application instance for testing."""
hass.http.api_password = None
hass.http = MagicMock(api_password=api_password)
app = web.Application(middlewares=[auth_middleware], loop=hass.loop)
app['hass'] = hass
app[KEY_USE_X_FORWARDED_FOR] = False
app[KEY_BANS_ENABLED] = False
app[KEY_TRUSTED_NETWORKS] = []
return app
def mock_mqtt_component(hass):
"""Mock the MQTT component."""
with mock.patch('homeassistant.components.mqtt.MQTT') as mock_mqtt:
with patch('homeassistant.components.mqtt.MQTT') as mock_mqtt:
setup_component(hass, mqtt.DOMAIN, {
mqtt.DOMAIN: {
mqtt.CONF_BROKER: 'mock-broker',

View File

@ -0,0 +1,285 @@
import asyncio
from unittest.mock import patch
from aiohttp import WSMsgType
from async_timeout import timeout
import pytest
from homeassistant.core import callback
from homeassistant.components import websocket_api as wapi, api, frontend
from tests.common import mock_http_component_app
API_PASSWORD = 'test1234'
@pytest.fixture
def websocket_client(loop, hass, test_client):
"""Websocket client fixture connected to websocket server."""
websocket_app = mock_http_component_app(hass)
wapi.WebsocketAPIView().register(websocket_app.router)
client = loop.run_until_complete(test_client(websocket_app))
ws = loop.run_until_complete(client.ws_connect(wapi.URL))
auth_ok = loop.run_until_complete(ws.receive_json())
assert auth_ok['type'] == wapi.TYPE_AUTH_OK
yield ws
if not ws.closed:
loop.run_until_complete(ws.close())
@pytest.fixture
def no_auth_websocket_client(hass, loop, test_client):
"""Websocket connection that requires authentication."""
websocket_app = mock_http_component_app(hass, API_PASSWORD)
wapi.WebsocketAPIView().register(websocket_app.router)
client = loop.run_until_complete(test_client(websocket_app))
ws = loop.run_until_complete(client.ws_connect(wapi.URL))
auth_ok = loop.run_until_complete(ws.receive_json())
assert auth_ok['type'] == wapi.TYPE_AUTH_REQUIRED
yield ws
if not ws.closed:
loop.run_until_complete(ws.close())
@asyncio.coroutine
def test_auth_via_msg(no_auth_websocket_client):
"""Test authenticating."""
no_auth_websocket_client.send_json({
'type': wapi.TYPE_AUTH,
'api_password': API_PASSWORD
})
msg = yield from no_auth_websocket_client.receive_json()
assert msg['type'] == wapi.TYPE_AUTH_OK
@asyncio.coroutine
def test_auth_via_msg_incorrect_pass(no_auth_websocket_client):
"""Test authenticating."""
no_auth_websocket_client.send_json({
'type': wapi.TYPE_AUTH,
'api_password': API_PASSWORD + 'wrong'
})
msg = yield from no_auth_websocket_client.receive_json()
assert msg['type'] == wapi.TYPE_AUTH_INVALID
assert msg['message'] == 'Invalid password'
@asyncio.coroutine
def test_pre_auth_only_auth_allowed(no_auth_websocket_client):
"""Verify that before authentication, only auth messages are allowed."""
no_auth_websocket_client.send_json({
'type': wapi.TYPE_CALL_SERVICE,
'domain': 'domain_test',
'service': 'test_service',
'service_data': {
'hello': 'world'
}
})
msg = yield from no_auth_websocket_client.receive_json()
assert msg['type'] == wapi.TYPE_AUTH_INVALID
assert msg['message'].startswith('Message incorrectly formatted')
@asyncio.coroutine
def test_invalid_message_format(websocket_client):
"""Test sending invalid JSON."""
websocket_client.send_json({'type': 5})
msg = yield from websocket_client.receive_json()
assert msg['type'] == wapi.TYPE_RESULT
error = msg['error']
assert error['code'] == wapi.ERR_INVALID_FORMAT
assert error['message'].startswith('Message incorrectly formatted')
@asyncio.coroutine
def test_invalid_json(websocket_client):
"""Test sending invalid JSON."""
websocket_client.send_str('this is not JSON')
msg = yield from websocket_client.receive()
assert msg.type == WSMsgType.close
@asyncio.coroutine
def test_quiting_hass(hass, websocket_client):
"""Test sending invalid JSON."""
with patch.object(hass.loop, 'stop'):
yield from hass.async_stop()
msg = yield from websocket_client.receive()
assert msg.type == WSMsgType.CLOSE
@asyncio.coroutine
def test_call_service(hass, websocket_client):
"""Test call service command."""
calls = []
@callback
def service_call(call):
calls.append(call)
hass.services.async_register('domain_test', 'test_service', service_call)
websocket_client.send_json({
'id': 5,
'type': wapi.TYPE_CALL_SERVICE,
'domain': 'domain_test',
'service': 'test_service',
'service_data': {
'hello': 'world'
}
})
msg = yield from websocket_client.receive_json()
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
assert len(calls) == 1
call = calls[0]
assert call.domain == 'domain_test'
assert call.service == 'test_service'
assert call.data == {'hello': 'world'}
@asyncio.coroutine
def test_subscribe_unsubscribe_events(hass, websocket_client):
"""Test subscribe/unsubscribe events command."""
init_count = sum(hass.bus.async_listeners().values())
websocket_client.send_json({
'id': 5,
'type': wapi.TYPE_SUBSCRIBE_EVENTS,
'event_type': 'test_event'
})
msg = yield from websocket_client.receive_json()
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
# Verify we have a new listener
assert sum(hass.bus.async_listeners().values()) == init_count + 1
hass.bus.async_fire('ignore_event')
hass.bus.async_fire('test_event', {'hello': 'world'})
hass.bus.async_fire('ignore_event')
with timeout(3, loop=hass.loop):
msg = yield from websocket_client.receive_json()
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_EVENT
event = msg['event']
assert event['event_type'] == 'test_event'
assert event['data'] == {'hello': 'world'}
assert event['origin'] == 'LOCAL'
websocket_client.send_json({
'id': 6,
'type': wapi.TYPE_UNSUBSCRIBE_EVENTS,
'subscription': 5
})
msg = yield from websocket_client.receive_json()
assert msg['id'] == 6
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
# Check our listener got unsubscribed
assert sum(hass.bus.async_listeners().values()) == init_count
@asyncio.coroutine
def test_get_states(hass, websocket_client):
""" Test get_states command."""
hass.states.async_set('greeting.hello', 'world')
hass.states.async_set('greeting.bye', 'universe')
websocket_client.send_json({
'id': 5,
'type': wapi.TYPE_GET_STATES,
})
msg = yield from websocket_client.receive_json()
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
states = []
for state in hass.states.async_all():
state = state.as_dict()
state['last_changed'] = state['last_changed'].isoformat()
state['last_updated'] = state['last_updated'].isoformat()
states.append(state)
assert msg['result'] == states
@asyncio.coroutine
def test_get_services(hass, websocket_client):
""" Test get_services command."""
websocket_client.send_json({
'id': 5,
'type': wapi.TYPE_GET_SERVICES,
})
msg = yield from websocket_client.receive_json()
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
assert msg['result'] == api.async_services_json(hass)
@asyncio.coroutine
def test_get_config(hass, websocket_client):
""" Test get_config command."""
websocket_client.send_json({
'id': 5,
'type': wapi.TYPE_GET_CONFIG,
})
msg = yield from websocket_client.receive_json()
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
assert msg['result'] == hass.config.as_dict()
@asyncio.coroutine
def test_get_panels(hass, websocket_client):
""" Test get_panels command."""
frontend.register_built_in_panel(hass, 'map', 'Map',
'mdi:account-location')
websocket_client.send_json({
'id': 5,
'type': wapi.TYPE_GET_PANELS,
})
msg = yield from websocket_client.receive_json()
assert msg['id'] == 5
assert msg['type'] == wapi.TYPE_RESULT
assert msg['success']
assert msg['result'] == hass.data[frontend.DATA_PANELS]

View File

@ -11,7 +11,7 @@ setenv =
LANG=en_US.UTF-8
PYTHONPATH = {toxinidir}:{toxinidir}/homeassistant
commands =
py.test -v --timeout=30 --duration=10 --cov --cov-report= {posargs}
py.test --timeout=30 --duration=10 --cov --cov-report= {posargs}
deps =
-r{toxinidir}/requirements_all.txt
-r{toxinidir}/requirements_test.txt