From 7f3871028d7c5d260fbd4d720948e30cb3121c22 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sun, 13 Jan 2019 16:09:47 -0800 Subject: [PATCH] Split out gpslogger into a separate component and platform (#20044) * Split out gpslogger into a separate component and platform * Lint * Lint * Increase test coverage --- .coveragerc | 1 - .../components/device_tracker/gpslogger.py | 100 ++------- .../components/gpslogger/__init__.py | 114 +++++++++++ tests/components/gpslogger/__init__.py | 1 + tests/components/gpslogger/test_init.py | 189 ++++++++++++++++++ 5 files changed, 316 insertions(+), 89 deletions(-) create mode 100644 homeassistant/components/gpslogger/__init__.py create mode 100644 tests/components/gpslogger/__init__.py create mode 100644 tests/components/gpslogger/test_init.py diff --git a/.coveragerc b/.coveragerc index 568dfd79386..aedf311d6cd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -525,7 +525,6 @@ omit = homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/google_maps.py homeassistant/components/device_tracker/googlehome.py - homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/hitron_coda.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index f39684aa834..e0d9b37bf84 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -5,104 +5,28 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.gpslogger/ """ import logging -from hmac import compare_digest -from aiohttp.web import Request, HTTPUnauthorized -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_PASSWORD, HTTP_UNPROCESSABLE_ENTITY -) -from homeassistant.components.http import ( - CONF_API_PASSWORD, HomeAssistantView -) -# pylint: disable=unused-import -from homeassistant.components.device_tracker import ( # NOQA - DOMAIN, PLATFORM_SCHEMA -) +from homeassistant.components.gpslogger import TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['http'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_PASSWORD): cv.string, -}) +DEPENDENCIES = ['gpslogger'] async def async_setup_scanner(hass: HomeAssistantType, config: ConfigType, async_see, discovery_info=None): - """Set up an endpoint for the GPSLogger application.""" - hass.http.register_view(GPSLoggerView(async_see, config)) - - return True - - -class GPSLoggerView(HomeAssistantView): - """View to handle GPSLogger requests.""" - - url = '/api/gpslogger' - name = 'api:gpslogger' - - def __init__(self, async_see, config): - """Initialize GPSLogger url endpoints.""" - self.async_see = async_see - self._password = config.get(CONF_PASSWORD) - # this component does not require external authentication if - # password is set - self.requires_auth = self._password is None - - async def get(self, request: Request): - """Handle for GPSLogger message received as GET.""" - hass = request.app['hass'] - data = request.query - - if self._password is not None: - authenticated = CONF_API_PASSWORD in data and compare_digest( - self._password, - data[CONF_API_PASSWORD] - ) - if not authenticated: - raise HTTPUnauthorized() - - if 'latitude' not in data or 'longitude' not in data: - return ('Latitude and longitude not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - if 'device' not in data: - _LOGGER.error("Device id not specified") - return ('Device id not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - device = data['device'].replace('-', '') - gps_location = (data['latitude'], data['longitude']) - accuracy = 200 - battery = -1 - - if 'accuracy' in data: - accuracy = int(float(data['accuracy'])) - if 'battery' in data: - battery = float(data['battery']) - - attrs = {} - if 'speed' in data: - attrs['speed'] = float(data['speed']) - if 'direction' in data: - attrs['direction'] = float(data['direction']) - if 'altitude' in data: - attrs['altitude'] = float(data['altitude']) - if 'provider' in data: - attrs['provider'] = data['provider'] - if 'activity' in data: - attrs['activity'] = data['activity'] - - hass.async_create_task(self.async_see( + """Set up an endpoint for the GPSLogger device tracker.""" + async def _set_location(device, gps_location, battery, accuracy, attrs): + """Fire HA event to set location.""" + await async_see( dev_id=device, - gps=gps_location, battery=battery, + gps=gps_location, + battery=battery, gps_accuracy=accuracy, attributes=attrs - )) + ) - return 'Setting location for {}'.format(device) + async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) + return True diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py new file mode 100644 index 00000000000..e978e3706be --- /dev/null +++ b/homeassistant/components/gpslogger/__init__.py @@ -0,0 +1,114 @@ +""" +Support for GPSLogger. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/gpslogger/ +""" +import logging +from hmac import compare_digest + +import voluptuous as vol +from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp.web_request import Request + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView, CONF_API_PASSWORD +from homeassistant.const import CONF_PASSWORD, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'gpslogger' +DEPENDENCIES = ['http'] + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Optional(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +URL = '/api/{}'.format(DOMAIN) + +TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) + + +async def async_setup(hass, hass_config): + """Set up the GPSLogger component.""" + config = hass_config[DOMAIN] + hass.http.register_view(GPSLoggerView(config)) + + hass.async_create_task( + async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) + ) + return True + + +class GPSLoggerView(HomeAssistantView): + """View to handle GPSLogger requests.""" + + url = URL + name = 'api:gpslogger' + + def __init__(self, config): + """Initialize GPSLogger url endpoints.""" + self._password = config.get(CONF_PASSWORD) + # this component does not require external authentication if + # password is set + self.requires_auth = self._password is None + + async def get(self, request: Request): + """Handle for GPSLogger message received as GET.""" + hass = request.app['hass'] + data = request.query + + if self._password is not None: + authenticated = CONF_API_PASSWORD in data and compare_digest( + self._password, + data[CONF_API_PASSWORD] + ) + if not authenticated: + raise HTTPUnauthorized() + + if 'latitude' not in data or 'longitude' not in data: + return ('Latitude and longitude not specified.', + HTTP_UNPROCESSABLE_ENTITY) + + if 'device' not in data: + _LOGGER.error("Device id not specified") + return ('Device id not specified.', + HTTP_UNPROCESSABLE_ENTITY) + + device = data['device'].replace('-', '') + gps_location = (data['latitude'], data['longitude']) + accuracy = 200 + battery = -1 + + if 'accuracy' in data: + accuracy = int(float(data['accuracy'])) + if 'battery' in data: + battery = float(data['battery']) + + attrs = {} + if 'speed' in data: + attrs['speed'] = float(data['speed']) + if 'direction' in data: + attrs['direction'] = float(data['direction']) + if 'altitude' in data: + attrs['altitude'] = float(data['altitude']) + if 'provider' in data: + attrs['provider'] = data['provider'] + if 'activity' in data: + attrs['activity'] = data['activity'] + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + gps_location, + battery, + accuracy, + attrs + ) + + return 'Setting location for {}'.format(device) diff --git a/tests/components/gpslogger/__init__.py b/tests/components/gpslogger/__init__.py new file mode 100644 index 00000000000..636a9a767f9 --- /dev/null +++ b/tests/components/gpslogger/__init__.py @@ -0,0 +1 @@ +"""Tests for the GPSLogger component.""" diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py new file mode 100644 index 00000000000..539b9d549d3 --- /dev/null +++ b/tests/components/gpslogger/test_init.py @@ -0,0 +1,189 @@ +"""The tests the for GPSLogger device tracker platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components import zone +from homeassistant.components.device_tracker import \ + DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.gpslogger import URL, DOMAIN +from homeassistant.components.http import CONF_API_PASSWORD +from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, \ + STATE_HOME, STATE_NOT_HOME, HTTP_UNAUTHORIZED, CONF_PASSWORD +from homeassistant.setup import async_setup_component + +HOME_LATITUDE = 37.239622 +HOME_LONGITUDE = -115.815811 + + +def _url(data=None): + """Generate URL.""" + data = data or {} + data = "&".join(["{}={}".format(name, value) for + name, value in data.items()]) + return "{}?{}".format(URL, data) + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + +@pytest.fixture +def authenticated_gpslogger_client(loop, hass, hass_client): + """Locative mock client (authenticated).""" + assert loop.run_until_complete(async_setup_component( + hass, DOMAIN, { + DOMAIN: {} + })) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(hass_client()) + + +@pytest.fixture +def unauthenticated_gpslogger_client(loop, hass, aiohttp_client): + """Locative mock client (unauthenticated).""" + assert loop.run_until_complete(async_setup_component( + hass, 'persistent_notification', {})) + + assert loop.run_until_complete(async_setup_component( + hass, DOMAIN, { + DOMAIN: { + CONF_PASSWORD: 'test' + } + })) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@pytest.fixture(autouse=True) +def setup_zones(loop, hass): + """Set up Zone config in HA.""" + assert loop.run_until_complete(async_setup_component( + hass, zone.DOMAIN, { + 'zone': { + 'name': 'Home', + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'radius': 100, + }})) + + +async def test_authentication(hass, unauthenticated_gpslogger_client): + """Test missing data.""" + data = { + 'latitude': 1.0, + 'longitude': 1.1, + 'device': '123', + CONF_API_PASSWORD: 'test' + } + + # No auth + req = await unauthenticated_gpslogger_client.get(_url({})) + await hass.async_block_till_done() + assert req.status == HTTP_UNAUTHORIZED + + # Authenticated + req = await unauthenticated_gpslogger_client.get(_url(data)) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_NOT_HOME == state_name + + +async def test_missing_data(hass, authenticated_gpslogger_client): + """Test missing data.""" + data = { + 'latitude': 1.0, + 'longitude': 1.1, + 'device': '123', + } + + # No data + req = await authenticated_gpslogger_client.get(_url({})) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No latitude + copy = data.copy() + del copy['latitude'] + req = await authenticated_gpslogger_client.get(_url(copy)) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No device + copy = data.copy() + del copy['device'] + req = await authenticated_gpslogger_client.get(_url(copy)) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +async def test_enter_and_exit(hass, authenticated_gpslogger_client): + """Test when there is a known zone.""" + data = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '123', + } + + # Enter the Home + req = await authenticated_gpslogger_client.get(_url(data)) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_HOME == state_name + + # Enter Home again + req = await authenticated_gpslogger_client.get(_url(data)) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_HOME == state_name + + data['longitude'] = 0 + data['latitude'] = 0 + + # Enter Somewhere else + req = await authenticated_gpslogger_client.get(_url(data)) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_NOT_HOME == state_name + + +async def test_enter_with_attrs(hass, authenticated_gpslogger_client): + """Test when additional attributes are present.""" + data = { + 'latitude': 1.0, + 'longitude': 1.1, + 'device': '123', + 'accuracy': 10.5, + 'battery': 10, + 'speed': 100, + 'direction': 105.32, + 'altitude': 102, + 'provider': 'gps', + 'activity': 'running' + } + + req = await authenticated_gpslogger_client.get(_url(data)) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert STATE_NOT_HOME == state.state + assert 10 == state.attributes['gps_accuracy'] + assert 10.0 == state.attributes['battery'] + assert 100.0 == state.attributes['speed'] + assert 105.32 == state.attributes['direction'] + assert 102.0 == state.attributes['altitude'] + assert 'gps' == state.attributes['provider'] + assert 'running' == state.attributes['activity']