From 8c0035c5b33b35a20d01e4e0f30ac1af0f74158d Mon Sep 17 00:00:00 2001 From: Chris Cowart Date: Sun, 7 Jan 2018 23:16:45 -0800 Subject: [PATCH] New features for Owntracks device_tracker (#11480) * New features for Owntracks device_tracker - Supporting a mapping of region names in OT to zones in HA, allowing separate namespaces in both applications. This is especially helpful if using one OT instance to update geofences for multiple homes. - Creating a setting to ignore all location updates, allowing users to rely completely on enter and leave events. I have personally always used OT integrations with home automation this way and find it the most reliable. - Allowing the OT topic to be overridden in configuration * Fixing configuration of MQTT topic, related tests * Tests for Owntracks events_only feature * Tests for customizing mqtt topic, region mapping * Fixing _parse and http for owntracks custom topic * Making tests more thorough and cleaning up lint --- .../components/device_tracker/owntracks.py | 63 ++++++++++---- .../device_tracker/owntracks_http.py | 6 +- .../device_tracker/test_owntracks.py | 87 ++++++++++++++++++- 3 files changed, 139 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 0c869dd4b57..32d677a59db 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -32,19 +32,27 @@ CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' DEPENDENCIES = ['mqtt'] -OWNTRACKS_TOPIC = 'owntracks/#' +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' +REGION_MAPPING = {} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( cv.ensure_list, [cv.string]), vol.Optional(CONF_SECRET): vol.Any( vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string) + cv.string), + vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict }) @@ -82,31 +90,39 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): yield from async_handle_message(hass, context, message) yield from mqtt.async_subscribe( - hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1) + hass, context.mqtt_topic, async_handle_mqtt_message, 1) return True -def _parse_topic(topic): - """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. +def _parse_topic(topic, subscribe_topic): + """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. Async friendly. """ + subscription = subscribe_topic.split('/') try: - _, user, device, *_ = topic.split('/', 3) + user_index = subscription.index('#') except ValueError: + _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic) + raise + + topic_list = topic.split('/') + try: + user, device = topic_list[user_index], topic_list[user_index + 1] + except IndexError: _LOGGER.error("Can't parse topic: '%s'", topic) raise return user, device -def _parse_see_args(message): +def _parse_see_args(message, subscribe_topic): """Parse the OwnTracks location parameters, into the format see expects. Async friendly. """ - user, device = _parse_topic(message['topic']) + user, device = _parse_topic(message['topic'], subscribe_topic) dev_id = slugify('{}_{}'.format(user, device)) kwargs = { 'dev_id': dev_id, @@ -185,16 +201,20 @@ def context_from_config(async_see, config): waypoint_import = config.get(CONF_WAYPOINT_IMPORT) waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) secret = config.get(CONF_SECRET) + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) return OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist) + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) class OwnTracksContext: """Hold the current OwnTracks context.""" def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, - waypoint_whitelist): + waypoint_whitelist, region_mapping, events_only, mqtt_topic): """Initialize an OwnTracks context.""" self.async_see = async_see self.secret = secret @@ -203,6 +223,9 @@ class OwnTracksContext: self.regions_entered = defaultdict(list) self.import_waypoints = import_waypoints self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic @callback def async_valid_accuracy(self, message): @@ -267,7 +290,11 @@ def async_handle_location_message(hass, context, message): if not context.async_valid_accuracy(message): return - dev_id, kwargs = _parse_see_args(message) + if context.events_only: + _LOGGER.debug("Location update ignored due to events_only setting") + return + + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) if context.regions_entered[dev_id]: _LOGGER.debug( @@ -283,7 +310,7 @@ def async_handle_location_message(hass, context, message): def _async_transition_message_enter(hass, context, message, location): """Execute enter event.""" zone = hass.states.get("zone.{}".format(slugify(location))) - dev_id, kwargs = _parse_see_args(message) + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) if zone is None and message.get('t') == 'b': # Not a HA zone, and a beacon so mobile beacon. @@ -309,7 +336,7 @@ def _async_transition_message_enter(hass, context, message, location): @asyncio.coroutine def _async_transition_message_leave(hass, context, message, location): """Execute leave event.""" - dev_id, kwargs = _parse_see_args(message) + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) regions = context.regions_entered[dev_id] if location in regions: @@ -352,6 +379,12 @@ def async_handle_transition_message(hass, context, message): # OwnTracks uses - at the start of a beacon zone # to switch on 'hold mode' - ignore this location = message['desc'].lstrip("-") + + # Create a layer of indirection for Owntracks instances that may name + # regions differently than their HA names + if location in context.region_mapping: + location = context.region_mapping[location] + if location.lower() == 'home': location = STATE_HOME @@ -398,7 +431,7 @@ def async_handle_waypoints_message(hass, context, message): return if context.waypoint_whitelist is not None: - user = _parse_topic(message['topic'])[0] + user = _parse_topic(message['topic'], context.mqtt_topic)[0] if user not in context.waypoint_whitelist: return @@ -410,7 +443,7 @@ def async_handle_waypoints_message(hass, context, message): _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) - name_base = ' '.join(_parse_topic(message['topic'])) + name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic)) for wayp in wayps: yield from async_handle_waypoint(hass, name_base, wayp) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py index dcc3300cc12..d74e1fc6d95 100644 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks_http/ """ import asyncio +import re from aiohttp.web_exceptions import HTTPInternalServerError @@ -43,8 +44,11 @@ class OwnTracksView(HomeAssistantView): """Handle an OwnTracks message.""" hass = request.app['hass'] + subscription = self.context.mqtt_topic + topic = re.sub('/#$', '', subscription) + message = yield from request.json() - message['topic'] = 'owntracks/{}/{}'.format(user, device) + message['topic'] = '{}/{}/{}'.format(topic, user, device) try: yield from async_handle_message(hass, self.context, message) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 4f5efb9d09d..5f1f29e7697 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -35,6 +35,9 @@ CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST CONF_SECRET = owntracks.CONF_SECRET +CONF_MQTT_TOPIC = owntracks.CONF_MQTT_TOPIC +CONF_EVENTS_ONLY = owntracks.CONF_EVENTS_ONLY +CONF_REGION_MAPPING = owntracks.CONF_REGION_MAPPING TEST_ZONE_LAT = 45.0 TEST_ZONE_LON = 90.0 @@ -179,6 +182,13 @@ REGION_GPS_LEAVE_MESSAGE_OUTER = build_message( 'event': 'leave'}, DEFAULT_TRANSITION_MESSAGE) +REGION_GPS_ENTER_MESSAGE_OUTER = build_message( + {'lon': OUTER_ZONE['longitude'], + 'lat': OUTER_ZONE['latitude'], + 'desc': 'outer', + 'event': 'enter'}, + DEFAULT_TRANSITION_MESSAGE) + # Region Beacon messages REGION_BEACON_ENTER_MESSAGE = DEFAULT_BEACON_TRANSITION_MESSAGE @@ -616,6 +626,46 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.send_message(EVENT_TOPIC, message) self.assert_location_state('inner') + def test_events_only_on(self): + """Test events_only config suppresses location updates.""" + # Sending a location message that is not home + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + self.assert_location_state(STATE_NOT_HOME) + + self.context.events_only = True + + # Enter and Leave messages + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + self.assert_location_state('outer') + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + self.assert_location_state(STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # Ignored location update. Location remains at previous. + self.assert_location_state(STATE_NOT_HOME) + + def test_events_only_off(self): + """Test when events_only is False.""" + # Sending a location message that is not home + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + self.assert_location_state(STATE_NOT_HOME) + + self.context.events_only = False + + # Enter and Leave messages + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + self.assert_location_state('outer') + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + self.assert_location_state(STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # Location update processed + self.assert_location_state('outer') + # Region Beacon based event entry / exit testing def test_event_region_entry_exit(self): @@ -1111,7 +1161,8 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): test_config = { CONF_PLATFORM: 'owntracks', CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True + CONF_WAYPOINT_IMPORT: True, + CONF_MQTT_TOPIC: 'owntracks/#', } run_coroutine_threadsafe(owntracks.async_setup_scanner( self.hass, test_config, mock_see), self.hass.loop).result() @@ -1353,3 +1404,37 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) self.assert_location_latitude(LOCATION_MESSAGE['lat']) + + def test_customized_mqtt_topic(self): + """Test subscribing to a custom mqtt topic.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_MQTT_TOPIC: 'mytracks/#', + }}) + + topic = 'mytracks/{}/{}'.format(USER, DEVICE) + + self.send_message(topic, LOCATION_MESSAGE) + self.assert_location_latitude(LOCATION_MESSAGE['lat']) + + def test_region_mapping(self): + """Test region to zone mapping.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }}) + + self.hass.states.set( + 'zone.inner', 'zoning', INNER_ZONE) + + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + self.assertEqual(message['desc'], 'foo') + + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner')