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