Merge pull request #2973 from nma83/owntracks-waypoint-import

Owntracks waypoint import
This commit is contained in:
Greg Dowling 2016-08-31 15:25:49 +01:00 committed by GitHub
commit 0943cc78cd
3 changed files with 212 additions and 17 deletions

View file

@ -12,6 +12,7 @@ from collections import defaultdict
import homeassistant.components.mqtt as mqtt
from homeassistant.const import STATE_HOME
from homeassistant.util import convert, slugify
from homeassistant.components import zone as zone_comp
DEPENDENCIES = ['mqtt']
@ -22,20 +23,29 @@ BEACON_DEV_ID = 'beacon'
LOCATION_TOPIC = 'owntracks/+/+'
EVENT_TOPIC = 'owntracks/+/+/event'
WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'
_LOGGER = logging.getLogger(__name__)
LOCK = threading.Lock()
CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
CONF_WAYPOINT_IMPORT = 'waypoints'
CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
VALIDATE_LOCATION = 'location'
VALIDATE_TRANSITION = 'transition'
VALIDATE_WAYPOINTS = 'waypoints'
WAYPOINT_LAT_KEY = 'lat'
WAYPOINT_LON_KEY = 'lon'
def setup_scanner(hass, config, see):
"""Setup an OwnTracks tracker."""
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
waypoint_import = config.get(CONF_WAYPOINT_IMPORT, True)
waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
def validate_payload(payload, data_type):
"""Validate OwnTracks payload."""
@ -50,7 +60,7 @@ def setup_scanner(hass, config, see):
'because of missing or malformatted data: %s',
data_type, data)
return None
if data_type == VALIDATE_TRANSITION:
if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS:
return data
if max_gps_accuracy is not None and \
convert(data.get('acc'), float, 0.0) > max_gps_accuracy:
@ -182,6 +192,26 @@ def setup_scanner(hass, config, see):
data['event'])
return
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
data = validate_payload(payload, VALIDATE_WAYPOINTS)
if not data:
return
wayps = data['waypoints']
_LOGGER.info("Got %d waypoints from %s", len(wayps), topic)
for wayp in wayps:
name = wayp['desc']
pretty_name = parse_topic(topic, True)[1] + ' - ' + name
lat = wayp[WAYPOINT_LAT_KEY]
lon = wayp[WAYPOINT_LON_KEY]
rad = wayp['rad']
zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad,
zone_comp.ICON_IMPORT, False, True)
zone_comp.add_zone(hass, pretty_name, zone)
def see_beacons(dev_id, kwargs_param):
"""Set active beacons to the current location."""
kwargs = kwargs_param.copy()
@ -195,18 +225,39 @@ def setup_scanner(hass, config, see):
mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1)
mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1)
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)
return True
def parse_topic(topic, pretty=False):
"""Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple."""
parts = topic.split('/')
dev_id_format = ''
if pretty:
dev_id_format = '{} {}'
else:
dev_id_format = '{}_{}'
dev_id = slugify(dev_id_format.format(parts[1], parts[2]))
host_name = parts[1]
return (host_name, dev_id)
def _parse_see_args(topic, data):
"""Parse the OwnTracks location parameters, into the format see expects."""
parts = topic.split('/')
dev_id = slugify('{}_{}'.format(parts[1], parts[2]))
host_name = parts[1]
(host_name, dev_id) = parse_topic(topic, False)
kwargs = {
'dev_id': dev_id,
'host_name': host_name,
'gps': (data['lat'], data['lon'])
'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY])
}
if 'acc' in data:
kwargs['gps_accuracy'] = data['acc']

View file

@ -27,6 +27,9 @@ ATTR_PASSIVE = 'passive'
DEFAULT_PASSIVE = False
ICON_HOME = 'mdi:home'
ICON_IMPORT = 'mdi:import'
_LOGGER = logging.getLogger(__name__)
def active_zone(hass, latitude, longitude, radius=0):
@ -71,7 +74,6 @@ def in_zone(zone, latitude, longitude, radius=0):
def setup(hass, config):
"""Setup zone."""
entities = set()
for key in extract_domain_configs(config, DOMAIN):
entries = config[key]
if not isinstance(entries, list):
@ -90,26 +92,48 @@ def setup(hass, config):
'Each zone needs a latitude and longitude.')
continue
zone = Zone(hass, name, latitude, longitude, radius, icon, passive)
zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name,
entities)
zone.update_ha_state()
zone = Zone(hass, name, latitude, longitude, radius,
icon, passive, False)
add_zone(hass, name, zone, entities)
entities.add(zone.entity_id)
if ENTITY_ID_HOME not in entities:
zone = Zone(hass, hass.config.location_name, hass.config.latitude,
hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False)
zone = Zone(hass, hass.config.location_name,
hass.config.latitude, hass.config.longitude,
DEFAULT_RADIUS, ICON_HOME, False, False)
add_zone(hass, hass.config.location_name, zone, entities)
zone.entity_id = ENTITY_ID_HOME
zone.update_ha_state()
return True
# Add a zone to the existing set
def add_zone(hass, name, zone, entities=None):
"""Add a zone from other components."""
_LOGGER.info("Adding new zone %s", name)
if entities is None:
_entities = set()
else:
_entities = entities
zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name,
_entities)
zone_exists = hass.states.get(zone.entity_id)
if zone_exists is None:
zone.update_ha_state()
_entities.add(zone.entity_id)
return zone
else:
_LOGGER.info("Zone already exists")
return zone_exists
class Zone(Entity):
"""Representation of a Zone."""
# pylint: disable=too-many-arguments, too-many-instance-attributes
def __init__(self, hass, name, latitude, longitude, radius, icon, passive):
def __init__(self, hass, name, latitude, longitude, radius, icon, passive,
imported):
"""Initialize the zone."""
self.hass = hass
self._name = name
@ -118,6 +142,7 @@ class Zone(Entity):
self._radius = radius
self._icon = icon
self._passive = passive
self._imported = imported
@property
def name(self):

