Switch locative to use the webhook component

This commit is contained in:
Rohan Kapoor 2019-01-12 19:18:33 -08:00
parent 04636e9ba7
commit 4e020b90e1
5 changed files with 158 additions and 94 deletions

View File

@ -0,0 +1,18 @@
{
"config": {
"title": "Locative Webhook",
"step": {
"user": {
"title": "Set up the Locative Webhook",
"description": "Are you sure you want to set up the Locative 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 locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
}
}
}

View File

@ -8,76 +8,72 @@ import logging
from homeassistant.components.device_tracker import \ from homeassistant.components.device_tracker import \
DOMAIN as DEVICE_TRACKER_DOMAIN DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, ATTR_LATITUDE, \ from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, ATTR_LATITUDE, \
ATTR_LONGITUDE, STATE_NOT_HOME ATTR_LONGITUDE, STATE_NOT_HOME, CONF_WEBHOOK_ID
from homeassistant.helpers import config_entry_flow
from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = 'locative' DOMAIN = 'locative'
DEPENDENCIES = ['http'] DEPENDENCIES = ['webhook']
URL = '/api/locative'
TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN)
async def async_setup(hass, hass_config): async def async_setup(hass, hass_config):
"""Set up the Locative component.""" """Set up the Locative component."""
hass.http.register_view(LocativeView)
hass.async_create_task( hass.async_create_task(
async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config)
) )
return True return True
class LocativeView(HomeAssistantView): async def handle_webhook(hass, webhook_id, request):
"""View to handle Locative requests.""" """Handle incoming webhook from Locative."""
data = await request.post()
url = URL if 'latitude' not in data or 'longitude' not in data:
name = 'api:locative' return ('Latitude and longitude not specified.',
HTTP_UNPROCESSABLE_ENTITY)
def __init__(self): if 'device' not in data:
"""Initialize Locative URL endpoints.""" _LOGGER.error('Device id not specified.')
return ('Device id not specified.',
HTTP_UNPROCESSABLE_ENTITY)
async def get(self, request): if 'trigger' not in data:
"""Locative message received as GET.""" _LOGGER.error('Trigger is not specified.')
return await self._handle(request.app['hass'], request.query) return ('Trigger is not specified.',
HTTP_UNPROCESSABLE_ENTITY)
async def post(self, request): if 'id' not in data and data['trigger'] != 'test':
"""Locative message received.""" _LOGGER.error('Location id not specified.')
data = await request.post() return ('Location id not specified.',
return await self._handle(request.app['hass'], data) HTTP_UNPROCESSABLE_ENTITY)
async def _handle(self, hass, data): device = data['device'].replace('-', '')
"""Handle locative request.""" location_name = data.get('id', data['trigger']).lower()
if 'latitude' not in data or 'longitude' not in data: direction = data['trigger']
return ('Latitude and longitude not specified.', gps_location = (data[ATTR_LATITUDE], data[ATTR_LONGITUDE])
HTTP_UNPROCESSABLE_ENTITY)
if 'device' not in data: if direction == 'enter':
_LOGGER.error('Device id not specified.') async_dispatcher_send(
return ('Device id not specified.', hass,
HTTP_UNPROCESSABLE_ENTITY) TRACKER_UPDATE,
device,
gps_location,
location_name
)
return 'Setting location to {}'.format(location_name)
if 'trigger' not in data: if direction == 'exit':
_LOGGER.error('Trigger is not specified.') current_state = hass.states.get(
return ('Trigger is not specified.', '{}.{}'.format(DEVICE_TRACKER_DOMAIN, device))
HTTP_UNPROCESSABLE_ENTITY)
if 'id' not in data and data['trigger'] != 'test': if current_state is None or current_state.state == location_name:
_LOGGER.error('Location id not specified.') location_name = STATE_NOT_HOME
return ('Location id not specified.',
HTTP_UNPROCESSABLE_ENTITY)
device = data['device'].replace('-', '')
location_name = data.get('id', data['trigger']).lower()
direction = data['trigger']
gps_location = (data[ATTR_LATITUDE], data[ATTR_LONGITUDE])
if direction == 'enter':
async_dispatcher_send( async_dispatcher_send(
hass, hass,
TRACKER_UPDATE, TRACKER_UPDATE,
@ -85,36 +81,42 @@ class LocativeView(HomeAssistantView):
gps_location, gps_location,
location_name location_name
) )
return 'Setting location to {}'.format(location_name) return 'Setting location to not home'
if direction == 'exit': # Ignore the message if it is telling us to exit a zone that we
current_state = hass.states.get( # aren't currently in. This occurs when a zone is entered
'{}.{}'.format(DEVICE_TRACKER_DOMAIN, device)) # before the previous zone was exited. The enter message will
# be sent first, then the exit message will be sent second.
return 'Ignoring exit from {} (already in {})'.format(
location_name, current_state)
if current_state is None or current_state.state == location_name: if direction == 'test':
location_name = STATE_NOT_HOME # In the app, a test message can be sent. Just return something to
async_dispatcher_send( # the user to let them know that it works.
hass, return 'Received test message.'
TRACKER_UPDATE,
device,
gps_location,
location_name
)
return 'Setting location to not home'
# Ignore the message if it is telling us to exit a zone that we _LOGGER.error('Received unidentified message from Locative: %s',
# aren't currently in. This occurs when a zone is entered direction)
# before the previous zone was exited. The enter message will return ('Received unidentified message: {}'.format(direction),
# be sent first, then the exit message will be sent second. HTTP_UNPROCESSABLE_ENTITY)
return 'Ignoring exit from {} (already in {})'.format(
location_name, current_state)
if direction == 'test':
# In the app, a test message can be sent. Just return something to
# the user to let them know that it works.
return 'Received test message.'
_LOGGER.error('Received unidentified message from Locative: %s', async def async_setup_entry(hass, entry):
direction) """Configure based on config entry."""
return ('Received unidentified message: {}'.format(direction), hass.components.webhook.async_register(
HTTP_UNPROCESSABLE_ENTITY) DOMAIN, 'Locative', 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,
'Locative Webhook',
{
'docs_url': 'https://www.home-assistant.io/components/locative/'
}
)

