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
pull/11509/head
Chris Cowart 2018-01-07 23:16:45 -08:00 committed by Martin Hjelmare
parent c53fc94e84
commit 8c0035c5b3
3 changed files with 139 additions and 17 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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')