diff --git a/.coveragerc b/.coveragerc index 9410611536d..7ebab01d399 100644 --- a/.coveragerc +++ b/.coveragerc @@ -46,9 +46,11 @@ omit = homeassistant/components/light/limitlessled.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/denon.py + homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/mpd.py homeassistant/components/media_player/squeezebox.py + homeassistant/components/media_player/sonos.py homeassistant/components/notify/file.py homeassistant/components/notify/instapush.py homeassistant/components/notify/nma.py @@ -60,9 +62,11 @@ omit = homeassistant/components/notify/xmpp.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/bitcoin.py + homeassistant/components/sensor/command_sensor.py homeassistant/components/sensor/dht.py homeassistant/components/sensor/efergy.py homeassistant/components/sensor/forecast.py + homeassistant/components/sensor/glances.py homeassistant/components/sensor/mysensors.py homeassistant/components/sensor/openweathermap.py homeassistant/components/sensor/rfxtrx.py @@ -73,6 +77,7 @@ omit = homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py homeassistant/components/sensor/transmission.py + homeassistant/components/switch/arest.py homeassistant/components/switch/command_switch.py homeassistant/components/switch/edimax.py homeassistant/components/switch/hikvisioncam.py diff --git a/README.md b/README.md index 26bb0b998f5..6b1b1353392 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Examples of devices it can interface it: * Monitoring connected devices to a wireless router: [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), [DD-WRT](http://www.dd-wrt.com/site/index), [TPLink](http://www.tp-link.us/), and [ASUSWRT](http://event.asus.com/2013/nw/ASUSWRT/) * [Philips Hue](http://meethue.com) lights, [WeMo](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) switches, [Edimax](http://www.edimax.com/) switches, [Efergy](https://efergy.com) energy monitoring, RFXtrx sensors, and [Tellstick](http://www.telldus.se/products/tellstick) devices and sensors - * [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), and [Kodi (XBMC)](http://kodi.tv/) + * [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), [Kodi (XBMC)](http://kodi.tv/), and iTunes (by way of [itunes-api](https://github.com/maddox/itunes-api)) * Support for [ISY994](https://www.universal-devices.com/residential/isy994i-series/) (Insteon and X10 devices), [Z-Wave](http://www.z-wave.com/), [Nest Thermostats](https://nest.com/), [Arduino](https://www.arduino.cc/), [Raspberry Pi](https://www.raspberrypi.org/), and [Modbus](http://www.modbus.org/) * Integrate data from the [Bitcoin](https://bitcoin.org) network, meteorological data from [OpenWeatherMap](http://openweathermap.org/) and [Forecast.io](https://forecast.io/), [Transmission](http://www.transmissionbt.com/), or [SABnzbd](http://sabnzbd.org). * [See full list of supported devices](https://home-assistant.io/components/) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2641961f5c3..428845031a5 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -95,6 +95,14 @@ def get_arguments(): type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--install-osx', + action='store_true', + help='Installs as a service on OS X and loads on boot.') + parser.add_argument( + '--uninstall-osx', + action='store_true', + help='Uninstalls from OS X.') if os.name != "nt": parser.add_argument( '--daemon', @@ -152,6 +160,46 @@ def write_pid(pid_file): sys.exit(1) +def install_osx(): + """ Setup to run via launchd on OS X """ + with os.popen('which hass') as inp: + hass_path = inp.read().strip() + + with os.popen('whoami') as inp: + user = inp.read().strip() + + cwd = os.path.dirname(__file__) + template_path = os.path.join(cwd, 'startup', 'launchd.plist') + + with open(template_path, 'r', encoding='utf-8') as inp: + plist = inp.read() + + plist = plist.replace("$HASS_PATH$", hass_path) + plist = plist.replace("$USER$", user) + + path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") + + try: + with open(path, 'w', encoding='utf-8') as outp: + outp.write(plist) + except IOError as err: + print('Unable to write to ' + path, err) + return + + os.popen('launchctl load -w -F ' + path) + + print("Home Assistant has been installed. \ + Open it here: http://localhost:8123") + + +def uninstall_osx(): + """ Unload from launchd on OS X """ + path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") + os.popen('launchctl unload ' + path) + + print("Home Assistant has been uninstalled.") + + def main(): """ Starts Home Assistant. """ validate_python() @@ -161,6 +209,14 @@ def main(): config_dir = os.path.join(os.getcwd(), args.config) ensure_config_path(config_dir) + # os x launchd functions + if args.install_osx: + install_osx() + return + if args.uninstall_osx: + uninstall_osx() + return + # daemon functions if args.pid_file: check_pid(args.pid_file) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index ca74f086632..a7e4dbfdc14 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -123,6 +123,7 @@ def prepare_setup_platform(hass, config, domain, platform_name): # Not found if platform is None: + _LOGGER.error('Unable to find platform %s', platform_path) return None # Already loaded diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8dcb158dea4..a89afeb8c21 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -9,7 +9,8 @@ import logging from homeassistant.bootstrap import prepare_setup_platform from homeassistant.helpers import config_per_platform from homeassistant.util import split_entity_id -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.components import logbook DOMAIN = "automation" @@ -19,6 +20,7 @@ CONF_ALIAS = "alias" CONF_SERVICE = "execute_service" CONF_SERVICE_ENTITY_ID = "service_entity_id" CONF_SERVICE_DATA = "service_data" +CONF_IF = "if" _LOGGER = logging.getLogger(__name__) @@ -34,7 +36,15 @@ def setup(hass, config): _LOGGER.error("Unknown automation platform specified: %s", p_type) continue - if platform.register(hass, p_config, _get_action(hass, p_config)): + action = _get_action(hass, p_config) + + if action is None: + return + + if CONF_IF in p_config: + action = _process_if(hass, config, p_config[CONF_IF], action) + + if platform.trigger(hass, p_config, action): _LOGGER.info( "Initialized %s rule %s", p_type, p_config.get(CONF_ALIAS, "")) success = True @@ -48,27 +58,59 @@ def setup(hass, config): def _get_action(hass, config): """ Return an action based on a config. """ + name = config.get(CONF_ALIAS, 'Unnamed automation') + + if CONF_SERVICE not in config: + _LOGGER.error('Error setting up %s, no action specified.', + name) + return + def action(): """ Action to be executed. """ - _LOGGER.info("Executing rule %s", config.get(CONF_ALIAS, "")) + _LOGGER.info('Executing %s', name) + logbook.log_entry(hass, name, 'has been triggered', DOMAIN) - if CONF_SERVICE in config: - domain, service = split_entity_id(config[CONF_SERVICE]) + domain, service = split_entity_id(config[CONF_SERVICE]) - service_data = config.get(CONF_SERVICE_DATA, {}) + service_data = config.get(CONF_SERVICE_DATA, {}) - if not isinstance(service_data, dict): - _LOGGER.error("%s should be a dictionary", CONF_SERVICE_DATA) - service_data = {} + if not isinstance(service_data, dict): + _LOGGER.error("%s should be a dictionary", CONF_SERVICE_DATA) + service_data = {} - if CONF_SERVICE_ENTITY_ID in config: - try: - service_data[ATTR_ENTITY_ID] = \ - config[CONF_SERVICE_ENTITY_ID].split(",") - except AttributeError: - service_data[ATTR_ENTITY_ID] = \ - config[CONF_SERVICE_ENTITY_ID] + if CONF_SERVICE_ENTITY_ID in config: + try: + service_data[ATTR_ENTITY_ID] = \ + config[CONF_SERVICE_ENTITY_ID].split(",") + except AttributeError: + service_data[ATTR_ENTITY_ID] = \ + config[CONF_SERVICE_ENTITY_ID] - hass.services.call(domain, service, service_data) + hass.services.call(domain, service, service_data) + + return action + + +def _process_if(hass, config, if_configs, action): + """ Processes if checks. """ + + if isinstance(if_configs, dict): + if_configs = [if_configs] + + for if_config in if_configs: + p_type = if_config.get(CONF_PLATFORM) + if p_type is None: + _LOGGER.error("No platform defined found for if-statement %s", + if_config) + continue + + platform = prepare_setup_platform(hass, config, DOMAIN, p_type) + + if platform is None or not hasattr(platform, 'if_action'): + _LOGGER.error("Unsupported if-statement platform specified: %s", + p_type) + continue + + action = platform.if_action(hass, if_config, action) return action diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 8a78f20d485..22be921f66a 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -12,7 +12,7 @@ CONF_EVENT_DATA = "event_data" _LOGGER = logging.getLogger(__name__) -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for events based on config. """ event_type = config.get(CONF_EVENT_TYPE) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 6b4e6b1e039..7004b919c72 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -14,7 +14,7 @@ CONF_TOPIC = 'mqtt_topic' CONF_PAYLOAD = 'mqtt_payload' -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for state changes based on `config`. """ topic = config.get(CONF_TOPIC) payload = config.get(CONF_PAYLOAD) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py new file mode 100644 index 00000000000..417ffffff7d --- /dev/null +++ b/homeassistant/components/automation/numeric_state.py @@ -0,0 +1,93 @@ +""" +homeassistant.components.automation.numeric_state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers numeric state listening automation rules. +""" +import logging + +from homeassistant.helpers.event import track_state_change + + +CONF_ENTITY_ID = "state_entity_id" +CONF_BELOW = "state_below" +CONF_ABOVE = "state_above" + +_LOGGER = logging.getLogger(__name__) + + +def trigger(hass, config, action): + """ Listen for state changes based on `config`. """ + entity_id = config.get(CONF_ENTITY_ID) + + if entity_id is None: + _LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID) + return False + + below = config.get(CONF_BELOW) + above = config.get(CONF_ABOVE) + + if below is None and above is None: + _LOGGER.error("Missing configuration key." + " One of %s or %s is required", + CONF_BELOW, CONF_ABOVE) + return False + + # pylint: disable=unused-argument + def state_automation_listener(entity, from_s, to_s): + """ Listens for state changes and calls action. """ + + # Fire action if we go from outside range into range + if _in_range(to_s.state, above, below) and \ + (from_s is None or not _in_range(from_s.state, above, below)): + action() + + track_state_change( + hass, entity_id, state_automation_listener) + + return True + + +def if_action(hass, config, action): + """ Wraps action method with state based condition. """ + + entity_id = config.get(CONF_ENTITY_ID) + + if entity_id is None: + _LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID) + return action + + below = config.get(CONF_BELOW) + above = config.get(CONF_ABOVE) + + if below is None and above is None: + _LOGGER.error("Missing configuration key." + " One of %s or %s is required", + CONF_BELOW, CONF_ABOVE) + return action + + def state_if(): + """ Execute action if state matches. """ + + state = hass.states.get(entity_id) + if state is None or _in_range(state.state, above, below): + action() + + return state_if + + +def _in_range(value, range_start, range_end): + """ Checks if value is inside the range """ + + try: + value = float(value) + except ValueError: + _LOGGER.warn("Missing value in numeric check") + return False + + if range_start is not None and range_end is not None: + return float(range_start) <= value < float(range_end) + elif range_end is not None: + return value < float(range_end) + else: + return float(range_start) <= value diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index ba96debf9ac..d336fcaa3d7 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -13,15 +13,16 @@ from homeassistant.const import MATCH_ALL CONF_ENTITY_ID = "state_entity_id" CONF_FROM = "state_from" CONF_TO = "state_to" +CONF_STATE = "state" -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for state changes based on `config`. """ entity_id = config.get(CONF_ENTITY_ID) if entity_id is None: logging.getLogger(__name__).error( - "Missing configuration key %s", CONF_ENTITY_ID) + "Missing trigger configuration key %s", CONF_ENTITY_ID) return False from_state = config.get(CONF_FROM, MATCH_ALL) @@ -35,3 +36,22 @@ def register(hass, config, action): hass, entity_id, state_automation_listener, from_state, to_state) return True + + +def if_action(hass, config, action): + """ Wraps action method with state based condition. """ + entity_id = config.get(CONF_ENTITY_ID) + state = config.get(CONF_STATE) + + if entity_id is None or state is None: + logging.getLogger(__name__).error( + "Missing if-condition configuration key %s or %s", CONF_ENTITY_ID, + CONF_STATE) + return action + + def state_if(): + """ Execute action if state matches. """ + if hass.states.is_state(entity_id, state): + action() + + return state_if diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 77bd40a7a41..b97f3e2f7f5 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -4,15 +4,23 @@ homeassistant.components.automation.time Offers time listening automation rules. """ +import logging + from homeassistant.util import convert +import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_time_change CONF_HOURS = "time_hours" CONF_MINUTES = "time_minutes" CONF_SECONDS = "time_seconds" +CONF_BEFORE = "before" +CONF_AFTER = "after" +CONF_WEEKDAY = "weekday" + +WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for state changes based on `config`. """ hours = convert(config.get(CONF_HOURS), int) minutes = convert(config.get(CONF_MINUTES), int) @@ -26,3 +34,49 @@ def register(hass, config, action): hour=hours, minute=minutes, second=seconds) return True + + +def if_action(hass, config, action): + """ Wraps action method with time based condition. """ + before = config.get(CONF_BEFORE) + after = config.get(CONF_AFTER) + weekday = config.get(CONF_WEEKDAY) + + if before is None and after is None and weekday is None: + logging.getLogger(__name__).error( + "Missing if-condition configuration key %s, %s or %s", + CONF_BEFORE, CONF_AFTER, CONF_WEEKDAY) + + def time_if(): + """ Validate time based if-condition """ + now = dt_util.now() + + if before is not None: + # Strip seconds if given + before_h, before_m = before.split(':')[0:2] + + before_point = now.replace(hour=int(before_h), + minute=int(before_m)) + + if now > before_point: + return + + if after is not None: + # Strip seconds if given + after_h, after_m = after.split(':')[0:2] + + after_point = now.replace(hour=int(after_h), minute=int(after_m)) + + if now < after_point: + return + + if weekday is not None: + now_weekday = WEEKDAYS[now.weekday()] + + if isinstance(weekday, str) and weekday != now_weekday or \ + now_weekday not in weekday: + return + + action() + + return time_if diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 5dc91b28370..beb7a63b47c 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -17,7 +17,7 @@ DOMAIN = "demo" DEPENDENCIES = ['introduction', 'conversation'] COMPONENTS_WITH_DEMO_PLATFORM = [ - 'switch', 'light', 'thermostat', 'sensor', 'media_player', 'notify'] + 'switch', 'light', 'sensor', 'thermostat', 'media_player', 'notify'] def setup(hass, config): diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index fd706b3d73a..c7dc2593ddb 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,52 +1,82 @@ """ -homeassistant.components.tracker -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +homeassistant.components.device_tracker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to keep track of devices. + +device_tracker: + platform: netgear + + # Optional + + # How many seconds to wait after not seeing device to consider it not home + consider_home: 180 + + # Seconds between each scan + interval_seconds: 12 + + # New found devices auto found + track_new_devices: yes """ -import logging -import threading -import os import csv from datetime import timedelta +import logging +import os +import threading -from homeassistant.helpers import validate_config -from homeassistant.helpers.entity import _OVERWRITE +from homeassistant.bootstrap import prepare_setup_platform +from homeassistant.components import discovery, group +from homeassistant.config import load_yaml_config_file +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_per_platform +from homeassistant.helpers.entity import Entity import homeassistant.util as util import homeassistant.util.dt as dt_util -from homeassistant.bootstrap import prepare_setup_platform from homeassistant.helpers.event import track_utc_time_change from homeassistant.const import ( - STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, - CONF_PLATFORM, DEVICE_DEFAULT_NAME) -from homeassistant.components import group + ATTR_ENTITY_PICTURE, DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME) DOMAIN = "device_tracker" DEPENDENCIES = [] -SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv" - GROUP_NAME_ALL_DEVICES = 'all devices' ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') ENTITY_ID_FORMAT = DOMAIN + '.{}' -# After how much time do we consider a device not home if -# it does not show up on scans -TIME_DEVICE_NOT_FOUND = timedelta(minutes=3) +CSV_DEVICES = "known_devices.csv" +YAML_DEVICES = 'known_devices.yaml' -# Filename to save known devices to -KNOWN_DEVICES_FILE = "known_devices.csv" +CONF_TRACK_NEW = "track_new_devices" +DEFAULT_CONF_TRACK_NEW = True -CONF_SECONDS = "interval_seconds" +CONF_CONSIDER_HOME = 'consider_home' +DEFAULT_CONF_CONSIDER_HOME = 180 # seconds -DEFAULT_CONF_SECONDS = 12 +CONF_SCAN_INTERVAL = "interval_seconds" +DEFAULT_SCAN_INTERVAL = 12 -TRACK_NEW_DEVICES = "track_new_devices" +CONF_AWAY_HIDE = 'hide_if_away' +DEFAULT_AWAY_HIDE = False +SERVICE_SEE = 'see' + +ATTR_LATITUDE = 'latitude' +ATTR_LONGITUDE = 'longitude' +ATTR_MAC = 'mac' +ATTR_DEV_ID = 'dev_id' +ATTR_HOST_NAME = 'host_name' +ATTR_LOCATION_NAME = 'location_name' +ATTR_GPS = 'gps' + +DISCOVERY_PLATFORMS = { + discovery.SERVICE_NETGEAR: 'netgear', +} _LOGGER = logging.getLogger(__name__) +# pylint: disable=too-many-arguments + def is_on(hass, entity_id=None): """ Returns if any or specified device is home. """ @@ -55,293 +85,309 @@ def is_on(hass, entity_id=None): return hass.states.is_state(entity, STATE_HOME) +def see(hass, mac=None, dev_id=None, host_name=None, location_name=None, + gps=None): + """ Call service to notify you see device. """ + data = {key: value for key, value in + ((ATTR_MAC, mac), + (ATTR_DEV_ID, dev_id), + (ATTR_HOST_NAME, host_name), + (ATTR_LOCATION_NAME, location_name), + (ATTR_GPS, gps)) if value is not None} + hass.services.call(DOMAIN, SERVICE_SEE, data) + + def setup(hass, config): - """ Sets up the device tracker. """ + """ Setup device tracker """ + yaml_path = hass.config.path(YAML_DEVICES) + csv_path = hass.config.path(CSV_DEVICES) + if os.path.isfile(csv_path) and not os.path.isfile(yaml_path) and \ + convert_csv_config(csv_path, yaml_path): + os.remove(csv_path) - if not validate_config(config, {DOMAIN: [CONF_PLATFORM]}, _LOGGER): - return False + conf = config.get(DOMAIN, {}) + consider_home = util.convert(conf.get(CONF_CONSIDER_HOME), int, + DEFAULT_CONF_CONSIDER_HOME) + track_new = util.convert(conf.get(CONF_TRACK_NEW), bool, + DEFAULT_CONF_TRACK_NEW) - tracker_type = config[DOMAIN].get(CONF_PLATFORM) + devices = load_config(yaml_path, hass, timedelta(seconds=consider_home)) + tracker = DeviceTracker(hass, consider_home, track_new, devices) - tracker_implementation = \ - prepare_setup_platform(hass, config, DOMAIN, tracker_type) - - if tracker_implementation is None: - _LOGGER.error("Unknown device_tracker type specified: %s.", - tracker_type) - - return False - - device_scanner = tracker_implementation.get_scanner(hass, config) - - if device_scanner is None: - _LOGGER.error("Failed to initialize device scanner: %s", - tracker_type) - - return False - - seconds = util.convert(config[DOMAIN].get(CONF_SECONDS), int, - DEFAULT_CONF_SECONDS) - - track_new_devices = config[DOMAIN].get(TRACK_NEW_DEVICES) or False - _LOGGER.info("Tracking new devices: %s", track_new_devices) - - tracker = DeviceTracker(hass, device_scanner, seconds, track_new_devices) - - # We only succeeded if we got to parse the known devices file - return not tracker.invalid_known_devices_file - - -class DeviceTracker(object): - """ Class that tracks which devices are home and which are not. """ - - def __init__(self, hass, device_scanner, seconds, track_new_devices): - self.hass = hass - - self.device_scanner = device_scanner - - self.lock = threading.Lock() - - # Do we track new devices by default? - self.track_new_devices = track_new_devices - - # Dictionary to keep track of known devices and devices we track - self.tracked = {} - self.untracked_devices = set() - - # Did we encounter an invalid known devices file - self.invalid_known_devices_file = False - - # Wrap it in a func instead of lambda so it can be identified in - # the bus by its __name__ attribute. - def update_device_state(now): - """ Triggers update of the device states. """ - self.update_devices(now) - - dev_group = group.Group( - hass, GROUP_NAME_ALL_DEVICES, user_defined=False) - - def reload_known_devices_service(service): - """ Reload known devices file. """ - self._read_known_devices_file() - - self.update_devices(dt_util.utcnow()) - - dev_group.update_tracked_entity_ids(self.device_entity_ids) - - reload_known_devices_service(None) - - if self.invalid_known_devices_file: - return - - seconds = range(0, 60, seconds) - - _LOGGER.info("Device tracker interval second=%s", seconds) - track_utc_time_change(hass, update_device_state, second=seconds) - - hass.services.register(DOMAIN, - SERVICE_DEVICE_TRACKER_RELOAD, - reload_known_devices_service) - - @property - def device_entity_ids(self): - """ Returns a set containing all device entity ids - that are being tracked. """ - return set(device['entity_id'] for device in self.tracked.values()) - - def _update_state(self, now, device, is_home): - """ Update the state of a device. """ - dev_info = self.tracked[device] - - if is_home: - # Update last seen if at home - dev_info['last_seen'] = now - else: - # State remains at home if it has been seen in the last - # TIME_DEVICE_NOT_FOUND - is_home = now - dev_info['last_seen'] < TIME_DEVICE_NOT_FOUND - - state = STATE_HOME if is_home else STATE_NOT_HOME - - # overwrite properties that have been set in the config file - attr = dict(dev_info['state_attr']) - attr.update(_OVERWRITE.get(dev_info['entity_id'], {})) - - self.hass.states.set( - dev_info['entity_id'], state, attr) - - def update_devices(self, now): - """ Update device states based on the found devices. """ - if not self.lock.acquire(False): + def setup_platform(p_type, p_config, disc_info=None): + """ Setup a device tracker platform. """ + platform = prepare_setup_platform(hass, config, DOMAIN, p_type) + if platform is None: return try: - found_devices = set(dev.upper() for dev in - self.device_scanner.scan_devices()) + if hasattr(platform, 'get_scanner'): + scanner = platform.get_scanner(hass, {DOMAIN: p_config}) - for device in self.tracked: - is_home = device in found_devices + if scanner is None: + _LOGGER.error('Error setting up platform %s', p_type) + return - self._update_state(now, device, is_home) + setup_scanner_platform(hass, p_config, scanner, tracker.see) + return - if is_home: - found_devices.remove(device) + if not platform.setup_scanner(hass, p_config, tracker.see): + _LOGGER.error('Error setting up platform %s', p_type) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error setting up platform %s', p_type) - # Did we find any devices that we didn't know about yet? - new_devices = found_devices - self.untracked_devices + for p_type, p_config in \ + config_per_platform(config, DOMAIN, _LOGGER): + setup_platform(p_type, p_config) - if new_devices: - if not self.track_new_devices: - self.untracked_devices.update(new_devices) + def device_tracker_discovered(service, info): + """ Called when a device tracker platform is discovered. """ + setup_platform(DISCOVERY_PLATFORMS[service], {}, info) - self._update_known_devices_file(new_devices) - finally: - self.lock.release() + discovery.listen(hass, DISCOVERY_PLATFORMS.keys(), + device_tracker_discovered) - # pylint: disable=too-many-branches - def _read_known_devices_file(self): - """ Parse and process the known devices file. """ - known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) + def update_stale(now): + """ Clean up stale devices. """ + tracker.update_stale(now) + track_utc_time_change(hass, update_stale, second=range(0, 60, 5)) - # Return if no known devices file exists - if not os.path.isfile(known_dev_path): + tracker.setup_group() + + def see_service(call): + """ 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)} + tracker.see(**args) + + hass.services.register(DOMAIN, SERVICE_SEE, see_service) + + return True + + +class DeviceTracker(object): + """ Track devices """ + def __init__(self, hass, consider_home, track_new, 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.track_new = track_new + self.lock = threading.Lock() + + entity_ids = [] + for device in devices: + if device.track: + entity_ids.append(device.entity_id) + device.update_ha_state() + + self.group = None + + def see(self, mac=None, dev_id=None, host_name=None, location_name=None, + gps=None): + """ Notify device tracker that you see a device. """ + with self.lock: + if mac is None and dev_id is None: + raise HomeAssistantError('Neither mac or device id passed in') + elif mac is not None: + mac = mac.upper() + device = self.mac_to_dev.get(mac) + if not device: + dev_id = util.slugify(host_name or mac) + else: + dev_id = str(dev_id) + device = self.devices.get(dev_id) + + if device: + device.seen(host_name, location_name, gps) + 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.devices[dev_id] = device + if mac is not None: + self.mac_to_dev[mac] = device + + device.seen(host_name, location_name, gps) + if device.track: + device.update_ha_state() + + # During init, we ignore the group + if self.group is not None: + self.group.update_tracked_entity_ids( + list(self.group.tracking) + [device.entity_id]) + update_config(self.hass.config.path(YAML_DEVICES), dev_id, device) + + def setup_group(self): + """ Initializes group for all tracked devices. """ + entity_ids = (dev.entity_id for dev in self.devices.values() + if dev.track) + self.group = group.setup_group( + self.hass, GROUP_NAME_ALL_DEVICES, entity_ids, False) + + def update_stale(self, now): + """ Update stale devices. """ + with self.lock: + for device in self.devices.values(): + if device.last_update_home and device.stale(now): + device.update_ha_state(True) + + +class Device(Entity): + """ Tracked device. """ + # pylint: disable=too-many-instance-attributes, too-many-arguments + + host_name = None + location_name = None + gps = None + last_seen = 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): + self.hass = hass + self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + + # Timedelta object how long we consider a device home if it is not + # detected anymore. + self.consider_home = consider_home + + # Device ID + self.dev_id = dev_id + self.mac = mac + + # If we should track this device + self.track = track + + # Configured name + self.config_name = name + + # Configured picture + self.config_picture = picture + self.away_hide = away_hide + + @property + def name(self): + """ Returns the name of the entity. """ + return self.config_name or self.host_name or DEVICE_DEFAULT_NAME + + @property + def state(self): + """ State of the device. """ + return self._state + + @property + def state_attributes(self): + """ Device state attributes. """ + attr = {} + + if self.config_picture: + attr[ATTR_ENTITY_PICTURE] = self.config_picture + + if self.gps: + attr[ATTR_LATITUDE] = self.gps[0], + attr[ATTR_LONGITUDE] = self.gps[1], + + return attr + + @property + def hidden(self): + """ 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): + """ 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.update() + + def stale(self, now=None): + """ Return if device state is stale. """ + return self.last_seen and \ + (now or dt_util.utcnow()) - self.last_seen > self.consider_home + + def update(self): + """ Update state of entity. """ + if not self.last_seen: return + elif self.location_name: + self._state = self.location_name + elif self.stale(): + self._state = STATE_NOT_HOME + self.last_update_home = False + else: + self._state = STATE_HOME + self.last_update_home = True - self.lock.acquire() - self.untracked_devices.clear() +def convert_csv_config(csv_path, yaml_path): + """ Convert CSV config file format to YAML. """ + used_ids = set() + with open(csv_path) as inp: + for row in csv.DictReader(inp): + dev_id = util.ensure_unique_string( + util.slugify(row['name']) or DEVICE_DEFAULT_NAME, used_ids) + used_ids.add(dev_id) + device = Device(None, None, row['track'] == '1', dev_id, + row['device'], row['name'], row['picture']) + update_config(yaml_path, dev_id, device) + return True - with open(known_dev_path) as inp: - # To track which devices need an entity_id assigned - need_entity_id = [] +def load_config(path, hass, consider_home): + """ Load devices from YAML config file. """ + if not os.path.isfile(path): + return [] + return [ + Device(hass, consider_home, device.get('track', False), + str(dev_id), device.get('mac'), device.get('name'), + device.get('picture'), device.get(CONF_AWAY_HIDE, False)) + for dev_id, device in load_yaml_config_file(path).items()] - # All devices that are still in this set after we read the CSV file - # have been removed from the file and thus need to be cleaned up. - removed_devices = set(self.tracked.keys()) - try: - for row in csv.DictReader(inp): - device = row['device'].upper() +def setup_scanner_platform(hass, config, scanner, see_device): + """ Helper method to connect scanner-based platform to device tracker. """ + interval = util.convert(config.get(CONF_SCAN_INTERVAL), int, + DEFAULT_SCAN_INTERVAL) - if row['track'] == '1': - if device in self.tracked: - # Device exists - removed_devices.remove(device) - else: - # We found a new device - need_entity_id.append(device) + # Initial scan of each mac we also tell about host name for config + seen = set() - self._track_device(device, row['name']) + def device_tracker_scan(now): + """ Called when interval matches. """ + for mac in scanner.scan_devices(): + if mac in seen: + host_name = None + else: + host_name = scanner.get_device_name(mac) + seen.add(mac) + see_device(mac=mac, host_name=host_name) - # Update state_attr with latest from file - state_attr = { - ATTR_FRIENDLY_NAME: row['name'] - } + track_utc_time_change(hass, device_tracker_scan, second=range(0, 60, + interval)) - if row['picture']: - state_attr[ATTR_ENTITY_PICTURE] = row['picture'] + device_tracker_scan(None) - self.tracked[device]['state_attr'] = state_attr - else: - self.untracked_devices.add(device) +def update_config(path, dev_id, device): + """ Add device to YAML config file. """ + with open(path, 'a') as out: + out.write('\n') + out.write('{}:\n'.format(device.dev_id)) - # Remove existing devices that we no longer track - for device in removed_devices: - entity_id = self.tracked[device]['entity_id'] - - _LOGGER.info("Removing entity %s", entity_id) - - self.hass.states.remove(entity_id) - - self.tracked.pop(device) - - self._generate_entity_ids(need_entity_id) - - if not self.tracked: - _LOGGER.warning( - "No devices to track. Please update %s.", - known_dev_path) - - _LOGGER.info("Loaded devices from %s", known_dev_path) - - except KeyError: - self.invalid_known_devices_file = True - - _LOGGER.warning( - ("Invalid known devices file: %s. " - "We won't update it with new found devices."), - known_dev_path) - - finally: - self.lock.release() - - def _update_known_devices_file(self, new_devices): - """ Add new devices to known devices file. """ - if not self.invalid_known_devices_file: - known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) - - try: - # If file does not exist we will write the header too - is_new_file = not os.path.isfile(known_dev_path) - - with open(known_dev_path, 'a') as outp: - _LOGGER.info("Found %d new devices, updating %s", - len(new_devices), known_dev_path) - - writer = csv.writer(outp) - - if is_new_file: - writer.writerow(("device", "name", "track", "picture")) - - for device in new_devices: - # See if the device scanner knows the name - # else defaults to unknown device - name = self.device_scanner.get_device_name(device) or \ - DEVICE_DEFAULT_NAME - - track = 0 - if self.track_new_devices: - self._track_device(device, name) - track = 1 - - writer.writerow((device, name, track, "")) - - if self.track_new_devices: - self._generate_entity_ids(new_devices) - - except IOError: - _LOGGER.exception("Error updating %s with %d new devices", - known_dev_path, len(new_devices)) - - def _track_device(self, device, name): - """ - Add a device to the list of tracked devices. - Does not generate the entity id yet. - """ - default_last_seen = dt_util.utcnow().replace(year=1990) - - self.tracked[device] = { - 'name': name, - 'last_seen': default_last_seen, - 'state_attr': {ATTR_FRIENDLY_NAME: name} - } - - def _generate_entity_ids(self, need_entity_id): - """ Generate entity ids for a list of devices. """ - # Setup entity_ids for the new devices - used_entity_ids = [info['entity_id'] for device, info - in self.tracked.items() - if device not in need_entity_id] - - for device in need_entity_id: - name = self.tracked[device]['name'] - - entity_id = util.ensure_unique_string( - ENTITY_ID_FORMAT.format(util.slugify(name)), - used_entity_ids) - - used_entity_ids.append(entity_id) - - self.tracked[device]['entity_id'] = entity_id + for key, value in (('name', device.name), ('mac', device.mac), + ('picture', device.config_picture), + ('track', 'yes' if device.track else 'no'), + (CONF_AWAY_HIDE, + 'yes' if device.away_hide else 'no')): + out.write(' {}: {}\n'.format(key, '' if value is None else value)) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index c0b29ab420f..1e3ac20b6f2 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -157,11 +157,19 @@ class AsusWrtDeviceScanner(object): devices = {} for lease in leases_result: match = _LEASES_REGEX.search(lease.decode('utf-8')) + + # For leases where the client doesn't set a hostname, ensure + # it is blank and not '*', which breaks the entity_id down + # the line + host = match.group('host') + if host == '*': + host = '' + devices[match.group('ip')] = { + 'host': host, + 'status': '', 'ip': match.group('ip'), 'mac': match.group('mac').upper(), - 'host': match.group('host'), - 'status': '' } for neighbor in neighbors: diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py new file mode 100644 index 00000000000..34cee8f6733 --- /dev/null +++ b/homeassistant/components/device_tracker/mqtt.py @@ -0,0 +1,48 @@ +""" +homeassistant.components.device_tracker.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +MQTT platform for the device tracker. + +device_tracker: + platform: mqtt + qos: 1 + devices: + paulus_oneplus: /location/paulus + annetherese_n4: /location/annetherese +""" +import logging +from homeassistant import util +import homeassistant.components.mqtt as mqtt + +DEPENDENCIES = ['mqtt'] + +CONF_QOS = 'qos' +CONF_DEVICES = 'devices' + +DEFAULT_QOS = 0 + +_LOGGER = logging.getLogger(__name__) + + +def setup_scanner(hass, config, see): + """ Set up a MQTT tracker. """ + devices = config.get(CONF_DEVICES) + qos = util.convert(config.get(CONF_QOS), int, DEFAULT_QOS) + + if not isinstance(devices, dict): + _LOGGER.error('Expected %s to be a dict, found %s', CONF_DEVICES, + devices) + return False + + dev_id_lookup = {} + + def device_tracker_message_received(topic, payload, qos): + """ MQTT message received. """ + see(dev_id=dev_id_lookup[topic], location_name=payload) + + for dev_id, topic in devices.items(): + dev_id_lookup[topic] = dev_id + mqtt.subscribe(hass, topic, device_tracker_message_received, qos) + + return True diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 88fd7aed78a..46c515dcb1f 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -70,7 +70,6 @@ class NetgearDeviceScanner(object): self.lock = threading.Lock() if host is None: - print("BIER") self._api = pynetgear.Netgear() elif username is None: self._api = pynetgear.Netgear(password, host) diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 5c619e001a3..8d9c2e72c20 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -44,7 +44,7 @@ _LOGGER = logging.getLogger(__name__) # interval in minutes to exclude devices from a scan while they are home CONF_HOME_INTERVAL = "home_interval" -REQUIREMENTS = ['python-nmap==0.4.1'] +REQUIREMENTS = ['python-nmap==0.4.3'] def get_scanner(hass, config): diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index c21249fbc60..6a780693f25 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -19,22 +19,22 @@ from homeassistant.const import ( DOMAIN = "discovery" DEPENDENCIES = [] -REQUIREMENTS = ['netdisco==0.3'] +REQUIREMENTS = ['netdisco==0.4'] SCAN_INTERVAL = 300 # seconds -# Next 3 lines for now a mirror from netdisco.const -# Should setup a mapping netdisco.const -> own constants SERVICE_WEMO = 'belkin_wemo' SERVICE_HUE = 'philips_hue' SERVICE_CAST = 'google_cast' SERVICE_NETGEAR = 'netgear_router' +SERVICE_SONOS = 'sonos' SERVICE_HANDLERS = { SERVICE_WEMO: "switch", SERVICE_CAST: "media_player", SERVICE_HUE: "light", SERVICE_NETGEAR: 'device_tracker', + SERVICE_SONOS: 'media_player', } @@ -79,13 +79,6 @@ def setup(hass, config): if not component: return - # Hack - fix when device_tracker supports discovery - if service == SERVICE_NETGEAR: - bootstrap.setup_component(hass, component, { - 'device_tracker': {'platform': 'netgear'} - }) - return - # This component cannot be setup. if not bootstrap.setup_component(hass, component, config): return diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 8c6b05726da..41e727adf89 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 = "35ecb5457a9ff0f4142c2605b53eb843" +VERSION = "397aa7c09f4938b1358672c9983f9f32" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 02d96975a0e..60831ab1e66 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -4123,7 +4123,7 @@ subject to an additional IP rights grant found at http://polymer.github.io/PATEN a { color: var(--accent-color); - }