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
https://home-assistant.io/components/device_tracker.owntracks/
"""
import asyncio
import json
import logging
import threading
import base64
from collections import defaultdict
import voluptuous as vol
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
import homeassistant.components.mqtt as mqtt
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.device_tracker import PLATFORM_SCHEMA
DEPENDENCIES = ['mqtt']
REQUIREMENTS = ['libnacl==1.5.0']
_LOGGER = logging.getLogger(__name__)
@ -30,16 +32,9 @@ CONF_SECRET = 'secret'
CONF_WAYPOINT_IMPORT = 'waypoints'
CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
DEPENDENCIES = ['mqtt']
EVENT_TOPIC = 'owntracks/+/+/event'
LOCATION_TOPIC = 'owntracks/+/+'
LOCK = threading.Lock()
MOBILE_BEACONS_ACTIVE = defaultdict(list)
REGIONS_ENTERED = defaultdict(list)
VALIDATE_LOCATION = 'location'
VALIDATE_TRANSITION = 'transition'
@ -60,8 +55,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
})
@callback
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.secret import SecretBox
@ -71,13 +70,18 @@ def get_cipher():
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."""
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
secret = config.get(CONF_SECRET)
mobile_beacons_active = defaultdict(list)
regions_entered = defaultdict(list)
@callback
def decrypt_payload(topic, ciphertext):
"""Decrypt encrypted payload."""
try:
@ -115,6 +119,7 @@ def setup_scanner(hass, config, see, discovery_info=None):
return None
# pylint: disable=too-many-return-statements
@callback
def validate_payload(topic, payload, data_type):
"""Validate the OwnTracks payload."""
try:
@ -154,7 +159,8 @@ def setup_scanner(hass, config, see, discovery_info=None):
return data
def owntracks_location_update(topic, payload, qos):
@callback
def async_owntracks_location_update(topic, payload, qos):
"""MQTT message received."""
# Docs on available data:
# 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)
# Block updates if we're in a region
with LOCK:
if REGIONS_ENTERED[dev_id]:
_LOGGER.debug(
"location update ignored - inside region %s",
REGIONS_ENTERED[-1])
return
if regions_entered[dev_id]:
_LOGGER.debug(
"location update ignored - inside region %s",
regions_entered[-1])
return
see(**kwargs)
see_beacons(dev_id, kwargs)
hass.async_add_job(async_see(**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."""
# Docs on available data:
# 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)
@callback
def enter_event():
"""Execute enter event."""
zone = hass.states.get("zone.{}".format(slugify(location)))
with LOCK:
if zone is None and data.get('t') == 'b':
# Not a HA zone, and a beacon so assume mobile
beacons = MOBILE_BEACONS_ACTIVE[dev_id]
if location not in beacons:
beacons.append(location)
_LOGGER.info("Added beacon %s", location)
else:
# Normal region
regions = REGIONS_ENTERED[dev_id]
if location not in regions:
regions.append(location)
_LOGGER.info("Enter region %s", location)
_set_gps_from_zone(kwargs, location, zone)
if zone is None and data.get('t') == 'b':
# Not a HA zone, and a beacon so assume mobile
beacons = mobile_beacons_active[dev_id]
if location not in beacons:
beacons.append(location)
_LOGGER.info("Added beacon %s", location)
else:
# Normal region
regions = regions_entered[dev_id]
if location not in regions:
regions.append(location)
_LOGGER.info("Enter region %s", location)
_set_gps_from_zone(kwargs, location, zone)
see(**kwargs)
see_beacons(dev_id, kwargs)
hass.async_add_job(async_see(**kwargs))
async_see_beacons(dev_id, kwargs)
@callback
def leave_event():
"""Execute leave event."""
with LOCK:
regions = REGIONS_ENTERED[dev_id]
if location in regions:
regions.remove(location)
new_region = regions[-1] if regions else None
regions = regions_entered[dev_id]
if location in regions:
regions.remove(location)
new_region = regions[-1] if regions else None
if new_region:
# Exit to previous region
zone = hass.states.get(
"zone.{}".format(slugify(new_region)))
_set_gps_from_zone(kwargs, new_region, zone)
_LOGGER.info("Exit to %s", new_region)
see(**kwargs)
see_beacons(dev_id, kwargs)
if new_region:
# Exit to previous region
zone = hass.states.get(
"zone.{}".format(slugify(new_region)))
_set_gps_from_zone(kwargs, new_region, zone)
_LOGGER.info("Exit to %s", new_region)
hass.async_add_job(async_see(**kwargs))
async_see_beacons(dev_id, kwargs)
else:
_LOGGER.info("Exit to GPS")
# Check for GPS accuracy
valid_gps = True
if 'acc' in data:
if data['acc'] == 0.0:
valid_gps = False
_LOGGER.warning(
'Ignoring GPS in region exit because accuracy'
'is zero: %s',
payload)
if (max_gps_accuracy is not None and
data['acc'] > max_gps_accuracy):
valid_gps = False
_LOGGER.info(
'Ignoring GPS in region exit because expected '
'GPS accuracy %s is not met: %s',
max_gps_accuracy, payload)
if valid_gps:
see(**kwargs)
see_beacons(dev_id, kwargs)
else:
_LOGGER.info("Exit to GPS")
# Check for GPS accuracy
valid_gps = True
if 'acc' in data:
if data['acc'] == 0.0:
valid_gps = False
_LOGGER.warning(
'Ignoring GPS in region exit because accuracy'
'is zero: %s',
payload)
if (max_gps_accuracy is not None and
data['acc'] > max_gps_accuracy):
valid_gps = False
_LOGGER.info(
'Ignoring GPS in region exit because expected '
'GPS accuracy %s is not met: %s',
max_gps_accuracy, payload)
if valid_gps:
hass.async_add_job(async_see(**kwargs))
async_see_beacons(dev_id, kwargs)
beacons = MOBILE_BEACONS_ACTIVE[dev_id]
if location in beacons:
beacons.remove(location)
_LOGGER.info("Remove beacon %s", location)
beacons = mobile_beacons_active[dev_id]
if location in beacons:
beacons.remove(location)
_LOGGER.info("Remove beacon %s", location)
if data['event'] == 'enter':
enter_event()
@ -271,7 +276,8 @@ def setup_scanner(hass, config, see, discovery_info=None):
data['event'])
return
def owntracks_waypoint_update(topic, payload, qos):
@callback
def async_owntracks_waypoint_update(topic, payload, qos):
"""List of waypoints published by a user."""
# Docs on available data:
# 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_comp.ICON_IMPORT, False)
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."""
kwargs = kwargs_param.copy()
# the battery state applies to the tracking device, not the beacon
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['host_name'] = beacon
see(**kwargs)
hass.async_add_job(async_see(**kwargs))
mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1)
mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1)
yield from mqtt.async_subscribe(
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_whitelist is None:
mqtt.subscribe(hass, WAYPOINT_TOPIC.format('+', '+'),
owntracks_waypoint_update, 1)
yield from mqtt.async_subscribe(
hass, WAYPOINT_TOPIC.format('+', '+'),
async_owntracks_waypoint_update, 1)
else:
for whitelist_user in waypoint_whitelist:
mqtt.subscribe(hass, WAYPOINT_TOPIC.format(whitelist_user,
'+'),
owntracks_waypoint_update, 1)
yield from mqtt.async_subscribe(
hass, WAYPOINT_TOPIC.format(whitelist_user, '+'),
async_owntracks_waypoint_update, 1)
return True
@callback
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('/')
dev_id_format = ''
if pretty:
@ -339,8 +353,12 @@ def parse_topic(topic, pretty=False):
return (host_name, dev_id)
@callback
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)
kwargs = {
'dev_id': dev_id,
@ -354,8 +372,12 @@ def _parse_see_args(topic, data):
return dev_id, kwargs
@callback
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:
kwargs['gps'] = (
zone.attributes['latitude'],

View file

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