Merge pull request #2973 from nma83/owntracks-waypoint-import
Owntracks waypoint import
This commit is contained in:
commit
0943cc78cd
3 changed files with 212 additions and 17 deletions
|
@ -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']
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue