2015-09-20 07:27:50 +00:00
|
|
|
"""
|
2016-03-07 17:12:06 +00:00
|
|
|
Support the OwnTracks platform.
|
2015-09-20 07:27:50 +00:00
|
|
|
|
2015-10-13 18:55:15 +00:00
|
|
|
For more details about this platform, please refer to the documentation at
|
2015-11-09 12:12:18 +00:00
|
|
|
https://home-assistant.io/components/device_tracker.owntracks/
|
2015-09-20 07:27:50 +00:00
|
|
|
"""
|
|
|
|
import json
|
2015-09-21 03:09:53 +00:00
|
|
|
import logging
|
2016-01-29 09:39:00 +00:00
|
|
|
import threading
|
|
|
|
from collections import defaultdict
|
2015-09-20 07:27:50 +00:00
|
|
|
|
2016-09-04 17:10:20 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
|
|
|
import homeassistant.helpers.config_validation as cv
|
2015-09-20 07:27:50 +00:00
|
|
|
import homeassistant.components.mqtt as mqtt
|
2016-01-29 09:39:00 +00:00
|
|
|
from homeassistant.const import STATE_HOME
|
2016-05-19 15:04:55 +00:00
|
|
|
from homeassistant.util import convert, slugify
|
2016-08-25 16:03:07 +00:00
|
|
|
from homeassistant.components import zone as zone_comp
|
2016-09-07 01:35:10 +00:00
|
|
|
from homeassistant.components.device_tracker import PLATFORM_SCHEMA
|
2015-09-20 07:27:50 +00:00
|
|
|
|
|
|
|
DEPENDENCIES = ['mqtt']
|
|
|
|
|
2016-01-29 09:39:00 +00:00
|
|
|
REGIONS_ENTERED = defaultdict(list)
|
2016-01-29 11:49:44 +00:00
|
|
|
MOBILE_BEACONS_ACTIVE = defaultdict(list)
|
|
|
|
|
|
|
|
BEACON_DEV_ID = 'beacon'
|
2016-01-29 09:39:00 +00:00
|
|
|
|
2015-09-20 07:27:50 +00:00
|
|
|
LOCATION_TOPIC = 'owntracks/+/+'
|
2016-01-02 17:26:59 +00:00
|
|
|
EVENT_TOPIC = 'owntracks/+/+/event'
|
2016-08-26 14:22:08 +00:00
|
|
|
WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'
|
2015-09-20 07:27:50 +00:00
|
|
|
|
2016-01-29 09:39:00 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
LOCK = threading.Lock()
|
|
|
|
|
2016-03-04 19:19:50 +00:00
|
|
|
CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
|
2016-08-26 14:22:08 +00:00
|
|
|
CONF_WAYPOINT_IMPORT = 'waypoints'
|
|
|
|
CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
|
2016-03-04 19:19:50 +00:00
|
|
|
|
2016-08-15 12:08:30 +00:00
|
|
|
VALIDATE_LOCATION = 'location'
|
|
|
|
VALIDATE_TRANSITION = 'transition'
|
2016-08-31 02:46:01 +00:00
|
|
|
VALIDATE_WAYPOINTS = 'waypoints'
|
2016-08-15 12:08:30 +00:00
|
|
|
|
2016-08-26 14:22:08 +00:00
|
|
|
WAYPOINT_LAT_KEY = 'lat'
|
|
|
|
WAYPOINT_LON_KEY = 'lon'
|
|
|
|
|
2016-09-07 01:35:10 +00:00
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
|
|
|
vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
|
2016-09-04 17:10:20 +00:00
|
|
|
vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean,
|
|
|
|
vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(cv.ensure_list, [cv.string])
|
|
|
|
})
|
|
|
|
|
2016-08-25 11:17:34 +00:00
|
|
|
|
2015-09-20 07:27:50 +00:00
|
|
|
def setup_scanner(hass, config, see):
|
2016-03-07 17:12:06 +00:00
|
|
|
"""Setup an OwnTracks tracker."""
|
2016-03-04 19:19:50 +00:00
|
|
|
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
|
2016-09-04 17:10:20 +00:00
|
|
|
waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
|
2016-08-26 14:22:08 +00:00
|
|
|
waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
|
2016-03-04 19:19:50 +00:00
|
|
|
|
2016-04-12 05:02:47 +00:00
|
|
|
def validate_payload(payload, data_type):
|
|
|
|
"""Validate OwnTracks payload."""
|
2015-09-20 18:46:01 +00:00
|
|
|
try:
|
|
|
|
data = json.loads(payload)
|
|
|
|
except ValueError:
|
|
|
|
# If invalid JSON
|
2016-04-12 05:02:47 +00:00
|
|
|
_LOGGER.error('Unable to parse payload as JSON: %s', payload)
|
|
|
|
return None
|
|
|
|
if not isinstance(data, dict) or data.get('_type') != data_type:
|
|
|
|
_LOGGER.debug('Skipping %s update for following data '
|
|
|
|
'because of missing or malformatted data: %s',
|
|
|
|
data_type, data)
|
|
|
|
return None
|
2016-08-31 02:46:01 +00:00
|
|
|
if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS:
|
2016-08-15 12:08:30 +00:00
|
|
|
return data
|
2016-04-12 05:02:47 +00:00
|
|
|
if max_gps_accuracy is not None and \
|
|
|
|
convert(data.get('acc'), float, 0.0) > max_gps_accuracy:
|
2016-08-16 08:48:13 +00:00
|
|
|
_LOGGER.warning('Ignoring %s update because expected GPS '
|
|
|
|
'accuracy %s is not met: %s',
|
|
|
|
data_type, max_gps_accuracy, payload)
|
2016-04-12 05:02:47 +00:00
|
|
|
return None
|
2016-05-19 15:16:43 +00:00
|
|
|
if convert(data.get('acc'), float, 1.0) == 0.0:
|
2016-08-16 08:48:13 +00:00
|
|
|
_LOGGER.warning('Ignoring %s update because GPS accuracy'
|
|
|
|
'is zero: %s',
|
|
|
|
data_type, payload)
|
2016-05-19 15:16:43 +00:00
|
|
|
return None
|
|
|
|
|
2016-04-12 05:02:47 +00:00
|
|
|
return data
|
2015-09-21 03:09:53 +00:00
|
|
|
|
2016-04-12 05:02:47 +00:00
|
|
|
def owntracks_location_update(topic, payload, qos):
|
|
|
|
"""MQTT message received."""
|
|
|
|
# Docs on available data:
|
|
|
|
# http://owntracks.org/booklet/tech/json/#_typelocation
|
2016-08-15 12:08:30 +00:00
|
|
|
data = validate_payload(payload, VALIDATE_LOCATION)
|
2016-04-12 05:02:47 +00:00
|
|
|
if not data:
|
2015-09-20 18:46:01 +00:00
|
|
|
return
|
2015-09-21 03:09:53 +00:00
|
|
|
|
2016-01-29 09:39:00 +00:00
|
|
|
dev_id, kwargs = _parse_see_args(topic, data)
|
|
|
|
|
|
|
|
# Block updates if we're in a region
|
|
|
|
with LOCK:
|
|
|
|
if REGIONS_ENTERED[dev_id]:
|
|
|
|
_LOGGER.debug(
|
|
|
|
"location update ignored - inside region %s",
|
|
|
|
REGIONS_ENTERED[-1])
|
|
|
|
return
|
2015-09-21 03:09:53 +00:00
|
|
|
|
2016-01-29 09:39:00 +00:00
|
|
|
see(**kwargs)
|
2016-01-29 11:49:44 +00:00
|
|
|
see_beacons(dev_id, kwargs)
|
2016-01-03 16:12:11 +00:00
|
|
|
|
2016-01-02 17:26:59 +00:00
|
|
|
def owntracks_event_update(topic, payload, qos):
|
2016-03-07 17:12:06 +00:00
|
|
|
"""MQTT event (geofences) received."""
|
2016-01-02 17:26:59 +00:00
|
|
|
# Docs on available data:
|
|
|
|
# http://owntracks.org/booklet/tech/json/#_typetransition
|
2016-08-15 12:08:30 +00:00
|
|
|
data = validate_payload(payload, VALIDATE_TRANSITION)
|
2016-04-12 05:02:47 +00:00
|
|
|
if not data:
|
2016-01-02 17:26:59 +00:00
|
|
|
return
|
2015-09-20 07:27:50 +00:00
|
|
|
|
2016-04-07 19:21:25 +00:00
|
|
|
if data.get('desc') is None:
|
|
|
|
_LOGGER.error(
|
2016-04-12 05:02:47 +00:00
|
|
|
"Location missing from `Entering/Leaving` message - "
|
2016-04-07 19:21:25 +00:00
|
|
|
"please turn `Share` on in OwnTracks app")
|
|
|
|
return
|
2016-01-29 09:39:00 +00:00
|
|
|
# OwnTracks uses - at the start of a beacon zone
|
|
|
|
# to switch on 'hold mode' - ignore this
|
2016-05-19 15:04:55 +00:00
|
|
|
location = slugify(data['desc'].lstrip("-"))
|
2016-01-29 09:39:00 +00:00
|
|
|
if location.lower() == 'home':
|
|
|
|
location = STATE_HOME
|
|
|
|
|
|
|
|
dev_id, kwargs = _parse_see_args(topic, data)
|
|
|
|
|
2016-04-12 05:02:47 +00:00
|
|
|
def enter_event():
|
|
|
|
"""Execute enter event."""
|
2016-08-25 16:03:07 +00:00
|
|
|
zone = hass.states.get("zone.{}".format(location))
|
2016-01-29 09:39:00 +00:00
|
|
|
with LOCK:
|
2016-08-25 16:03:07 +00:00
|
|
|
if zone is None and data.get('t') == 'b':
|
2016-04-12 05:02:47 +00:00
|
|
|
# Not a HA zone, and a beacon so assume mobile
|
|
|
|
beacons = MOBILE_BEACONS_ACTIVE[dev_id]
|
|
|
|
if location not in beacons:
|
|
|
|
beacons.append(location)
|
|
|
|
_LOGGER.info("Added beacon %s", location)
|
2016-01-29 11:49:44 +00:00
|
|
|
else:
|
|
|
|
# Normal region
|
2016-01-29 09:39:00 +00:00
|
|
|
regions = REGIONS_ENTERED[dev_id]
|
|
|
|
if location not in regions:
|
|
|
|
regions.append(location)
|
|
|
|
_LOGGER.info("Enter region %s", location)
|
2016-08-25 16:03:07 +00:00
|
|
|
_set_gps_from_zone(kwargs, location, zone)
|
2016-01-29 09:39:00 +00:00
|
|
|
|
|
|
|
see(**kwargs)
|
2016-01-29 11:49:44 +00:00
|
|
|
see_beacons(dev_id, kwargs)
|
2016-01-03 16:12:11 +00:00
|
|
|
|
2016-04-12 05:02:47 +00:00
|
|
|
def leave_event():
|
|
|
|
"""Execute leave event."""
|
2016-02-16 12:54:36 +00:00
|
|
|
with LOCK:
|
|
|
|
regions = REGIONS_ENTERED[dev_id]
|
|
|
|
if location in regions:
|
|
|
|
regions.remove(location)
|
|
|
|
new_region = regions[-1] if regions else None
|
|
|
|
|
|
|
|
if new_region:
|
|
|
|
# Exit to previous region
|
2016-08-25 16:03:07 +00:00
|
|
|
zone = hass.states.get("zone.{}".format(new_region))
|
|
|
|
_set_gps_from_zone(kwargs, new_region, zone)
|
2016-02-16 12:54:36 +00:00
|
|
|
_LOGGER.info("Exit to %s", new_region)
|
2016-03-04 19:19:50 +00:00
|
|
|
see(**kwargs)
|
|
|
|
see_beacons(dev_id, kwargs)
|
2016-01-29 09:39:00 +00:00
|
|
|
|
2016-02-16 12:54:36 +00:00
|
|
|
else:
|
|
|
|
_LOGGER.info("Exit to GPS")
|
2016-03-04 19:19:50 +00:00
|
|
|
# Check for GPS accuracy
|
2016-08-15 12:08:30 +00:00
|
|
|
valid_gps = True
|
|
|
|
if 'acc' in data:
|
|
|
|
if data['acc'] == 0.0:
|
|
|
|
valid_gps = False
|
2016-08-16 08:48:13 +00:00
|
|
|
_LOGGER.warning(
|
|
|
|
'Ignoring GPS in region exit because accuracy'
|
|
|
|
'is zero: %s',
|
|
|
|
payload)
|
2016-08-15 12:08:30 +00:00
|
|
|
if (max_gps_accuracy is not None and
|
|
|
|
data['acc'] > max_gps_accuracy):
|
|
|
|
valid_gps = False
|
2016-08-16 08:48:13 +00:00
|
|
|
_LOGGER.warning(
|
|
|
|
'Ignoring GPS in region exit because expected '
|
|
|
|
'GPS accuracy %s is not met: %s',
|
|
|
|
max_gps_accuracy, payload)
|
2016-08-15 12:08:30 +00:00
|
|
|
if valid_gps:
|
2016-03-04 19:19:50 +00:00
|
|
|
see(**kwargs)
|
|
|
|
see_beacons(dev_id, kwargs)
|
2016-01-29 11:49:44 +00:00
|
|
|
|
2016-02-16 12:54:36 +00:00
|
|
|
beacons = MOBILE_BEACONS_ACTIVE[dev_id]
|
|
|
|
if location in beacons:
|
|
|
|
beacons.remove(location)
|
|
|
|
_LOGGER.info("Remove beacon %s", location)
|
2016-01-29 09:39:00 +00:00
|
|
|
|
2016-04-12 05:02:47 +00:00
|
|
|
if data['event'] == 'enter':
|
|
|
|
enter_event()
|
|
|
|
elif data['event'] == 'leave':
|
|
|
|
leave_event()
|
2016-01-02 17:26:59 +00:00
|
|
|
else:
|
2016-01-29 09:39:00 +00:00
|
|
|
_LOGGER.error(
|
2016-01-03 16:42:49 +00:00
|
|
|
'Misformatted mqtt msgs, _type=transition, event=%s',
|
|
|
|
data['event'])
|
2016-01-02 17:26:59 +00:00
|
|
|
return
|
|
|
|
|
2016-08-12 09:18:28 +00:00
|
|
|
def owntracks_waypoint_update(topic, payload, qos):
|
|
|
|
"""List of waypoints published by a user."""
|
|
|
|
# Docs on available data:
|
|
|
|
# http://owntracks.org/booklet/tech/json/#_typewaypoints
|
2016-08-31 02:46:01 +00:00
|
|
|
data = validate_payload(payload, VALIDATE_WAYPOINTS)
|
2016-08-12 09:18:28 +00:00
|
|
|
if not data:
|
|
|
|
return
|
|
|
|
|
|
|
|
wayps = data['waypoints']
|
|
|
|
_LOGGER.info("Got %d waypoints from %s", len(wayps), topic)
|
|
|
|
for wayp in wayps:
|
|
|
|
name = wayp['desc']
|
2016-08-28 07:48:30 +00:00
|
|
|
pretty_name = parse_topic(topic, True)[1] + ' - ' + name
|
2016-08-26 14:22:08 +00:00
|
|
|
lat = wayp[WAYPOINT_LAT_KEY]
|
|
|
|
lon = wayp[WAYPOINT_LON_KEY]
|
2016-08-12 09:18:28 +00:00
|
|
|
rad = wayp['rad']
|
2016-09-24 07:04:03 +00:00
|
|
|
|
|
|
|
# check zone exists
|
|
|
|
entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name))
|
|
|
|
|
|
|
|
# Check if state already exists
|
|
|
|
if hass.states.get(entity_id) is not None:
|
|
|
|
continue
|
|
|
|
|
2016-08-28 07:48:30 +00:00
|
|
|
zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad,
|
2016-09-24 07:04:03 +00:00
|
|
|
zone_comp.ICON_IMPORT, False)
|
|
|
|
zone.entity_id = entity_id
|
|
|
|
zone.update_ha_state()
|
2016-08-12 09:18:28 +00:00
|
|
|
|
2016-01-29 16:12:34 +00:00
|
|
|
def see_beacons(dev_id, kwargs_param):
|
2016-03-07 17:12:06 +00:00
|
|
|
"""Set active beacons to the current location."""
|
2016-01-29 16:12:34 +00:00
|
|
|
kwargs = kwargs_param.copy()
|
2016-02-16 12:54:36 +00:00
|
|
|
# the battery state applies to the tracking device, not the beacon
|
|
|
|
kwargs.pop('battery', None)
|
2016-01-29 11:49:44 +00:00
|
|
|
for beacon in MOBILE_BEACONS_ACTIVE[dev_id]:
|
2016-01-29 15:15:59 +00:00
|
|
|
kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
|
|
|
|
kwargs['host_name'] = beacon
|
2016-01-29 11:49:44 +00:00
|
|
|
see(**kwargs)
|
|
|
|
|
2016-01-29 09:39:00 +00:00
|
|
|
mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1)
|
|
|
|
mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1)
|
2016-01-03 16:12:11 +00:00
|
|
|
|
2016-08-26 14:22:08 +00:00
|
|
|
if waypoint_import:
|
|
|
|
if waypoint_whitelist is None:
|
|
|
|
mqtt.subscribe(hass, WAYPOINT_TOPIC.format('+', '+'),
|
|
|
|
owntracks_waypoint_update, 1)
|
|
|
|
else:
|
|
|
|
for whitelist_user in waypoint_whitelist:
|
|
|
|
mqtt.subscribe(hass, WAYPOINT_TOPIC.format(whitelist_user,
|
|
|
|
'+'),
|
|
|
|
owntracks_waypoint_update, 1)
|
2016-08-12 09:18:28 +00:00
|
|
|
|
2016-01-29 09:39:00 +00:00
|
|
|
return True
|
2016-01-03 16:12:11 +00:00
|
|
|
|
2015-09-20 07:27:50 +00:00
|
|
|
|
2016-08-28 07:48:30 +00:00
|
|
|
def parse_topic(topic, pretty=False):
|
|
|
|
"""Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple."""
|
2016-01-29 09:39:00 +00:00
|
|
|
parts = topic.split('/')
|
2016-08-28 07:48:30 +00:00
|
|
|
dev_id_format = ''
|
|
|
|
if pretty:
|
|
|
|
dev_id_format = '{} {}'
|
|
|
|
else:
|
|
|
|
dev_id_format = '{}_{}'
|
|
|
|
dev_id = slugify(dev_id_format.format(parts[1], parts[2]))
|
2016-01-29 09:39:00 +00:00
|
|
|
host_name = parts[1]
|
2016-08-28 07:48:30 +00:00
|
|
|
return (host_name, dev_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_see_args(topic, data):
|
|
|
|
"""Parse the OwnTracks location parameters, into the format see expects."""
|
|
|
|
(host_name, dev_id) = parse_topic(topic, False)
|
2016-01-29 09:39:00 +00:00
|
|
|
kwargs = {
|
|
|
|
'dev_id': dev_id,
|
|
|
|
'host_name': host_name,
|
2016-08-26 14:22:08 +00:00
|
|
|
'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY])
|
2016-01-29 09:39:00 +00:00
|
|
|
}
|
|
|
|
if 'acc' in data:
|
|
|
|
kwargs['gps_accuracy'] = data['acc']
|
|
|
|
if 'batt' in data:
|
|
|
|
kwargs['battery'] = data['batt']
|
|
|
|
return dev_id, kwargs
|
|
|
|
|
|
|
|
|
2016-08-25 16:03:07 +00:00
|
|
|
def _set_gps_from_zone(kwargs, location, zone):
|
2016-03-07 17:12:06 +00:00
|
|
|
"""Set the see parameters from the zone parameters."""
|
2016-08-25 16:03:07 +00:00
|
|
|
if zone is not None:
|
2016-01-29 09:39:00 +00:00
|
|
|
kwargs['gps'] = (
|
2016-08-25 16:03:07 +00:00
|
|
|
zone.attributes['latitude'],
|
|
|
|
zone.attributes['longitude'])
|
|
|
|
kwargs['gps_accuracy'] = zone.attributes['radius']
|
2016-03-13 18:01:29 +00:00
|
|
|
kwargs['location_name'] = location
|
2016-01-29 09:39:00 +00:00
|
|
|
return kwargs
|