From 2bdbf6955d5a4e808997c843a68e72a483f94595 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Tue, 8 Jan 2019 20:47:05 -0800 Subject: [PATCH] Migrate geofency over to the Webhook component (#18951) * Migrate geofency over to the Webhook component * Return web.Response correctly * Fix test * Lint * Fix error that tests caught --- .../components/geofency/.translations/en.json | 18 ++ homeassistant/components/geofency/__init__.py | 193 ++++++++++-------- .../components/geofency/strings.json | 18 ++ homeassistant/config_entries.py | 1 + tests/components/geofency/test_init.py | 62 ++++-- 5 files changed, 186 insertions(+), 106 deletions(-) create mode 100644 homeassistant/components/geofency/.translations/en.json create mode 100644 homeassistant/components/geofency/strings.json diff --git a/homeassistant/components/geofency/.translations/en.json b/homeassistant/components/geofency/.translations/en.json new file mode 100644 index 00000000000..e67af592c16 --- /dev/null +++ b/homeassistant/components/geofency/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Geofency Webhook", + "step": { + "user": { + "title": "Set up the Geofency Webhook", + "description": "Are you sure you want to set up the Geofency Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 92f8f475e65..1e2f368d5b3 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -7,11 +7,12 @@ https://home-assistant.io/components/geofency/ import logging import voluptuous as vol +from aiohttp import web 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 + ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, HTTP_OK +from homeassistant.helpers import config_entry_flow from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import slugify @@ -19,7 +20,7 @@ from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) DOMAIN = 'geofency' -DEPENDENCIES = ['http'] +DEPENDENCIES = ['webhook'] CONF_MOBILE_BEACONS = 'mobile_beacons' @@ -40,8 +41,6 @@ BEACON_DEV_PREFIX = 'beacon' LOCATION_ENTRY = '1' LOCATION_EXIT = '0' -URL = '/api/geofency' - TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) @@ -50,7 +49,6 @@ async def async_setup(hass, hass_config): 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) @@ -58,89 +56,106 @@ async def async_setup(hass, hass_config): return True -class GeofencyView(HomeAssistantView): - """View to handle Geofency requests.""" +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with Mailgun inbound messages.""" + data = _validate_data(await request.post()) - 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 + if not data: + return web.Response( + body="Invalid data", + status=HTTP_UNPROCESSABLE_ENTITY ) - return "Setting location for {}".format(device) + if _is_mobile_beacon(data, hass.data[DOMAIN]): + return _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 _set_location(hass, data, location_name) + + +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(data, mobile_beacons): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in mobile_beacons + + +def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + return data['device'] + + +def _set_location(hass, data, location_name): + """Fire HA event to set location.""" + device = _device_name(data) + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name, + data + ) + + return web.Response( + body="Setting location for {}".format(device), + status=HTTP_OK + ) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + DOMAIN, 'Geofency', entry.data[CONF_WEBHOOK_ID], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return True + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Geofency Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/geofency/' + } +) diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json new file mode 100644 index 00000000000..e67af592c16 --- /dev/null +++ b/homeassistant/components/geofency/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Geofency Webhook", + "step": { + "user": { + "title": "Set up the Geofency Webhook", + "description": "Are you sure you want to set up the Geofency Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2048b4214c6..8af366ce604 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -140,6 +140,7 @@ FLOWS = [ 'deconz', 'dialogflow', 'esphome', + 'geofency', 'hangouts', 'homematicip_cloud', 'hue', diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index ae90af61ced..fa1829e2d68 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -1,12 +1,13 @@ """The tests for the Geofency device tracker platform.""" # pylint: disable=redefined-outer-name -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest +from homeassistant import data_entry_flow from homeassistant.components import zone from homeassistant.components.geofency import ( - CONF_MOBILE_BEACONS, URL, DOMAIN) + CONF_MOBILE_BEACONS, DOMAIN) from homeassistant.const import ( HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) @@ -113,6 +114,9 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture def geofency_client(loop, hass, hass_client): """Geofency mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, 'persistent_notification', {})) + assert loop.run_until_complete(async_setup_component( hass, DOMAIN, { DOMAIN: { @@ -138,10 +142,28 @@ def setup_zones(loop, hass): }})) -async def test_data_validation(geofency_client): +@pytest.fixture +async def webhook_id(hass, geofency_client): + """Initialize the Geofency component and get the webhook_id.""" + hass.config.api = Mock(base_url='http://example.com') + result = await hass.config_entries.flow.async_init('geofency', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + return result['result'].data['webhook_id'] + + +async def test_data_validation(geofency_client, webhook_id): """Test data validation.""" + url = '/api/webhook/{}'.format(webhook_id) + # No data - req = await geofency_client.post(URL) + req = await geofency_client.post(url) assert req.status == HTTP_UNPROCESSABLE_ENTITY missing_attributes = ['address', 'device', @@ -151,14 +173,16 @@ async def test_data_validation(geofency_client): for attribute in missing_attributes: copy = GPS_ENTER_HOME.copy() del copy[attribute] - req = await geofency_client.post(URL, data=copy) + req = await geofency_client.post(url, data=copy) assert req.status == HTTP_UNPROCESSABLE_ENTITY -async def test_gps_enter_and_exit_home(hass, geofency_client): +async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): """Test GPS based zone enter and exit.""" + url = '/api/webhook/{}'.format(webhook_id) + # Enter the Home zone - req = await 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']) @@ -167,7 +191,7 @@ async def test_gps_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = await 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']) @@ -180,7 +204,7 @@ async def test_gps_enter_and_exit_home(hass, geofency_client): data['currentLatitude'] = NOT_HOME_LATITUDE data['currentLongitude'] = NOT_HOME_LONGITUDE - req = await 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']) @@ -192,10 +216,12 @@ async def test_gps_enter_and_exit_home(hass, geofency_client): assert NOT_HOME_LONGITUDE == current_longitude -async def test_beacon_enter_and_exit_home(hass, geofency_client): +async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" + url = '/api/webhook/{}'.format(webhook_id) + # Enter the Home zone - req = await 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'])) @@ -204,7 +230,7 @@ async def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = await 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'])) @@ -213,10 +239,12 @@ async def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_NOT_HOME == state_name -async def test_beacon_enter_and_exit_car(hass, geofency_client): +async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id): """Test use of mobile iBeacon.""" + url = '/api/webhook/{}'.format(webhook_id) + # Enter the Car away from Home zone - req = await 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'])) @@ -225,7 +253,7 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_NOT_HOME == state_name # Exit the Car away from Home zone - req = await 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'])) @@ -237,7 +265,7 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client): data = BEACON_ENTER_CAR.copy() data['latitude'] = HOME_LATITUDE data['longitude'] = HOME_LONGITUDE - req = await 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'])) @@ -246,7 +274,7 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_HOME == state_name # Exit the Car in the Home zone - req = await 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']))