View File

@ -0,0 +1,18 @@
{
"config": {
"title": "Locative Webhook",
"step": {
"user": {
"title": "Set up the Locative Webhook",
"description": "Are you sure you want to set up the Locative 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 locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
}
}
}

View File

@ -148,6 +148,7 @@ FLOWS = [
'ifttt', 'ifttt',
'ios', 'ios',
'lifx', 'lifx',
'locative',
'luftdaten', 'luftdaten',
'mailgun', 'mailgun',
'mqtt', 'mqtt',

View File

@ -1,11 +1,12 @@
"""The tests the for Locative device tracker platform.""" """The tests the for Locative device tracker platform."""
from unittest.mock import patch from unittest.mock import patch, Mock
import pytest import pytest
from homeassistant import data_entry_flow
from homeassistant.components.device_tracker import \ from homeassistant.components.device_tracker import \
DOMAIN as DEVICE_TRACKER_DOMAIN DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.locative import URL, DOMAIN from homeassistant.components.locative import DOMAIN
from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -36,8 +37,26 @@ def locative_client(loop, hass, hass_client):
yield loop.run_until_complete(hass_client()) yield loop.run_until_complete(hass_client())
async def test_missing_data(locative_client): @pytest.fixture
async def webhook_id(hass, locative_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('locative', 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_missing_data(locative_client, webhook_id):
"""Test missing data.""" """Test missing data."""
url = '/api/webhook/{}'.format(webhook_id)
data = { data = {
'latitude': 1.0, 'latitude': 1.0,
'longitude': 1.1, 'longitude': 1.1,
@ -47,55 +66,57 @@ async def test_missing_data(locative_client):
} }
# No data # No data
req = await locative_client.get(_url({})) req = await locative_client.post(url)
assert req.status == HTTP_UNPROCESSABLE_ENTITY assert req.status == HTTP_UNPROCESSABLE_ENTITY
# No latitude # No latitude
copy = data.copy() copy = data.copy()
del copy['latitude'] del copy['latitude']
req = await locative_client.get(_url(copy)) req = await locative_client.post(url, data=copy)
assert req.status == HTTP_UNPROCESSABLE_ENTITY assert req.status == HTTP_UNPROCESSABLE_ENTITY
# No device # No device
copy = data.copy() copy = data.copy()
del copy['device'] del copy['device']
req = await locative_client.get(_url(copy)) req = await locative_client.post(url, data=copy)
assert req.status == HTTP_UNPROCESSABLE_ENTITY assert req.status == HTTP_UNPROCESSABLE_ENTITY
# No location # No location
copy = data.copy() copy = data.copy()
del copy['id'] del copy['id']
req = await locative_client.get(_url(copy)) req = await locative_client.post(url, data=copy)
assert req.status == HTTP_UNPROCESSABLE_ENTITY assert req.status == HTTP_UNPROCESSABLE_ENTITY
# No trigger # No trigger
copy = data.copy() copy = data.copy()
del copy['trigger'] del copy['trigger']
req = await locative_client.get(_url(copy)) req = await locative_client.post(url, data=copy)
assert req.status == HTTP_UNPROCESSABLE_ENTITY assert req.status == HTTP_UNPROCESSABLE_ENTITY
# Test message # Test message
copy = data.copy() copy = data.copy()
copy['trigger'] = 'test' copy['trigger'] = 'test'
req = await locative_client.get(_url(copy)) req = await locative_client.post(url, data=copy)
assert req.status == HTTP_OK assert req.status == HTTP_OK
# Test message, no location # Test message, no location
copy = data.copy() copy = data.copy()
copy['trigger'] = 'test' copy['trigger'] = 'test'
del copy['id'] del copy['id']
req = await locative_client.get(_url(copy)) req = await locative_client.post(url, data=copy)
assert req.status == HTTP_OK assert req.status == HTTP_OK
# Unknown trigger # Unknown trigger
copy = data.copy() copy = data.copy()
copy['trigger'] = 'foobar' copy['trigger'] = 'foobar'
req = await locative_client.get(_url(copy)) req = await locative_client.post(url, data=copy)
assert req.status == HTTP_UNPROCESSABLE_ENTITY assert req.status == HTTP_UNPROCESSABLE_ENTITY
async def test_enter_and_exit(hass, locative_client): async def test_enter_and_exit(hass, locative_client, webhook_id):
"""Test when there is a known zone.""" """Test when there is a known zone."""
url = '/api/webhook/{}'.format(webhook_id)
data = { data = {
'latitude': 40.7855, 'latitude': 40.7855,
'longitude': -111.7367, 'longitude': -111.7367,
@ -105,7 +126,7 @@ async def test_enter_and_exit(hass, locative_client):
} }
# Enter the Home # Enter the Home
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
@ -116,7 +137,7 @@ async def test_enter_and_exit(hass, locative_client):
data['trigger'] = 'exit' data['trigger'] = 'exit'
# Exit Home # Exit Home
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
@ -127,7 +148,7 @@ async def test_enter_and_exit(hass, locative_client):
data['trigger'] = 'enter' data['trigger'] = 'enter'
# Enter Home again # Enter Home again
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
@ -137,7 +158,7 @@ async def test_enter_and_exit(hass, locative_client):
data['trigger'] = 'exit' data['trigger'] = 'exit'
# Exit Home # Exit Home
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
@ -148,7 +169,7 @@ async def test_enter_and_exit(hass, locative_client):
data['trigger'] = 'enter' data['trigger'] = 'enter'
# Enter Work # Enter Work
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
@ -156,8 +177,10 @@ async def test_enter_and_exit(hass, locative_client):
assert 'work' == state_name assert 'work' == state_name
async def test_exit_after_enter(hass, locative_client): async def test_exit_after_enter(hass, locative_client, webhook_id):
"""Test when an exit message comes after an enter message.""" """Test when an exit message comes after an enter message."""
url = '/api/webhook/{}'.format(webhook_id)
data = { data = {
'latitude': 40.7855, 'latitude': 40.7855,
'longitude': -111.7367, 'longitude': -111.7367,
@ -167,7 +190,7 @@ async def test_exit_after_enter(hass, locative_client):
} }
# Enter Home # Enter Home
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
@ -178,7 +201,7 @@ async def test_exit_after_enter(hass, locative_client):
data['id'] = 'Work' data['id'] = 'Work'
# Enter Work # Enter Work
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
@ -190,7 +213,7 @@ async def test_exit_after_enter(hass, locative_client):
data['trigger'] = 'exit' data['trigger'] = 'exit'
# Exit Home # Exit Home
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK
@ -199,8 +222,10 @@ async def test_exit_after_enter(hass, locative_client):
assert state.state == 'work' assert state.state == 'work'
async def test_exit_first(hass, locative_client): async def test_exit_first(hass, locative_client, webhook_id):
"""Test when an exit message is sent first on a new device.""" """Test when an exit message is sent first on a new device."""
url = '/api/webhook/{}'.format(webhook_id)
data = { data = {
'latitude': 40.7855, 'latitude': 40.7855,
'longitude': -111.7367, 'longitude': -111.7367,
@ -210,7 +235,7 @@ async def test_exit_first(hass, locative_client):
} }
# Exit Home # Exit Home
req = await locative_client.get(_url(data)) req = await locative_client.post(url, data=data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert req.status == HTTP_OK assert req.status == HTTP_OK