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);
- }No logbook entries found.