From 68286dcef8e342eeb45affd5b5edd7218198404e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 20 Sep 2015 00:27:50 -0700 Subject: [PATCH 1/4] initial owntracks support --- .../components/device_tracker/owntracks.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 homeassistant/components/device_tracker/owntracks.py diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py new file mode 100644 index 00000000000..46a4c6b1e34 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks.py @@ -0,0 +1,31 @@ +""" +homeassistant.components.device_tracker.owntracks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +OwnTracks platform for the device tracker. + +device_tracker: + platform: owntracks +""" +import json + +import homeassistant.components.mqtt as mqtt + +DEPENDENCIES = ['mqtt'] + +LOCATION_TOPIC = 'owntracks/+/+' + + +def setup_scanner(hass, config, see): + """ Set up a MQTT tracker. """ + + def owntracks_location_update(topic, payload, qos): + """ MQTT message received. """ + parts = topic.split('/') + data = json.loads(payload) + dev_id = '{}_{}'.format(parts[1], parts[2]) + see(dev_id=dev_id, host_name=parts[1], gps=[data['lat'], data['lon']]) + + mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) + + return True From 19d40612e687721d7b582cb3e8c7c5e9ad5c2147 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 20 Sep 2015 09:35:03 -0700 Subject: [PATCH 2/4] Add home_range to device tracker --- homeassistant/bootstrap.py | 12 ++-- .../components/device_tracker/__init__.py | 59 ++++++++++++++----- homeassistant/core.py | 5 ++ homeassistant/util/location.py | 20 +++++++ 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index a7e4dbfdc14..b2e5fa51540 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -297,11 +297,15 @@ def process_ha_core_config(hass, config): else: _LOGGER.error('Received invalid time zone %s', time_zone_str) - for key, attr in ((CONF_LATITUDE, 'latitude'), - (CONF_LONGITUDE, 'longitude'), - (CONF_NAME, 'location_name')): + for key, attr, typ in ((CONF_LATITUDE, 'latitude', float), + (CONF_LONGITUDE, 'longitude', float), + (CONF_NAME, 'location_name', str)): if key in config: - setattr(hac, attr, config[key]) + try: + setattr(hac, attr, typ(config[key])) + except ValueError: + _LOGGER.error('Received invalid %s value for %s: %s', + typ.__name__, key, attr) set_time_zone(config.get(CONF_TIME_ZONE)) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 97c3d769715..bb0ac890d1a 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -17,6 +17,9 @@ device_tracker: # New found devices auto found track_new_devices: yes + + # Maximum distance from home we consider people home + range_home: 100 """ import csv from datetime import timedelta @@ -52,7 +55,7 @@ CONF_TRACK_NEW = "track_new_devices" DEFAULT_CONF_TRACK_NEW = True CONF_CONSIDER_HOME = 'consider_home' -DEFAULT_CONF_CONSIDER_HOME = 180 # seconds +DEFAULT_CONSIDER_HOME = 180 # seconds CONF_SCAN_INTERVAL = "interval_seconds" DEFAULT_SCAN_INTERVAL = 12 @@ -60,6 +63,9 @@ DEFAULT_SCAN_INTERVAL = 12 CONF_AWAY_HIDE = 'hide_if_away' DEFAULT_AWAY_HIDE = False +CONF_HOME_RANGE = 'home_range' +DEFAULT_HOME_RANGE = 100 + SERVICE_SEE = 'see' ATTR_LATITUDE = 'latitude' @@ -106,13 +112,17 @@ def setup(hass, config): os.remove(csv_path) conf = config.get(DOMAIN, {}) - consider_home = util.convert(conf.get(CONF_CONSIDER_HOME), int, - DEFAULT_CONF_CONSIDER_HOME) + consider_home = timedelta( + seconds=util.convert(conf.get(CONF_CONSIDER_HOME), int, + DEFAULT_CONSIDER_HOME)) track_new = util.convert(conf.get(CONF_TRACK_NEW), bool, DEFAULT_CONF_TRACK_NEW) + home_range = util.convert(conf.get(CONF_HOME_RANGE), int, + DEFAULT_HOME_RANGE) - devices = load_config(yaml_path, hass, timedelta(seconds=consider_home)) - tracker = DeviceTracker(hass, consider_home, track_new, devices) + devices = load_config(yaml_path, hass, consider_home, home_range) + tracker = DeviceTracker(hass, consider_home, track_new, home_range, + devices) def setup_platform(p_type, p_config, disc_info=None): """ Setup a device tracker platform. """ @@ -168,12 +178,13 @@ def setup(hass, config): class DeviceTracker(object): """ Track devices """ - def __init__(self, hass, consider_home, track_new, devices): + def __init__(self, hass, consider_home, track_new, home_range, devices): self.hass = hass self.devices = {dev.dev_id: dev for dev in devices} self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} - self.consider_home = timedelta(seconds=consider_home) + self.consider_home = consider_home self.track_new = track_new + self.home_range = home_range self.lock = threading.Lock() for device in devices: @@ -205,8 +216,8 @@ class DeviceTracker(object): # If no device can be found, create it device = Device( - self.hass, self.consider_home, self.track_new, dev_id, mac, - (host_name or dev_id).replace('_', ' ')) + self.hass, self.consider_home, self.home_range, self.track_new, + dev_id, mac, (host_name or dev_id).replace('_', ' ')) self.devices[dev_id] = device if mac is not None: self.mac_to_dev[mac] = device @@ -250,8 +261,8 @@ class Device(Entity): last_update_home = False _state = STATE_NOT_HOME - def __init__(self, hass, consider_home, track, dev_id, mac, name=None, - picture=None, away_hide=False): + def __init__(self, hass, consider_home, home_range, track, dev_id, mac, + name=None, picture=None, away_hide=False): self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -259,6 +270,8 @@ class Device(Entity): # detected anymore. self.consider_home = consider_home + # Distance in meters + self.home_range = home_range # Device ID self.dev_id = dev_id self.mac = mac @@ -273,6 +286,12 @@ class Device(Entity): self.config_picture = picture self.away_hide = away_hide + @property + def gps_home(self): + """ Return if device is within range of home. """ + return (self.gps is not None and + self.hass.config.distance(*self.gps) < self.home_range) + @property def name(self): """ Returns the name of the entity. """ @@ -307,7 +326,15 @@ class Device(Entity): self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name - self.gps = gps + if gps is None: + self.gps = None + else: + try: + self.gps = tuple(float(val) for val in gps) + except ValueError: + _LOGGER.warning('Could not parse gps value for %s: %s', + self.dev_id, gps) + self.gps = None self.update() def stale(self, now=None): @@ -321,6 +348,8 @@ class Device(Entity): return elif self.location_name: self._state = self.location_name + elif self.gps is not None: + self._state = STATE_HOME if self.gps_home else STATE_NOT_HOME elif self.stale(): self._state = STATE_NOT_HOME self.last_update_home = False @@ -338,18 +367,18 @@ def convert_csv_config(csv_path, yaml_path): (util.slugify(row['name']) or DEVICE_DEFAULT_NAME).lower(), used_ids) used_ids.add(dev_id) - device = Device(None, None, row['track'] == '1', dev_id, + device = Device(None, None, None, row['track'] == '1', dev_id, row['device'], row['name'], row['picture']) update_config(yaml_path, dev_id, device) return True -def load_config(path, hass, consider_home): +def load_config(path, hass, consider_home, home_range): """ Load devices from YAML config file. """ if not os.path.isfile(path): return [] return [ - Device(hass, consider_home, device.get('track', False), + Device(hass, consider_home, home_range, device.get('track', False), str(dev_id).lower(), str(device.get('mac')).upper(), device.get('name'), device.get('picture'), device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) diff --git a/homeassistant/core.py b/homeassistant/core.py index df18d7e7902..d0494e070f6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -26,6 +26,7 @@ from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError) import homeassistant.util as util import homeassistant.util.dt as date_util +import homeassistant.util.location as location import homeassistant.helpers.temperature as temp_helper from homeassistant.config import get_default_config_dir @@ -676,6 +677,10 @@ class Config(object): # Directory that holds the configuration self.config_dir = get_default_config_dir() + def distance(self, lat, lon): + """ Calculate distance from Home Assistant in meters. """ + return location.distance(self.latitude, self.longitude, lat, lon) + def path(self, *path): """ Returns path to the file within the config dir. """ return os.path.join(self.config_dir, *path) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 8cc008613cb..acb5deb675b 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -1,5 +1,6 @@ """Module with location helpers.""" import collections +from math import radians, cos, sin, asin, sqrt import requests @@ -28,3 +29,22 @@ def detect_location_info(): 'BS', 'BZ', 'KY', 'PW', 'US', 'AS', 'VI') return LocationInfo(**data) + + +# From: http://stackoverflow.com/a/4913653/646416 +def distance(lon1, lat1, lon2, lat2): + """ + Calculate the great circle distance in meters between two points specified + in decimal degrees on the earth using the Haversine algorithm. + """ + # convert decimal degrees to radians + lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) + + # haversine formula + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 + c = 2 * asin(sqrt(a)) + # Radius of earth in meters. + radius = 6371000 + return c * radius From 0d09e2e1dff385f182017d9a90840822f64544f5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 20 Sep 2015 11:00:35 -0700 Subject: [PATCH 3/4] Attempt to fix CI scripts --- script/lint | 9 +++++---- script/release | 21 +++++++++++++++++++++ script/test | 10 ++++++---- 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100755 script/release diff --git a/script/lint b/script/lint index 05178a20ad8..c8b65f99677 100755 --- a/script/lint +++ b/script/lint @@ -5,14 +5,15 @@ cd "$(dirname "$0")/.." echo "Checking style with flake8..." flake8 --exclude www_static homeassistant -STATUS=$? +FLAKE8_STATUS=$? echo "Checking style with pylint..." pylint homeassistant +PYLINT_STATUS=$? -if [ $STATUS -eq 0 ] +if [ $FLAKE8_STATUS -eq 0 ] then - exit $? + exit $FLAKE8_STATUS else - exit $STATUS + exit $PYLINT_STATUS fi diff --git a/script/release b/script/release new file mode 100755 index 00000000000..40d906b17bf --- /dev/null +++ b/script/release @@ -0,0 +1,21 @@ +# Pushes a new version to PyPi + +cd "$(dirname "$0")/.." + +head -n 3 homeassistant/const.py | tail -n 1 | grep dev + +if [ $? -eq 0 ] +then + echo "Release version should not contain dev tag" + exit 1 +fi + +CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` + +if [ "$CURRENT_BRANCH" != "master" ] +then + echo "You have to be on the master branch to release." + exit 1 +fi + +python3 setup.py sdist bdist_wheel upload diff --git a/script/test b/script/test index 2e13bcc4b0e..d407f57a338 100755 --- a/script/test +++ b/script/test @@ -7,19 +7,21 @@ cd "$(dirname "$0")/.." script/lint -STATUS=$? +LINT_STATUS=$? echo "Running tests..." if [ "$1" = "coverage" ]; then py.test --cov --cov-report= + TEST_STATUS=$? else py.test + TEST_STATUS=$? fi -if [ $STATUS -eq 0 ] +if [ $LINT_STATUS -eq 0 ] then - exit $? + exit $TEST_STATUS else - exit $STATUS + exit $LINT_STATUS fi From 30492cc6852b934f02f50554e30c285737594dd8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 20 Sep 2015 11:46:01 -0700 Subject: [PATCH 4/4] Fix tests and linting --- .../components/device_tracker/__init__.py | 23 ++++++++++++++----- .../components/device_tracker/owntracks.py | 15 ++++++++++-- homeassistant/util/location.py | 8 +++---- tests/components/device_tracker/test_init.py | 14 +++++------ 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index bb0ac890d1a..c62a433f230 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -21,6 +21,8 @@ device_tracker: # Maximum distance from home we consider people home range_home: 100 """ +# pylint: disable=too-many-instance-attributes, too-many-arguments +# pylint: disable=too-many-locals import csv from datetime import timedelta import logging @@ -75,6 +77,7 @@ ATTR_DEV_ID = 'dev_id' ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_GPS = 'gps' +ATTR_BATTERY = 'battery' DISCOVERY_PLATFORMS = { discovery.SERVICE_NETGEAR: 'netgear', @@ -250,12 +253,13 @@ class DeviceTracker(object): class Device(Entity): """ Tracked device. """ - # pylint: disable=too-many-instance-attributes, too-many-arguments host_name = None location_name = None gps = None + gps_accuracy = 0 last_seen = None + battery = None # Track if the last update of this device was HOME last_update_home = False @@ -289,8 +293,9 @@ class Device(Entity): @property def gps_home(self): """ Return if device is within range of home. """ - return (self.gps is not None and - self.hass.config.distance(*self.gps) < self.home_range) + distance = max( + 0, self.hass.config.distance(*self.gps) - self.gps_accuracy) + return self.gps is not None and distance <= self.home_range @property def name(self): @@ -311,8 +316,11 @@ class Device(Entity): attr[ATTR_ENTITY_PICTURE] = self.config_picture if self.gps: - attr[ATTR_LATITUDE] = self.gps[0], - attr[ATTR_LONGITUDE] = self.gps[1], + attr[ATTR_LATITUDE] = self.gps[0] + attr[ATTR_LONGITUDE] = self.gps[1] + + if self.battery: + attr[ATTR_BATTERY] = self.battery return attr @@ -321,11 +329,14 @@ class Device(Entity): """ If device should be hidden. """ return self.away_hide and self.state != STATE_HOME - def seen(self, host_name=None, location_name=None, gps=None): + def seen(self, host_name=None, location_name=None, gps=None, + gps_accuracy=0, battery=None): """ Mark the device as seen. """ self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name + self.gps_accuracy = gps_accuracy + self.battery = battery if gps is None: self.gps = None else: diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 46a4c6b1e34..fdd1fc73b07 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -21,10 +21,21 @@ def setup_scanner(hass, config, see): def owntracks_location_update(topic, payload, qos): """ MQTT message received. """ + + # Docs on available data: + # http://owntracks.org/booklet/tech/json/#_typelocation + parts = topic.split('/') - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + # If invalid JSON + return + if data.get('_type') != 'location': + return dev_id = '{}_{}'.format(parts[1], parts[2]) - see(dev_id=dev_id, host_name=parts[1], gps=[data['lat'], data['lon']]) + see(dev_id=dev_id, host_name=parts[1], gps=(data['lat'], data['lon']), + gps_accuracy=data['acc'], battery=data['batt']) mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index acb5deb675b..ade15131a8f 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -38,13 +38,11 @@ def distance(lon1, lat1, lon2, lat2): in decimal degrees on the earth using the Haversine algorithm. """ # convert decimal degrees to radians - lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) + lon1, lat1, lon2, lat2 = (radians(val) for val in (lon1, lat1, lon2, lat2)) - # haversine formula dlon = lon2 - lon1 dlat = lat2 - lat1 - a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 - c = 2 * asin(sqrt(a)) + angle = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 # Radius of earth in meters. radius = 6371000 - return c * radius + return 2 * radius * asin(sqrt(angle)) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 8b086e97c88..fb368bf863a 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -103,12 +103,12 @@ class TestComponentsDeviceTracker(unittest.TestCase): def test_reading_yaml_config(self): dev_id = 'test' device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, 'AB:CD:EF:GH:IJ', - 'Test name', 'http://test.picture', True) + self.hass, timedelta(seconds=180), 0, True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', 'http://test.picture', True) device_tracker.update_config(self.yaml_devices, dev_id, device) self.assertTrue(device_tracker.setup(self.hass, {})) config = device_tracker.load_config(self.yaml_devices, self.hass, - device.consider_home)[0] + device.consider_home, 0)[0] self.assertEqual(device.dev_id, config.dev_id) self.assertEqual(device.track, config.track) self.assertEqual(device.mac, config.mac) @@ -126,7 +126,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): self.assertTrue(device_tracker.setup(self.hass, { device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}})) config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0))[0] + timedelta(seconds=0), 0)[0] self.assertEqual('dev1', config.dev_id) self.assertEqual(True, config.track) @@ -176,7 +176,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): picture = 'http://placehold.it/200x200' device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, + self.hass, timedelta(seconds=180), 0, True, dev_id, None, friendly_name, picture, away_hide=True) device_tracker.update_config(self.yaml_devices, dev_id, device) @@ -191,7 +191,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): dev_id = 'test_entity' entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, + self.hass, timedelta(seconds=180), 0, True, dev_id, None, away_hide=True) device_tracker.update_config(self.yaml_devices, dev_id, device) @@ -208,7 +208,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): dev_id = 'test_entity' entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, + self.hass, timedelta(seconds=180), 0, True, dev_id, None, away_hide=True) device_tracker.update_config(self.yaml_devices, dev_id, device)