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
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue