OwnTrack Async (#6363)

* Migrate owntrack to async

* fix tests
This commit is contained in:
Pascal Vizeli 2017-03-03 09:23:58 +01:00 committed by GitHub
parent aa17481c94
commit 3e70154695
2 changed files with 117 additions and 91 deletions

View File

@ -4,14 +4,15 @@ Support the OwnTracks platform.
For more details about this platform, please refer to the documentation at For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/device_tracker.owntracks/ https://home-assistant.io/components/device_tracker.owntracks/
""" """
import asyncio
import json import json
import logging import logging
import threading
import base64 import base64
from collections import defaultdict from collections import defaultdict
import voluptuous as vol import voluptuous as vol
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.components.mqtt as mqtt import homeassistant.components.mqtt as mqtt
from homeassistant.const import STATE_HOME from homeassistant.const import STATE_HOME
@ -19,6 +20,7 @@ from homeassistant.util import convert, slugify
from homeassistant.components import zone as zone_comp from homeassistant.components import zone as zone_comp
from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.components.device_tracker import PLATFORM_SCHEMA
DEPENDENCIES = ['mqtt']
REQUIREMENTS = ['libnacl==1.5.0'] REQUIREMENTS = ['libnacl==1.5.0']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -30,16 +32,9 @@ CONF_SECRET = 'secret'
CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_IMPORT = 'waypoints'
CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
DEPENDENCIES = ['mqtt']
EVENT_TOPIC = 'owntracks/+/+/event' EVENT_TOPIC = 'owntracks/+/+/event'
LOCATION_TOPIC = 'owntracks/+/+' LOCATION_TOPIC = 'owntracks/+/+'
LOCK = threading.Lock()
MOBILE_BEACONS_ACTIVE = defaultdict(list)
REGIONS_ENTERED = defaultdict(list)
VALIDATE_LOCATION = 'location' VALIDATE_LOCATION = 'location'
VALIDATE_TRANSITION = 'transition' VALIDATE_TRANSITION = 'transition'
@ -60,8 +55,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
}) })
@callback
def get_cipher(): def get_cipher():
"""Return decryption function and length of key.""" """Return decryption function and length of key.
Async friendly.
"""
from libnacl import crypto_secretbox_KEYBYTES as KEYLEN from libnacl import crypto_secretbox_KEYBYTES as KEYLEN
from libnacl.secret import SecretBox from libnacl.secret import SecretBox
@ -71,13 +70,18 @@ def get_cipher():
return (KEYLEN, decrypt) return (KEYLEN, decrypt)
def setup_scanner(hass, config, see, discovery_info=None): @asyncio.coroutine
def async_setup_scanner(hass, config, async_see, discovery_info=None):
"""Set up an OwnTracks tracker.""" """Set up an OwnTracks tracker."""
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
waypoint_import = config.get(CONF_WAYPOINT_IMPORT) waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
secret = config.get(CONF_SECRET) secret = config.get(CONF_SECRET)
mobile_beacons_active = defaultdict(list)
regions_entered = defaultdict(list)
@callback
def decrypt_payload(topic, ciphertext): def decrypt_payload(topic, ciphertext):
"""Decrypt encrypted payload.""" """Decrypt encrypted payload."""
try: try:
@ -115,6 +119,7 @@ def setup_scanner(hass, config, see, discovery_info=None):
return None return None
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
@callback
def validate_payload(topic, payload, data_type): def validate_payload(topic, payload, data_type):
"""Validate the OwnTracks payload.""" """Validate the OwnTracks payload."""
try: try:
@ -154,7 +159,8 @@ def setup_scanner(hass, config, see, discovery_info=None):
return data return data
def owntracks_location_update(topic, payload, qos): @callback
def async_owntracks_location_update(topic, payload, qos):
"""MQTT message received.""" """MQTT message received."""
# Docs on available data: # Docs on available data:
# http://owntracks.org/booklet/tech/json/#_typelocation # http://owntracks.org/booklet/tech/json/#_typelocation
@ -164,18 +170,17 @@ def setup_scanner(hass, config, see, discovery_info=None):
dev_id, kwargs = _parse_see_args(topic, data) dev_id, kwargs = _parse_see_args(topic, data)
# Block updates if we're in a region if regions_entered[dev_id]:
with LOCK: _LOGGER.debug(
if REGIONS_ENTERED[dev_id]: "location update ignored - inside region %s",
_LOGGER.debug( regions_entered[-1])
"location update ignored - inside region %s", return
REGIONS_ENTERED[-1])
return
see(**kwargs) hass.async_add_job(async_see(**kwargs))
see_beacons(dev_id, kwargs) async_see_beacons(dev_id, kwargs)
def owntracks_event_update(topic, payload, qos): @callback
def async_owntracks_event_update(topic, payload, qos):
"""MQTT event (geofences) received.""" """MQTT event (geofences) received."""
# Docs on available data: # Docs on available data:
# http://owntracks.org/booklet/tech/json/#_typetransition # http://owntracks.org/booklet/tech/json/#_typetransition
@ -196,70 +201,70 @@ def setup_scanner(hass, config, see, discovery_info=None):
dev_id, kwargs = _parse_see_args(topic, data) dev_id, kwargs = _parse_see_args(topic, data)
@callback
def enter_event(): def enter_event():
"""Execute enter event.""" """Execute enter event."""
zone = hass.states.get("zone.{}".format(slugify(location))) zone = hass.states.get("zone.{}".format(slugify(location)))
with LOCK: if zone is None and data.get('t') == 'b':
if zone is None and data.get('t') == 'b': # Not a HA zone, and a beacon so assume mobile
# Not a HA zone, and a beacon so assume mobile beacons = mobile_beacons_active[dev_id]
beacons = MOBILE_BEACONS_ACTIVE[dev_id] if location not in beacons:
if location not in beacons: beacons.append(location)
beacons.append(location) _LOGGER.info("Added beacon %s", location)
_LOGGER.info("Added beacon %s", location) else:
else: # Normal region
# Normal region regions = regions_entered[dev_id]
regions = REGIONS_ENTERED[dev_id] if location not in regions:
if location not in regions: regions.append(location)
regions.append(location) _LOGGER.info("Enter region %s", location)
_LOGGER.info("Enter region %s", location) _set_gps_from_zone(kwargs, location, zone)
_set_gps_from_zone(kwargs, location, zone)
see(**kwargs) hass.async_add_job(async_see(**kwargs))
see_beacons(dev_id, kwargs) async_see_beacons(dev_id, kwargs)
@callback
def leave_event(): def leave_event():
"""Execute leave event.""" """Execute leave event."""
with LOCK: regions = regions_entered[dev_id]
regions = REGIONS_ENTERED[dev_id] if location in regions:
if location in regions: regions.remove(location)
regions.remove(location) new_region = regions[-1] if regions else None
new_region = regions[-1] if regions else None
if new_region: if new_region:
# Exit to previous region # Exit to previous region
zone = hass.states.get( zone = hass.states.get(
"zone.{}".format(slugify(new_region))) "zone.{}".format(slugify(new_region)))
_set_gps_from_zone(kwargs, new_region, zone) _set_gps_from_zone(kwargs, new_region, zone)
_LOGGER.info("Exit to %s", new_region) _LOGGER.info("Exit to %s", new_region)
see(**kwargs) hass.async_add_job(async_see(**kwargs))
see_beacons(dev_id, kwargs) async_see_beacons(dev_id, kwargs)
else: else:
_LOGGER.info("Exit to GPS") _LOGGER.info("Exit to GPS")
# Check for GPS accuracy # Check for GPS accuracy
valid_gps = True valid_gps = True
if 'acc' in data: if 'acc' in data:
if data['acc'] == 0.0: if data['acc'] == 0.0:
valid_gps = False valid_gps = False
_LOGGER.warning( _LOGGER.warning(
'Ignoring GPS in region exit because accuracy' 'Ignoring GPS in region exit because accuracy'
'is zero: %s', 'is zero: %s',
payload) payload)
if (max_gps_accuracy is not None and if (max_gps_accuracy is not None and
data['acc'] > max_gps_accuracy): data['acc'] > max_gps_accuracy):
valid_gps = False valid_gps = False
_LOGGER.info( _LOGGER.info(
'Ignoring GPS in region exit because expected ' 'Ignoring GPS in region exit because expected '
'GPS accuracy %s is not met: %s', 'GPS accuracy %s is not met: %s',
max_gps_accuracy, payload) max_gps_accuracy, payload)
if valid_gps: if valid_gps:
see(**kwargs) hass.async_add_job(async_see(**kwargs))
see_beacons(dev_id, kwargs) async_see_beacons(dev_id, kwargs)
beacons = MOBILE_BEACONS_ACTIVE[dev_id] beacons = mobile_beacons_active[dev_id]
if location in beacons: if location in beacons:
beacons.remove(location) beacons.remove(location)
_LOGGER.info("Remove beacon %s", location) _LOGGER.info("Remove beacon %s", location)
if data['event'] == 'enter': if data['event'] == 'enter':
enter_event() enter_event()
@ -271,7 +276,8 @@ def setup_scanner(hass, config, see, discovery_info=None):
data['event']) data['event'])
return return
def owntracks_waypoint_update(topic, payload, qos): @callback
def async_owntracks_waypoint_update(topic, payload, qos):
"""List of waypoints published by a user.""" """List of waypoints published by a user."""
# Docs on available data: # Docs on available data:
# http://owntracks.org/booklet/tech/json/#_typewaypoints # http://owntracks.org/booklet/tech/json/#_typewaypoints
@ -298,36 +304,44 @@ def setup_scanner(hass, config, see, discovery_info=None):
zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad,
zone_comp.ICON_IMPORT, False) zone_comp.ICON_IMPORT, False)
zone.entity_id = entity_id zone.entity_id = entity_id
zone.update_ha_state() hass.async_add_job(zone.async_update_ha_state())
def see_beacons(dev_id, kwargs_param): @callback
def async_see_beacons(dev_id, kwargs_param):
"""Set active beacons to the current location.""" """Set active beacons to the current location."""
kwargs = kwargs_param.copy() kwargs = kwargs_param.copy()
# the battery state applies to the tracking device, not the beacon # the battery state applies to the tracking device, not the beacon
kwargs.pop('battery', None) kwargs.pop('battery', None)
for beacon in MOBILE_BEACONS_ACTIVE[dev_id]: for beacon in mobile_beacons_active[dev_id]:
kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
kwargs['host_name'] = beacon kwargs['host_name'] = beacon
see(**kwargs) hass.async_add_job(async_see(**kwargs))
mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) yield from mqtt.async_subscribe(
mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1) hass, LOCATION_TOPIC, async_owntracks_location_update, 1)
yield from mqtt.async_subscribe(
hass, EVENT_TOPIC, async_owntracks_event_update, 1)
if waypoint_import: if waypoint_import:
if waypoint_whitelist is None: if waypoint_whitelist is None:
mqtt.subscribe(hass, WAYPOINT_TOPIC.format('+', '+'), yield from mqtt.async_subscribe(
owntracks_waypoint_update, 1) hass, WAYPOINT_TOPIC.format('+', '+'),
async_owntracks_waypoint_update, 1)
else: else:
for whitelist_user in waypoint_whitelist: for whitelist_user in waypoint_whitelist:
mqtt.subscribe(hass, WAYPOINT_TOPIC.format(whitelist_user, yield from mqtt.async_subscribe(
'+'), hass, WAYPOINT_TOPIC.format(whitelist_user, '+'),
owntracks_waypoint_update, 1) async_owntracks_waypoint_update, 1)
return True return True
@callback
def parse_topic(topic, pretty=False): def parse_topic(topic, pretty=False):
"""Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple.""" """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple.
Async friendly.
"""
parts = topic.split('/') parts = topic.split('/')
dev_id_format = '' dev_id_format = ''
if pretty: if pretty:
@ -339,8 +353,12 @@ def parse_topic(topic, pretty=False):
return (host_name, dev_id) return (host_name, dev_id)
@callback
def _parse_see_args(topic, data): def _parse_see_args(topic, data):
"""Parse the OwnTracks location parameters, into the format see expects.""" """Parse the OwnTracks location parameters, into the format see expects.
Async friendly.
"""
(host_name, dev_id) = parse_topic(topic, False) (host_name, dev_id) = parse_topic(topic, False)
kwargs = { kwargs = {
'dev_id': dev_id, 'dev_id': dev_id,
@ -354,8 +372,12 @@ def _parse_see_args(topic, data):
return dev_id, kwargs return dev_id, kwargs
@callback
def _set_gps_from_zone(kwargs, location, zone): def _set_gps_from_zone(kwargs, location, zone):
"""Set the see parameters from the zone parameters.""" """Set the see parameters from the zone parameters.
Async friendly.
"""
if zone is not None: if zone is not None:
kwargs['gps'] = ( kwargs['gps'] = (
zone.attributes['latitude'], zone.attributes['latitude'],

View File

@ -1,4 +1,5 @@
"""The tests for the Owntracks device tracker.""" """The tests for the Owntracks device tracker."""
import asyncio
import json import json
import os import os
import unittest import unittest
@ -12,6 +13,7 @@ import homeassistant.components.device_tracker.owntracks as owntracks
from homeassistant.bootstrap import setup_component from homeassistant.bootstrap import setup_component
from homeassistant.components import device_tracker from homeassistant.components import device_tracker
from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME
from homeassistant.util.async import run_coroutine_threadsafe
USER = 'greg' USER = 'greg'
DEVICE = 'phone' DEVICE = 'phone'
@ -640,6 +642,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT):
def test_waypoint_import_no_whitelist(self): def test_waypoint_import_no_whitelist(self):
"""Test import of list of waypoints with no whitelist set.""" """Test import of list of waypoints with no whitelist set."""
@asyncio.coroutine
def mock_see(**kwargs): def mock_see(**kwargs):
"""Fake see method for owntracks.""" """Fake see method for owntracks."""
return return
@ -649,7 +652,8 @@ class TestDeviceTrackerOwnTracks(BaseMQTT):
CONF_MAX_GPS_ACCURACY: 200, CONF_MAX_GPS_ACCURACY: 200,
CONF_WAYPOINT_IMPORT: True CONF_WAYPOINT_IMPORT: True
} }
owntracks.setup_scanner(self.hass, test_config, mock_see) run_coroutine_threadsafe(owntracks.async_setup_scanner(
self.hass, test_config, mock_see), self.hass.loop).result()
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message)
# Check if it made it into states # Check if it made it into states