diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index cdb1f90ba8a..abc503a370a 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -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'] diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index db57b387c9f..a7841578e2b 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -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): diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 393b61a3134..57125d6e6ea 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -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)