From 3e70154695b3141560814d6fddf695c3ac0e55e8 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Mar 2017 09:23:58 +0100 Subject: [PATCH] OwnTrack Async (#6363) * Migrate owntrack to async * fix tests --- .../components/device_tracker/owntracks.py | 202 ++++++++++-------- .../device_tracker/test_owntracks.py | 6 +- 2 files changed, 117 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index c03041b6317..f4737fd26da 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -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'], diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 4bea0d3d0b3..31f9a6b96a0 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -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