From bdba3852d03e948495d875ee6ed687fe93166bc9 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Tue, 6 Nov 2018 11:12:03 -0800 Subject: [PATCH] Split out geofency with a component and platform (#17933) * Split out geofency with a component and platform * Make geofency component/device_tracker more async * Move geofency tests to new package * Remove coroutine in geofency callback * Lint * Fix coroutine in geofency callback * Fix incorrect patch --- .../components/device_tracker/geofency.py | 131 ++-------------- homeassistant/components/geofency/__init__.py | 146 ++++++++++++++++++ .../device_tracker/test_owntracks.py | 14 +- tests/components/geofency/__init__.py | 1 + .../test_init.py} | 56 +++---- 5 files changed, 197 insertions(+), 151 deletions(-) create mode 100644 homeassistant/components/geofency/__init__.py create mode 100644 tests/components/geofency/__init__.py rename tests/components/{device_tracker/test_geofency.py => geofency/test_init.py} (82%) diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py index 3687571c118..cec494f322c 100644 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -4,129 +4,26 @@ Support for the Geofency platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.geofency/ """ -from functools import partial import logging -import voluptuous as vol - -from homeassistant.components.device_tracker import PLATFORM_SCHEMA -from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify +from homeassistant.components.geofency import TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['http'] - -ATTR_CURRENT_LATITUDE = 'currentLatitude' -ATTR_CURRENT_LONGITUDE = 'currentLongitude' - -BEACON_DEV_PREFIX = 'beacon' -CONF_MOBILE_BEACONS = 'mobile_beacons' - -LOCATION_ENTRY = '1' -LOCATION_EXIT = '0' - -URL = '/api/geofency' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MOBILE_BEACONS): vol.All( - cv.ensure_list, [cv.string]), -}) +DEPENDENCIES = ['geofency'] -def setup_scanner(hass, config, see, discovery_info=None): - """Set up an endpoint for the Geofency application.""" - mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] - - hass.http.register_view(GeofencyView(see, mobile_beacons)) - - return True - - -class GeofencyView(HomeAssistantView): - """View to handle Geofency requests.""" - - url = URL - name = 'api:geofency' - - def __init__(self, see, mobile_beacons): - """Initialize Geofency url endpoints.""" - self.see = see - self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] - - async def post(self, request): - """Handle Geofency requests.""" - data = await request.post() - hass = request.app['hass'] - - data = self._validate_data(data) - if not data: - return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) - - if self._is_mobile_beacon(data): - return await self._set_location(hass, data, None) - if data['entry'] == LOCATION_ENTRY: - location_name = data['name'] - else: - location_name = STATE_NOT_HOME - if ATTR_CURRENT_LATITUDE in data: - data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] - data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] - - return await self._set_location(hass, data, location_name) - - @staticmethod - def _validate_data(data): - """Validate POST payload.""" - data = data.copy() - - required_attributes = ['address', 'device', 'entry', - 'latitude', 'longitude', 'name'] - - valid = True - for attribute in required_attributes: - if attribute not in data: - valid = False - _LOGGER.error("'%s' not specified in message", attribute) - - if not valid: - return False - - data['address'] = data['address'].replace('\n', ' ') - data['device'] = slugify(data['device']) - data['name'] = slugify(data['name']) - - gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] - - for attribute in gps_attributes: - if attribute in data: - data[attribute] = float(data[attribute]) - - return data - - def _is_mobile_beacon(self, data): - """Check if we have a mobile beacon.""" - return 'beaconUUID' in data and data['name'] in self.mobile_beacons - - @staticmethod - def _device_name(data): - """Return name of device tracker.""" - if 'beaconUUID' in data: - return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - return data['device'] - - async def _set_location(self, hass, data, location_name): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up the Geofency device tracker.""" + async def _set_location(device, gps, location_name, attributes): """Fire HA event to set location.""" - device = self._device_name(data) + await async_see( + dev_id=device, + gps=gps, + location_name=location_name, + attributes=attributes + ) - await hass.async_add_job( - partial(self.see, dev_id=device, - gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), - location_name=location_name, - attributes=data)) - - return "Setting location for {}".format(device) + async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) + return True diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py new file mode 100644 index 00000000000..92f8f475e65 --- /dev/null +++ b/homeassistant/components/geofency/__init__.py @@ -0,0 +1,146 @@ +""" +Support for Geofency. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/geofency/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, \ + ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'geofency' +DEPENDENCIES = ['http'] + +CONF_MOBILE_BEACONS = 'mobile_beacons' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Optional(CONF_MOBILE_BEACONS, default=[]): vol.All( + cv.ensure_list, + [cv.string] + ), + }), +}, extra=vol.ALLOW_EXTRA) + +ATTR_CURRENT_LATITUDE = 'currentLatitude' +ATTR_CURRENT_LONGITUDE = 'currentLongitude' + +BEACON_DEV_PREFIX = 'beacon' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) + + +async def async_setup(hass, hass_config): + """Set up the Geofency component.""" + config = hass_config[DOMAIN] + mobile_beacons = config[CONF_MOBILE_BEACONS] + hass.data[DOMAIN] = [slugify(beacon) for beacon in mobile_beacons] + hass.http.register_view(GeofencyView(hass.data[DOMAIN])) + + hass.async_create_task( + async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) + ) + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.mobile_beacons = mobile_beacons + + async def post(self, request): + """Handle Geofency requests.""" + data = await request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return "Invalid data", HTTP_UNPROCESSABLE_ENTITY + + if self._is_mobile_beacon(data): + return await self._set_location(hass, data, None) + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + if ATTR_CURRENT_LATITUDE in data: + data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] + data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] + + return await self._set_location(hass, data, location_name) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return {} + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] + + for attribute in gps_attributes: + if attribute in data: + data[attribute] = float(data[attribute]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + return data['device'] + + async def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name, + data + ) + + return "Setting location for {}".format(device) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index dcd66ed2a7c..eaf17fb53f4 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1322,19 +1322,19 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): mock_component(self.hass, 'group') mock_component(self.hass, 'zone') - patch_load = patch( + self.patch_load = patch( 'homeassistant.components.device_tracker.async_load_config', return_value=mock_coro([])) - patch_load.start() - self.addCleanup(patch_load.stop) + self.patch_load.start() - patch_save = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patch_save.start() - self.addCleanup(patch_save.stop) + self.patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + self.patch_save.start() def teardown_method(self, method): """Tear down resources.""" + self.patch_load.stop() + self.patch_save.stop() self.hass.stop() @patch('homeassistant.components.device_tracker.owntracks.get_cipher', diff --git a/tests/components/geofency/__init__.py b/tests/components/geofency/__init__.py new file mode 100644 index 00000000000..12313e062db --- /dev/null +++ b/tests/components/geofency/__init__.py @@ -0,0 +1 @@ +"""Tests for the Geofency component.""" diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/geofency/test_init.py similarity index 82% rename from tests/components/device_tracker/test_geofency.py rename to tests/components/geofency/test_init.py index d84940d9fbf..442660c2daf 100644 --- a/tests/components/device_tracker/test_geofency.py +++ b/tests/components/geofency/test_init.py @@ -1,16 +1,14 @@ """The tests for the Geofency device tracker platform.""" # pylint: disable=redefined-outer-name -import asyncio from unittest.mock import patch import pytest from homeassistant.components import zone -import homeassistant.components.device_tracker as device_tracker -from homeassistant.components.device_tracker.geofency import ( - CONF_MOBILE_BEACONS, URL) +from homeassistant.components.geofency import ( + CONF_MOBILE_BEACONS, URL, DOMAIN) from homeassistant.const import ( - CONF_PLATFORM, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, + HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) from homeassistant.setup import async_setup_component from homeassistant.util import slugify @@ -110,9 +108,8 @@ BEACON_EXIT_CAR = { def geofency_client(loop, hass, aiohttp_client): """Geofency mock client.""" assert loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'geofency', + hass, DOMAIN, { + DOMAIN: { CONF_MOBILE_BEACONS: ['Car 1'] }})) @@ -133,11 +130,10 @@ def setup_zones(loop, hass): }})) -@asyncio.coroutine -def test_data_validation(geofency_client): +async def test_data_validation(geofency_client): """Test data validation.""" # No data - req = yield from geofency_client.post(URL) + req = await geofency_client.post(URL) assert req.status == HTTP_UNPROCESSABLE_ENTITY missing_attributes = ['address', 'device', @@ -147,15 +143,15 @@ def test_data_validation(geofency_client): for attribute in missing_attributes: copy = GPS_ENTER_HOME.copy() del copy[attribute] - req = yield from geofency_client.post(URL, data=copy) + req = await geofency_client.post(URL, data=copy) assert req.status == HTTP_UNPROCESSABLE_ENTITY -@asyncio.coroutine -def test_gps_enter_and_exit_home(hass, geofency_client): +async def test_gps_enter_and_exit_home(hass, geofency_client): """Test GPS based zone enter and exit.""" # Enter the Home zone - req = yield from geofency_client.post(URL, data=GPS_ENTER_HOME) + req = await geofency_client.post(URL, data=GPS_ENTER_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME['device']) state_name = hass.states.get('{}.{}'.format( @@ -163,7 +159,8 @@ def test_gps_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = yield from geofency_client.post(URL, data=GPS_EXIT_HOME) + req = await geofency_client.post(URL, data=GPS_EXIT_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) state_name = hass.states.get('{}.{}'.format( @@ -175,7 +172,8 @@ def test_gps_enter_and_exit_home(hass, geofency_client): data['currentLatitude'] = NOT_HOME_LATITUDE data['currentLongitude'] = NOT_HOME_LONGITUDE - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) current_latitude = hass.states.get('{}.{}'.format( @@ -186,11 +184,11 @@ def test_gps_enter_and_exit_home(hass, geofency_client): assert NOT_HOME_LONGITUDE == current_longitude -@asyncio.coroutine -def test_beacon_enter_and_exit_home(hass, geofency_client): +async def test_beacon_enter_and_exit_home(hass, geofency_client): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" # Enter the Home zone - req = yield from geofency_client.post(URL, data=BEACON_ENTER_HOME) + req = await geofency_client.post(URL, data=BEACON_ENTER_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) state_name = hass.states.get('{}.{}'.format( @@ -198,7 +196,8 @@ def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = yield from geofency_client.post(URL, data=BEACON_EXIT_HOME) + req = await geofency_client.post(URL, data=BEACON_EXIT_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) state_name = hass.states.get('{}.{}'.format( @@ -206,11 +205,11 @@ def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_NOT_HOME == state_name -@asyncio.coroutine -def test_beacon_enter_and_exit_car(hass, geofency_client): +async def test_beacon_enter_and_exit_car(hass, geofency_client): """Test use of mobile iBeacon.""" # Enter the Car away from Home zone - req = yield from geofency_client.post(URL, data=BEACON_ENTER_CAR) + req = await geofency_client.post(URL, data=BEACON_ENTER_CAR) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) state_name = hass.states.get('{}.{}'.format( @@ -218,7 +217,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_NOT_HOME == state_name # Exit the Car away from Home zone - req = yield from geofency_client.post(URL, data=BEACON_EXIT_CAR) + req = await geofency_client.post(URL, data=BEACON_EXIT_CAR) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) state_name = hass.states.get('{}.{}'.format( @@ -229,7 +229,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): data = BEACON_ENTER_CAR.copy() data['latitude'] = HOME_LATITUDE data['longitude'] = HOME_LONGITUDE - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) state_name = hass.states.get('{}.{}'.format( @@ -237,7 +238,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_HOME == state_name # Exit the Car in the Home zone - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) state_name = hass.states.get('{}.{}'.format(