diff --git a/homeassistant/components/frontend/www_static/websocket_test.html b/homeassistant/components/frontend/www_static/websocket_test.html new file mode 100644 index 0000000000000000000000000000000000000000..d4c0974899c6f6c69adc908b144fb52957487afe --- /dev/null +++ b/homeassistant/components/frontend/www_static/websocket_test.html @@ -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> diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 14b442e5dde43557974044c8d851cb2605b1d5fb..6ff653eef358c66da3da4790957098583f6f9bb5 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -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) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py new file mode 100644 index 0000000000000000000000000000000000000000..391c27e88941f6fecab4f0bbc539b515887871eb --- /dev/null +++ b/homeassistant/components/websocket_api.py @@ -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])) diff --git a/requirements_test.txt b/requirements_test.txt index 838e4c96875e8938d02bff4ad174c7d340b002c1..2dbc98326dba9b8c77df85e7d5119401b8da8c94 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -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 diff --git a/tests/common.py b/tests/common.py index fc779e120f8fba50a4beb52fbf1fda6502c3c553..25a674dd995f3880ab323c5c3999e7ae9a343d0c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -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', diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py new file mode 100644 index 0000000000000000000000000000000000000000..5b70f0cde100e3cabb61cce74f0229e861246393 --- /dev/null +++ b/tests/components/test_websocket_api.py @@ -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] diff --git a/tox.ini b/tox.ini index 609e17087b0fb6744329596424bdb2821bed20f1..1cf402468b587584a4d334d8b3fddcbdcd61e319 100644 --- a/tox.ini +++ b/tox.ini @@ -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