diff --git a/.coveragerc b/.coveragerc index 7ebab01d399..95816fa55a9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -49,6 +49,7 @@ omit = homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/mpd.py + homeassistant/components/media_player/plex.py homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/sonos.py homeassistant/components/notify/file.py @@ -69,6 +70,7 @@ omit = homeassistant/components/sensor/glances.py homeassistant/components/sensor/mysensors.py homeassistant/components/sensor/openweathermap.py + homeassistant/components/sensor/rest.py homeassistant/components/sensor/rfxtrx.py homeassistant/components/sensor/rpi_gpio.py homeassistant/components/sensor/sabnzbd.py diff --git a/.gitignore b/.gitignore index 881411c54ea..8935ffedc17 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,6 @@ tests/config/home-assistant.log *.sublime-project *.sublime-workspace -# Hide code validator output -pep8.txt -pylint.txt - # Hide some OS X stuff .DS_Store .AppleDouble @@ -30,6 +26,9 @@ Icon .idea +# pytest +.cache + # GITHUB Proposed Python stuff: *.py[cod] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f2fd110a1d..f646766a231 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ For help on building your component, please see the [developer documentation](ht After you finish adding support for your device: - Update the supported devices in the `README.md` file. - - Add any new dependencies to `requirements.txt`. + - Add any new dependencies to `requirements_all.txt`. There is no ordering right now, so just add it to the end. - Update the `.coveragerc` file. - Provide some documentation for [home-assistant.io](https://home-assistant.io/). The documentation is handled in a separate [git repository](https://github.com/balloob/home-assistant.io). - Make sure all your code passes Pylint and flake8 (PEP8 and some more) validation. To generate reports, run `pylint homeassistant > pylint.txt` and `flake8 homeassistant --exclude bower_components,external > flake8.txt`. diff --git a/config/configuration.yaml.example b/config/configuration.yaml.example index 5acca361a30..44153c7876d 100644 --- a/config/configuration.yaml.example +++ b/config/configuration.yaml.example @@ -1,7 +1,9 @@ homeassistant: # Omitted values in this section will be auto detected using freegeoip.net - # Location required to calculate the time the sun rises and sets + # Location required to calculate the time the sun rises and sets. + # Cooridinates are also used for location for weather related components. + # Google Maps can be used to determine more precise GPS cooridinates. latitude: 32.87336 longitude: 117.22743 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/api.py b/homeassistant/components/api.py index 108cc88741b..e4c794df424 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -103,6 +103,10 @@ def _handle_get_api_stream(handler, path_match, data): write_lock = threading.Lock() block = threading.Event() + restrict = data.get('restrict') + if restrict: + restrict = restrict.split(',') + def write_message(payload): """ Writes a message to the output. """ with write_lock: @@ -118,7 +122,8 @@ def _handle_get_api_stream(handler, path_match, data): """ Forwards events to the open request. """ nonlocal gracefully_closed - if block.is_set() or event.event_type == EVENT_TIME_CHANGED: + if block.is_set() or event.event_type == EVENT_TIME_CHANGED or \ + restrict and event.event_type not in restrict: return elif event.event_type == EVENT_HOMEASSISTANT_STOP: gracefully_closed = True diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 45859617624..b734728e59b 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -16,9 +16,9 @@ DOMAIN = 'automation' DEPENDENCIES = ['group'] CONF_ALIAS = 'alias' -CONF_SERVICE = 'execute_service' -CONF_SERVICE_ENTITY_ID = 'service_entity_id' -CONF_SERVICE_DATA = 'service_data' +CONF_SERVICE = 'service' +CONF_SERVICE_ENTITY_ID = 'entity_id' +CONF_SERVICE_DATA = 'data' CONF_CONDITION = 'condition' CONF_ACTION = 'action' @@ -40,25 +40,45 @@ def setup(hass, config): found = 1 while config_key in config: - p_config = _migrate_old_config(config[config_key]) + # check for one block syntax + if isinstance(config[config_key], dict): + config_block = _migrate_old_config(config[config_key]) + name = config_block.get(CONF_ALIAS, config_key) + _setup_automation(hass, config_block, name, config) + + # check for multiple block syntax + elif isinstance(config[config_key], list): + for list_no, config_block in enumerate(config[config_key]): + name = config_block.get(CONF_ALIAS, + "{}, {}".format(config_key, list_no)) + _setup_automation(hass, config_block, name, config) + + # any scalar value is incorrect + else: + _LOGGER.error('Error in config in section %s.', config_key) + found += 1 config_key = "{} {}".format(DOMAIN, found) - name = p_config.get(CONF_ALIAS, config_key) - action = _get_action(hass, p_config.get(CONF_ACTION, {}), name) + return True + + +def _setup_automation(hass, config_block, name, config): + """ Setup one instance of automation """ + + action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) + + if action is None: + return False + + if CONF_CONDITION in config_block or CONF_CONDITION_TYPE in config_block: + action = _process_if(hass, config, config_block, action) if action is None: - continue - - if CONF_CONDITION in p_config or CONF_CONDITION_TYPE in p_config: - action = _process_if(hass, config, p_config, action) - - if action is None: - continue - - _process_trigger(hass, config, p_config.get(CONF_TRIGGER, []), name, - action) + return False + _process_trigger(hass, config, config_block.get(CONF_TRIGGER, []), name, + action) return True @@ -118,7 +138,10 @@ def _migrate_old_config(config): ('trigger', 'state_from', 'from'), ('trigger', 'state_hours', 'hours'), ('trigger', 'state_minutes', 'minutes'), - ('trigger', 'state_seconds', 'seconds')): + ('trigger', 'state_seconds', 'seconds'), + ('action', 'execute_service', 'service'), + ('action', 'service_entity_id', 'entity_id'), + ('action', 'service_data', 'data')): if key in new_conf[cat]: new_conf[cat][new_key] = new_conf[cat].pop(key) diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 22be921f66a..c5b0ee47923 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -20,11 +20,12 @@ def trigger(hass, config, action): _LOGGER.error("Missing configuration key %s", CONF_EVENT_TYPE) return False - event_data = config.get(CONF_EVENT_DATA, {}) + event_data = config.get(CONF_EVENT_DATA) def handle_event(event): """ Listens for events and calls the action when data matches. """ - if event_data == event.data: + if not event_data or all(val == event.data.get(key) for key, val + in event_data.items()): action() hass.bus.listen(event_type, handle_event) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 559832eee80..1d97ccc135d 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -79,11 +79,11 @@ def if_action(hass, config): now = dt_util.now() if before is not None and now > now.replace(hour=before.hour, minute=before.minute): - return False + return False if after is not None and now < now.replace(hour=after.hour, minute=after.minute): - return False + return False if weekday is not None: now_weekday = WEEKDAYS[now.weekday()] diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index beb7a63b47c..f0ce2259dc4 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -33,10 +33,10 @@ def setup(hass, config): # Setup sun if not hass.config.latitude: - hass.config.latitude = '32.87336' + hass.config.latitude = 32.87336 if not hass.config.longitude: - hass.config.longitude = '117.22743' + hass.config.longitude = 117.22743 bootstrap.setup_component(hass, 'sun') @@ -108,7 +108,9 @@ def setup(hass, config): "http://graph.facebook.com/297400035/picture", ATTR_FRIENDLY_NAME: 'Paulus'}) hass.states.set("device_tracker.anne_therese", "not_home", - {ATTR_FRIENDLY_NAME: 'Anne Therese'}) + {ATTR_FRIENDLY_NAME: 'Anne Therese', + 'latitude': hass.config.latitude + 0.002, + 'longitude': hass.config.longitude + 0.002}) hass.states.set("group.all_devices", "home", { diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index d33d182dd2c..27e9417ab5b 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -17,7 +17,12 @@ device_tracker: # New found devices auto found track_new_devices: yes + + # 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 @@ -52,7 +57,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 +65,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' @@ -69,6 +77,8 @@ ATTR_DEV_ID = 'dev_id' ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_GPS = 'gps' +ATTR_GPS_ACCURACY = 'gps_accuracy' +ATTR_BATTERY = 'battery' DISCOVERY_PLATFORMS = { discovery.SERVICE_NETGEAR: 'netgear', @@ -86,7 +96,7 @@ def is_on(hass, entity_id=None): def see(hass, mac=None, dev_id=None, host_name=None, location_name=None, - gps=None): + gps=None, gps_accuracy=None, battery=None): """ Call service to notify you see device. """ data = {key: value for key, value in ((ATTR_MAC, mac), @@ -106,13 +116,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. """ @@ -158,7 +172,7 @@ def setup(hass, config): """ Service to see a device. """ args = {key: value for key, value in call.data.items() if key in (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, - ATTR_GPS)} + ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY)} tracker.see(**args) hass.services.register(DOMAIN, SERVICE_SEE, see_service) @@ -168,12 +182,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: @@ -183,7 +198,7 @@ class DeviceTracker(object): self.group = None def see(self, mac=None, dev_id=None, host_name=None, location_name=None, - gps=None): + gps=None, gps_accuracy=None, battery=None): """ Notify device tracker that you see a device. """ with self.lock: if mac is None and dev_id is None: @@ -198,20 +213,21 @@ class DeviceTracker(object): device = self.devices.get(dev_id) if device: - device.seen(host_name, location_name, gps) + device.seen(host_name, location_name, gps, gps_accuracy, + battery) if device.track: device.update_ha_state() return # 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 - device.seen(host_name, location_name, gps) + device.seen(host_name, location_name, gps, gps_accuracy, battery) if device.track: device.update_ha_state() @@ -239,19 +255,20 @@ 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 _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 +276,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 +292,13 @@ 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. """ + 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): """ Returns the name of the entity. """ @@ -292,8 +318,12 @@ 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] + attr[ATTR_GPS_ACCURACY] = self.gps_accuracy + + if self.battery: + attr[ATTR_BATTERY] = self.battery return attr @@ -302,12 +332,23 @@ 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 = gps + self.gps_accuracy = gps_accuracy + self.battery = battery + 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 +362,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,21 +381,21 @@ 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, False)) + device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) for dev_id, device in load_yaml_config_file(path).items()] diff --git a/homeassistant/components/device_tracker/demo.py b/homeassistant/components/device_tracker/demo.py new file mode 100644 index 00000000000..e8cf906be9e --- /dev/null +++ b/homeassistant/components/device_tracker/demo.py @@ -0,0 +1,50 @@ +""" +homeassistant.components.device_tracker.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Demo platform for the device tracker. + +device_tracker: + platform: demo +""" +import random + +from homeassistant.components.device_tracker import DOMAIN + + +def setup_scanner(hass, config, see): + """ Set up a demo tracker. """ + + def offset(): + """ Return random offset. """ + return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1)) + + def random_see(dev_id, name): + """ Randomize a sighting. """ + see( + dev_id=dev_id, + host_name=name, + gps=(hass.config.latitude + offset(), + hass.config.longitude + offset()), + gps_accuracy=random.randrange(50, 150), + battery=random.randrange(10, 90) + ) + + def observe(call=None): + """ Observe three entities. """ + random_see('demo_paulus', 'Paulus') + random_see('demo_anne_therese', 'Anne Therese') + + observe() + + see( + dev_id='demo_home_boy', + host_name='Home Boy', + gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002], + gps_accuracy=20, + battery=53 + ) + + hass.services.register(DOMAIN, 'demo', observe) + + return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py new file mode 100644 index 00000000000..9ef227909e1 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks.py @@ -0,0 +1,54 @@ +""" +homeassistant.components.device_tracker.owntracks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +OwnTracks platform for the device tracker. + +device_tracker: + platform: owntracks +""" +import json +import logging + +import homeassistant.components.mqtt as mqtt + +DEPENDENCIES = ['mqtt'] + +LOCATION_TOPIC = 'owntracks/+/+' + + +def setup_scanner(hass, config, see): + """ Set up a OwnTracksks tracker. """ + + def owntracks_location_update(topic, payload, qos): + """ MQTT message received. """ + + # Docs on available data: + # http://owntracks.org/booklet/tech/json/#_typelocation + try: + data = json.loads(payload) + except ValueError: + # If invalid JSON + logging.getLogger(__name__).error( + 'Unable to parse payload as JSON: %s', payload) + return + + if data.get('_type') != 'location': + return + + parts = topic.split('/') + kwargs = { + 'dev_id': '{}_{}'.format(parts[1], parts[2]), + 'host_name': parts[1], + 'gps': (data['lat'], data['lon']), + } + if 'acc' in data: + kwargs['gps_accuracy'] = data['acc'] + if 'batt' in data: + kwargs['battery'] = data['batt'] + + see(**kwargs) + + mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) + + return True diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 6a780693f25..450019022e1 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -19,7 +19,7 @@ from homeassistant.const import ( DOMAIN = "discovery" DEPENDENCIES = [] -REQUIREMENTS = ['netdisco==0.4'] +REQUIREMENTS = ['netdisco==0.4.1'] SCAN_INTERVAL = 300 # seconds diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 902b14e38b3..419e48d55b5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,8 @@ _LOGGER = logging.getLogger(__name__) FRONTEND_URLS = [ - URL_ROOT, '/logbook', '/history', '/devService', '/devState', '/devEvent'] + URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState', + '/devEvent'] STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)') diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 7b434017191..2d3bbed8e5b 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "0ab148ece11ddde26b95460c2c91da3d" +VERSION = "7301f590f66ffc5b34d3991fc9eb0247" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 0434c21893b..ccc003f604d 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,6 +1,6 @@ -