diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py index b9a813738ad..b9f379e7534 100644 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -4,52 +4,79 @@ Device tracker platform that adds support for OwnTracks over HTTP. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks_http/ """ +import json +import logging import re -from aiohttp.web_exceptions import HTTPInternalServerError - -from homeassistant.components.http import HomeAssistantView +from aiohttp.web import Response +import voluptuous as vol # pylint: disable=unused-import -from .owntracks import ( # NOQA - REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) +from homeassistant.components.device_tracker.owntracks import ( # NOQA + PLATFORM_SCHEMA, REQUIREMENTS, async_handle_message, context_from_config) +from homeassistant.const import CONF_WEBHOOK_ID +import homeassistant.helpers.config_validation as cv +DEPENDENCIES = ['webhook'] -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) + +EVENT_RECEIVED = 'owntracks_http_webhook_received' +EVENT_RESPONSE = 'owntracks_http_webhook_response_' + +DOMAIN = 'device_tracker.owntracks_http' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_WEBHOOK_ID): cv.string +}) async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" + """Set up OwnTracks HTTP component.""" context = context_from_config(async_see, config) - hass.http.register_view(OwnTracksView(context)) + subscription = context.mqtt_topic + topic = re.sub('/#$', '', subscription) - return True + async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + headers = request.headers + data = dict() + if 'X-Limit-U' in headers: + data['user'] = headers['X-Limit-U'] + elif 'u' in request.query: + data['user'] = request.query['u'] + else: + return Response( + body=json.dumps({'error': 'You need to supply username.'}), + content_type="application/json" + ) -class OwnTracksView(HomeAssistantView): - """View to handle OwnTracks HTTP requests.""" - - url = '/api/owntracks/{user}/{device}' - name = 'api:owntracks' - - def __init__(self, context): - """Initialize OwnTracks URL endpoints.""" - self.context = context - - async def post(self, request, user, device): - """Handle an OwnTracks message.""" - hass = request.app['hass'] - - subscription = self.context.mqtt_topic - topic = re.sub('/#$', '', subscription) + if 'X-Limit-D' in headers: + data['device'] = headers['X-Limit-D'] + elif 'd' in request.query: + data['device'] = request.query['d'] + else: + return Response( + body=json.dumps({'error': 'You need to supply device name.'}), + content_type="application/json" + ) message = await request.json() - message['topic'] = '{}/{}/{}'.format(topic, user, device) + + message['topic'] = '{}/{}/{}'.format(topic, data['user'], + data['device']) try: - await async_handle_message(hass, self.context, message) - return self.json([]) - + await async_handle_message(hass, context, message) + return Response(body=json.dumps([]), status=200, + content_type="application/json") except ValueError: - raise HTTPInternalServerError + _LOGGER.error("Received invalid JSON") + return None + + hass.components.webhook.async_register( + 'owntracks', 'OwnTracks', config['webhook_id'], handle_webhook) + + return True diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/device_tracker/test_owntracks_http.py index d7b48cafe46..a49f30c6839 100644 --- a/tests/components/device_tracker/test_owntracks_http.py +++ b/tests/components/device_tracker/test_owntracks_http.py @@ -2,11 +2,47 @@ import asyncio from unittest.mock import patch +import os import pytest +from homeassistant.components import device_tracker from homeassistant.setup import async_setup_component -from tests.common import mock_coro, mock_component +from tests.common import mock_component, mock_coro + +MINIMAL_LOCATION_MESSAGE = { + '_type': 'location', + 'lon': 45, + 'lat': 90, + 'p': 101.3977584838867, + 'tid': 'test', + 'tst': 1, +} + +LOCATION_MESSAGE = { + '_type': 'location', + 'acc': 60, + 'alt': 27, + 'batt': 92, + 'cog': 248, + 'lon': 45, + 'lat': 90, + 'p': 101.3977584838867, + 'tid': 'test', + 't': 'u', + 'tst': 1, + 'vac': 4, + 'vel': 0 +} + + +@pytest.fixture(autouse=True) +def owntracks_http_cleanup(hass): + """Remove known_devices.yaml.""" + try: + os.remove(hass.config.path(device_tracker.YAML_DEVICES)) + except OSError: + pass @pytest.fixture @@ -19,42 +55,70 @@ def mock_client(hass, aiohttp_client): hass.loop.run_until_complete( async_setup_component(hass, 'device_tracker', { 'device_tracker': { - 'platform': 'owntracks_http' + 'platform': 'owntracks_http', + 'webhook_id': 'owntracks_test' } })) return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) -@pytest.fixture -def mock_handle_message(): - """Mock async_handle_message.""" - with patch('homeassistant.components.device_tracker.' - 'owntracks_http.async_handle_message') as mock: - mock.return_value = mock_coro(None) - yield mock - - @asyncio.coroutine -def test_forward_message_correctly(mock_client, mock_handle_message): - """Test that we forward messages correctly to OwnTracks handle message.""" - resp = yield from mock_client.post('/api/owntracks/user/device', json={ - '_type': 'test' - }) +def test_handle_valid_message(mock_client): + """Test that we forward messages correctly to OwnTracks.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?' + 'u=test&d=test', + json=LOCATION_MESSAGE) + assert resp.status == 200 - assert len(mock_handle_message.mock_calls) == 1 - data = mock_handle_message.mock_calls[0][1][2] - assert data == { - '_type': 'test', - 'topic': 'owntracks/user/device' - } + json = yield from resp.json() + assert json == [] @asyncio.coroutine -def test_handle_value_error(mock_client, mock_handle_message): - """Test that we handle errors from handle message correctly.""" - mock_handle_message.side_effect = ValueError - resp = yield from mock_client.post('/api/owntracks/user/device', json={ - '_type': 'test' - }) - assert resp.status == 500 +def test_handle_valid_minimal_message(mock_client): + """Test that we forward messages correctly to OwnTracks.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?' + 'u=test&d=test', + json=MINIMAL_LOCATION_MESSAGE) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == [] + + +@asyncio.coroutine +def test_handle_value_error(mock_client): + """Test we don't disclose that this is a valid webhook.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test' + '?u=test&d=test', json='') + + assert resp.status == 200 + + json = yield from resp.text() + assert json == "" + + +@asyncio.coroutine +def test_returns_error_missing_username(mock_client): + """Test that an error is returned when username is missing.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?d=test', + json=LOCATION_MESSAGE) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == {'error': 'You need to supply username.'} + + +@asyncio.coroutine +def test_returns_error_missing_device(mock_client): + """Test that an error is returned when device name is missing.""" + resp = yield from mock_client.post('/api/webhook/owntracks_test?u=test', + json=LOCATION_MESSAGE) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == {'error': 'You need to supply device name.'}