View file

@ -17,6 +17,10 @@ DEVICE = 'phone'
LOCATION_TOPIC = "owntracks/{}/{}".format(USER, DEVICE)
EVENT_TOPIC = "owntracks/{}/{}/event".format(USER, DEVICE)
WAYPOINT_TOPIC = owntracks.WAYPOINT_TOPIC.format(USER, DEVICE)
USER_BLACKLIST = 'ram'
WAYPOINT_TOPIC_BLOCKED = owntracks.WAYPOINT_TOPIC.format(USER_BLACKLIST,
DEVICE)
DEVICE_TRACKER_STATE = "device_tracker.{}_{}".format(USER, DEVICE)
@ -24,6 +28,8 @@ IBEACON_DEVICE = 'keys'
REGION_TRACKER_STATE = "device_tracker.beacon_{}".format(IBEACON_DEVICE)
CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT
CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST
LOCATION_MESSAGE = {
'batt': 92,
@ -107,6 +113,48 @@ REGION_LEAVE_INACCURATE_MESSAGE = {
'lat': 20.0,
'_type': 'transition'}
WAYPOINTS_EXPORTED_MESSAGE = {
"_type": "waypoints",
"_creator": "test",
"waypoints": [
{
"_type": "waypoint",
"tst": 3,
"lat": 47,
"lon": 9,
"rad": 10,
"desc": "exp_wayp1"
},
{
"_type": "waypoint",
"tst": 4,
"lat": 3,
"lon": 9,
"rad": 500,
"desc": "exp_wayp2"
}
]
}
WAYPOINTS_UPDATED_MESSAGE = {
"_type": "waypoints",
"_creator": "test",
"waypoints": [
{
"_type": "waypoint",
"tst": 4,
"lat": 9,
"lon": 47,
"rad": 50,
"desc": "exp_wayp1"
},
]
}
WAYPOINT_ENTITY_NAMES = ['zone.greg_phone__exp_wayp1',
'zone.greg_phone__exp_wayp2',
'zone.ram_phone__exp_wayp1',
'zone.ram_phone__exp_wayp2']
REGION_ENTER_ZERO_MESSAGE = {
'lon': 1.0,
@ -132,6 +180,9 @@ REGION_LEAVE_ZERO_MESSAGE = {
'lat': 20.0,
'_type': 'transition'}
BAD_JSON_PREFIX = '--$this is bad json#--'
BAD_JSON_SUFFIX = '** and it ends here ^^'
class TestDeviceTrackerOwnTracks(unittest.TestCase):
"""Test the OwnTrack sensor."""
@ -143,7 +194,9 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase):
self.assertTrue(device_tracker.setup(self.hass, {
device_tracker.DOMAIN: {
CONF_PLATFORM: 'owntracks',
CONF_MAX_GPS_ACCURACY: 200
CONF_MAX_GPS_ACCURACY: 200,
CONF_WAYPOINT_IMPORT: True,
CONF_WAYPOINT_WHITELIST: ['jon', 'greg']
}}))
self.hass.states.set(
@ -187,10 +240,18 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase):
except FileNotFoundError:
pass
def send_message(self, topic, message):
def mock_see(**kwargs):
"""Fake see method for owntracks."""
return
def send_message(self, topic, message, corrupt=False):
"""Test the sending of a message."""
fire_mqtt_message(
self.hass, topic, json.dumps(message))
str_message = json.dumps(message)
if corrupt:
mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX
else:
mod_message = str_message
fire_mqtt_message(self.hass, topic, mod_message)
self.hass.pool.block_till_done()
def assert_location_state(self, location):
@ -530,3 +591,61 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase):
self.send_message(EVENT_TOPIC, exit_message)
self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], [])
def test_waypoint_import_simple(self):
"""Test a simple import of list of waypoints."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
self.send_message(WAYPOINT_TOPIC, waypoints_message)
# Check if it made it into states
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0])
self.assertTrue(wayp is not None)
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1])
self.assertTrue(wayp is not None)
def test_waypoint_import_blacklist(self):
"""Test import of list of waypoints for blacklisted user."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message)
# Check if it made it into states
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2])
self.assertTrue(wayp is None)
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3])
self.assertTrue(wayp is None)
def test_waypoint_import_no_whitelist(self):
"""Test import of list of waypoints with no whitelist set."""
test_config = {
CONF_PLATFORM: 'owntracks',
CONF_MAX_GPS_ACCURACY: 200,
CONF_WAYPOINT_IMPORT: True
}
owntracks.setup_scanner(self.hass, test_config, self.mock_see)
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message)
# Check if it made it into states
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2])
self.assertTrue(wayp is not None)
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3])
self.assertTrue(wayp is not None)
def test_waypoint_import_bad_json(self):
"""Test importing a bad JSON payload."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
self.send_message(WAYPOINT_TOPIC, waypoints_message, True)
# Check if it made it into states
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2])
self.assertTrue(wayp is None)
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3])
self.assertTrue(wayp is None)
def test_waypoint_import_existing(self):
"""Test importing a zone that exists."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
self.send_message(WAYPOINT_TOPIC, waypoints_message)
# Get the first waypoint exported
wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0])
# Send an update
waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy()
self.send_message(WAYPOINT_TOPIC, waypoints_message)
new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0])
self.assertTrue(wayp == new_wayp)