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 lintpull/11509/head
parent
c53fc94e84
commit
8c0035c5b3
homeassistant/components/device_tracker
tests/components/device_tracker
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
Loading…
Reference in New Issue