New features for Owntracks device_tracker ()

* 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
homeassistant/components/device_tracker
tests/components/device_tracker

View File

@ -32,19 +32,27 @@ CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
CONF_SECRET = 'secret' CONF_SECRET = 'secret'
CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_IMPORT = 'waypoints'
CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
CONF_MQTT_TOPIC = 'mqtt_topic'
CONF_REGION_MAPPING = 'region_mapping'
CONF_EVENTS_ONLY = 'events_only'
DEPENDENCIES = ['mqtt'] DEPENDENCIES = ['mqtt']
OWNTRACKS_TOPIC = 'owntracks/#' DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#'
REGION_MAPPING = {}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, 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( vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(
cv.ensure_list, [cv.string]), cv.ensure_list, [cv.string]),
vol.Optional(CONF_SECRET): vol.Any( vol.Optional(CONF_SECRET): vol.Any(
vol.Schema({vol.Optional(cv.string): cv.string}), 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 async_handle_message(hass, context, message)
yield from mqtt.async_subscribe( yield from mqtt.async_subscribe(
hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1) hass, context.mqtt_topic, async_handle_mqtt_message, 1)
return True return True
def _parse_topic(topic): def _parse_topic(topic, subscribe_topic):
"""Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple.
Async friendly. Async friendly.
""" """
subscription = subscribe_topic.split('/')
try: try:
_, user, device, *_ = topic.split('/', 3) user_index = subscription.index('#')
except ValueError: 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) _LOGGER.error("Can't parse topic: '%s'", topic)
raise raise
return user, device 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. """Parse the OwnTracks location parameters, into the format see expects.
Async friendly. Async friendly.
""" """
user, device = _parse_topic(message['topic']) user, device = _parse_topic(message['topic'], subscribe_topic)
dev_id = slugify('{}_{}'.format(user, device)) dev_id = slugify('{}_{}'.format(user, device))
kwargs = { kwargs = {
'dev_id': dev_id, 'dev_id': dev_id,
@ -185,16 +201,20 @@ def context_from_config(async_see, config):
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)
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, return OwnTracksContext(async_see, secret, max_gps_accuracy,
waypoint_import, waypoint_whitelist) waypoint_import, waypoint_whitelist,
region_mapping, events_only, mqtt_topic)
class OwnTracksContext: class OwnTracksContext:
"""Hold the current OwnTracks context.""" """Hold the current OwnTracks context."""
def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, 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.""" """Initialize an OwnTracks context."""
self.async_see = async_see self.async_see = async_see
self.secret = secret self.secret = secret
@ -203,6 +223,9 @@ class OwnTracksContext:
self.regions_entered = defaultdict(list) self.regions_entered = defaultdict(list)
self.import_waypoints = import_waypoints self.import_waypoints = import_waypoints
self.waypoint_whitelist = waypoint_whitelist self.waypoint_whitelist = waypoint_whitelist
self.region_mapping = region_mapping
self.events_only = events_only
self.mqtt_topic = mqtt_topic
@callback @callback
def async_valid_accuracy(self, message): 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): if not context.async_valid_accuracy(message):
return 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]: if context.regions_entered[dev_id]:
_LOGGER.debug( _LOGGER.debug(
@ -283,7 +310,7 @@ def async_handle_location_message(hass, context, message):
def _async_transition_message_enter(hass, context, message, location): def _async_transition_message_enter(hass, context, message, location):
"""Execute enter event.""" """Execute enter event."""
zone = hass.states.get("zone.{}".format(slugify(location))) 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': if zone is None and message.get('t') == 'b':
# Not a HA zone, and a beacon so mobile beacon. # 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 @asyncio.coroutine
def _async_transition_message_leave(hass, context, message, location): def _async_transition_message_leave(hass, context, message, location):
"""Execute leave event.""" """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] regions = context.regions_entered[dev_id]
if location in regions: 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 # OwnTracks uses - at the start of a beacon zone
# to switch on 'hold mode' - ignore this # to switch on 'hold mode' - ignore this
location = message['desc'].lstrip("-") 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': if location.lower() == 'home':
location = STATE_HOME location = STATE_HOME
@ -398,7 +431,7 @@ def async_handle_waypoints_message(hass, context, message):
return return
if context.waypoint_whitelist is not None: 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: if user not in context.waypoint_whitelist:
return return
@ -410,7 +443,7 @@ def async_handle_waypoints_message(hass, context, message):
_LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) _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: for wayp in wayps:
yield from async_handle_waypoint(hass, name_base, wayp) 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/ https://home-assistant.io/components/device_tracker.owntracks_http/
""" """
import asyncio import asyncio
import re
from aiohttp.web_exceptions import HTTPInternalServerError from aiohttp.web_exceptions import HTTPInternalServerError
@ -43,8 +44,11 @@ class OwnTracksView(HomeAssistantView):
"""Handle an OwnTracks message.""" """Handle an OwnTracks message."""
hass = request.app['hass'] hass = request.app['hass']
subscription = self.context.mqtt_topic
topic = re.sub('/#$', '', subscription)
message = yield from request.json() message = yield from request.json()
message['topic'] = 'owntracks/{}/{}'.format(user, device) message['topic'] = '{}/{}/{}'.format(topic, user, device)
try: try:
yield from async_handle_message(hass, self.context, message) 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_IMPORT = owntracks.CONF_WAYPOINT_IMPORT
CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST
CONF_SECRET = owntracks.CONF_SECRET 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_LAT = 45.0
TEST_ZONE_LON = 90.0 TEST_ZONE_LON = 90.0
@ -179,6 +182,13 @@ REGION_GPS_LEAVE_MESSAGE_OUTER = build_message(
'event': 'leave'}, 'event': 'leave'},
DEFAULT_TRANSITION_MESSAGE) 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 messages
REGION_BEACON_ENTER_MESSAGE = DEFAULT_BEACON_TRANSITION_MESSAGE REGION_BEACON_ENTER_MESSAGE = DEFAULT_BEACON_TRANSITION_MESSAGE
@ -616,6 +626,46 @@ class TestDeviceTrackerOwnTracks(BaseMQTT):
self.send_message(EVENT_TOPIC, message) self.send_message(EVENT_TOPIC, message)
self.assert_location_state('inner') 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 # Region Beacon based event entry / exit testing
def test_event_region_entry_exit(self): def test_event_region_entry_exit(self):
@ -1111,7 +1161,8 @@ class TestDeviceTrackerOwnTracks(BaseMQTT):
test_config = { test_config = {
CONF_PLATFORM: 'owntracks', CONF_PLATFORM: 'owntracks',
CONF_MAX_GPS_ACCURACY: 200, CONF_MAX_GPS_ACCURACY: 200,
CONF_WAYPOINT_IMPORT: True CONF_WAYPOINT_IMPORT: True,
CONF_MQTT_TOPIC: 'owntracks/#',
} }
run_coroutine_threadsafe(owntracks.async_setup_scanner( run_coroutine_threadsafe(owntracks.async_setup_scanner(
self.hass, test_config, mock_see), self.hass.loop).result() 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.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE)
self.assert_location_latitude(LOCATION_MESSAGE['lat']) 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')