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/.gitignore b/.gitignore index 6bda29ca6fc..b04c0ebc219 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/.travis.yml b/.travis.yml index 339ed48d424..4a4dfbc2354 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,6 @@ language: python python: - "3.4" install: - - pip install -r requirements_all.txt - - pip install flake8 pylint coveralls + - script/bootstrap_server script: - - flake8 homeassistant - - pylint homeassistant - - coverage run -m unittest discover tests -after_success: - - coveralls + - script/cibuild 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/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..e97ed0c6386 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -95,6 +95,18 @@ 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.') + parser.add_argument( + '--restart-osx', + action='store_true', + help='Restarts on OS X.') if os.name != "nt": parser.add_argument( '--daemon', @@ -152,6 +164,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 +213,18 @@ 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 + if args.restart_osx: + uninstall_osx() + install_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..b2e5fa51540 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 @@ -296,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/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py new file mode 100644 index 00000000000..bf68e35ffe3 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -0,0 +1,108 @@ +""" +homeassistant.components.alarm_control_panel +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Component to interface with a alarm control panel. +""" +import logging +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components import verisure +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY) + +DOMAIN = 'alarm_control_panel' +DEPENDENCIES = [] +SCAN_INTERVAL = 30 + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +# Maps discovered services to their platforms +DISCOVERY_PLATFORMS = { + verisure.DISCOVER_SENSORS: 'verisure' +} + +SERVICE_TO_METHOD = { + SERVICE_ALARM_DISARM: 'alarm_disarm', + SERVICE_ALARM_ARM_HOME: 'alarm_arm_home', + SERVICE_ALARM_ARM_AWAY: 'alarm_arm_away', +} + +ATTR_CODE = 'code' + +ATTR_TO_PROPERTY = [ + ATTR_CODE, +] + + +def setup(hass, config): + """ Track states and offer events for sensors. """ + component = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, + DISCOVERY_PLATFORMS) + + component.setup(config) + + def alarm_service_handler(service): + """ Maps services to methods on Alarm. """ + target_alarms = component.extract_from_service(service) + + if ATTR_CODE not in service.data: + return + + code = service.data[ATTR_CODE] + + method = SERVICE_TO_METHOD[service.service] + + for alarm in target_alarms: + getattr(alarm, method)(code) + + for service in SERVICE_TO_METHOD: + hass.services.register(DOMAIN, service, alarm_service_handler) + + return True + + +def alarm_disarm(hass, code, entity_id=None): + """ Send the alarm the command for disarm. """ + data = {ATTR_CODE: code} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data) + + +def alarm_arm_home(hass, code, entity_id=None): + """ Send the alarm the command for arm home. """ + data = {ATTR_CODE: code} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data) + + +def alarm_arm_away(hass, code, entity_id=None): + """ Send the alarm the command for arm away. """ + data = {ATTR_CODE: code} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) + + +class AlarmControlPanel(Entity): + """ ABC for alarm control devices. """ + def alarm_disarm(self, code): + """ Send disarm command. """ + raise NotImplementedError() + + def alarm_arm_home(self, code): + """ Send arm home command. """ + raise NotImplementedError() + + def alarm_arm_away(self, code): + """ Send arm away command. """ + raise NotImplementedError() diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py new file mode 100644 index 00000000000..f19cdc102d2 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -0,0 +1,88 @@ +""" +homeassistant.components.alarm_control_panel.verisure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Interfaces with Verisure alarm control panel. +""" +import logging + +import homeassistant.components.verisure as verisure +import homeassistant.components.alarm_control_panel as alarm + +from homeassistant.const import ( + STATE_UNKNOWN, + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Verisure platform. """ + + if not verisure.MY_PAGES: + _LOGGER.error('A connection has not been made to Verisure mypages.') + return False + + alarms = [] + + alarms.extend([ + VerisureAlarm(value) + for value in verisure.get_alarm_status().values() + if verisure.SHOW_ALARM + ]) + + add_devices(alarms) + + +class VerisureAlarm(alarm.AlarmControlPanel): + """ represents a Verisure alarm status within home assistant. """ + + def __init__(self, alarm_status): + self._id = alarm_status.id + self._device = verisure.MY_PAGES.DEVICE_ALARM + self._state = STATE_UNKNOWN + + @property + def name(self): + """ Returns the name of the device. """ + return 'Alarm {}'.format(self._id) + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + def update(self): + ''' update alarm status ''' + verisure.update() + + if verisure.STATUS[self._device][self._id].status == 'unarmed': + self._state = STATE_ALARM_DISARMED + elif verisure.STATUS[self._device][self._id].status == 'armedhome': + self._state = STATE_ALARM_ARMED_HOME + elif verisure.STATUS[self._device][self._id].status == 'armedaway': + self._state = STATE_ALARM_ARMED_AWAY + elif verisure.STATUS[self._device][self._id].status != 'pending': + _LOGGER.error( + 'Unknown alarm state %s', + verisure.STATUS[self._device][self._id].status) + + def alarm_disarm(self, code): + """ Send disarm command. """ + verisure.MY_PAGES.set_alarm_status( + code, + verisure.MY_PAGES.ALARM_DISARMED) + _LOGGER.warning('disarming') + + def alarm_arm_home(self, code): + """ Send arm home command. """ + verisure.MY_PAGES.set_alarm_status( + code, + verisure.MY_PAGES.ALARM_ARMED_HOME) + _LOGGER.warning('arming home') + + def alarm_arm_away(self, code): + """ Send arm away command. """ + verisure.MY_PAGES.set_alarm_status( + code, + verisure.MY_PAGES.ALARM_ARMED_AWAY) + _LOGGER.warning('arming away') diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8dcb158dea4..b734728e59b 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,68 +7,217 @@ Allows to setup simple automation rules via the config file. 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" +DOMAIN = 'automation' -DEPENDENCIES = ["group"] +DEPENDENCIES = ['group'] -CONF_ALIAS = "alias" -CONF_SERVICE = "execute_service" -CONF_SERVICE_ENTITY_ID = "service_entity_id" -CONF_SERVICE_DATA = "service_data" +CONF_ALIAS = 'alias' +CONF_SERVICE = 'service' +CONF_SERVICE_ENTITY_ID = 'entity_id' +CONF_SERVICE_DATA = 'data' + +CONF_CONDITION = 'condition' +CONF_ACTION = 'action' +CONF_TRIGGER = 'trigger' +CONF_CONDITION_TYPE = 'condition_type' + +CONDITION_USE_TRIGGER_VALUES = 'use_trigger_values' +CONDITION_TYPE_AND = 'and' +CONDITION_TYPE_OR = 'or' + +DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND _LOGGER = logging.getLogger(__name__) def setup(hass, config): """ Sets up automation. """ - success = False + config_key = DOMAIN + found = 1 - for p_type, p_config in config_per_platform(config, DOMAIN, _LOGGER): - platform = prepare_setup_platform(hass, config, DOMAIN, p_type) + while config_key in config: + # 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) - if platform is None: - _LOGGER.error("Unknown automation platform specified: %s", p_type) - continue + # 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) - if platform.register(hass, p_config, _get_action(hass, p_config)): - _LOGGER.info( - "Initialized %s rule %s", p_type, p_config.get(CONF_ALIAS, "")) - success = True + # any scalar value is incorrect else: - _LOGGER.error( - "Error setting up rule %s", p_config.get(CONF_ALIAS, "")) + _LOGGER.error('Error in config in section %s.', config_key) - return success + found += 1 + config_key = "{} {}".format(DOMAIN, found) + + return True -def _get_action(hass, config): +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: + return False + + _process_trigger(hass, config, config_block.get(CONF_TRIGGER, []), name, + action) + return True + + +def _get_action(hass, config, name): """ Return an action based on a config. """ + if CONF_SERVICE not in config: + _LOGGER.error('Error setting up %s, no action specified.', name) + return None + 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 _migrate_old_config(config): + """ Migrate old config to new. """ + if CONF_PLATFORM not in config: + return config + + _LOGGER.warning( + 'You are using an old configuration format. Please upgrade: ' + 'https://home-assistant.io/components/automation.html') + + new_conf = { + CONF_TRIGGER: dict(config), + CONF_CONDITION: config.get('if', []), + CONF_ACTION: dict(config), + } + + for cat, key, new_key in (('trigger', 'mqtt_topic', 'topic'), + ('trigger', 'mqtt_payload', 'payload'), + ('trigger', 'state_entity_id', 'entity_id'), + ('trigger', 'state_before', 'before'), + ('trigger', 'state_after', 'after'), + ('trigger', 'state_to', 'to'), + ('trigger', 'state_from', 'from'), + ('trigger', 'state_hours', 'hours'), + ('trigger', 'state_minutes', 'minutes'), + ('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) + + return new_conf + + +def _process_if(hass, config, p_config, action): + """ Processes if checks. """ + + cond_type = p_config.get(CONF_CONDITION_TYPE, + DEFAULT_CONDITION_TYPE).lower() + + if_configs = p_config.get(CONF_CONDITION) + use_trigger = if_configs == CONDITION_USE_TRIGGER_VALUES + + if use_trigger: + if_configs = p_config[CONF_TRIGGER] + + if isinstance(if_configs, dict): + if_configs = [if_configs] + + checks = [] + for if_config in if_configs: + platform = _resolve_platform('if_action', hass, config, + if_config.get(CONF_PLATFORM)) + if platform is None: + continue + + check = platform.if_action(hass, if_config) + + # Invalid conditions are allowed if we base it on trigger + if check is None and not use_trigger: + return None + + checks.append(check) + + if cond_type == CONDITION_TYPE_AND: + def if_action(): + """ AND all conditions. """ + if all(check() for check in checks): + action() + else: + def if_action(): + """ OR all conditions. """ + if any(check() for check in checks): + action() + + return if_action + + +def _process_trigger(hass, config, trigger_configs, name, action): + """ Setup triggers. """ + if isinstance(trigger_configs, dict): + trigger_configs = [trigger_configs] + + for conf in trigger_configs: + platform = _resolve_platform('trigger', hass, config, + conf.get(CONF_PLATFORM)) + if platform is None: + continue + + if platform.trigger(hass, conf, action): + _LOGGER.info("Initialized rule %s", name) + else: + _LOGGER.error("Error setting up rule %s", name) + + +def _resolve_platform(method, hass, config, platform): + """ Find automation platform. """ + if platform is None: + return None + platform = prepare_setup_platform(hass, config, DOMAIN, platform) + + if platform is None or not hasattr(platform, method): + _LOGGER.error("Unknown automation platform specified for %s: %s", + method, platform) + return None + + return platform diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 8a78f20d485..c5b0ee47923 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) @@ -20,11 +20,12 @@ def register(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/mqtt.py b/homeassistant/components/automation/mqtt.py index 6b4e6b1e039..3f85792f907 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -10,11 +10,11 @@ import homeassistant.components.mqtt as mqtt DEPENDENCIES = ['mqtt'] -CONF_TOPIC = 'mqtt_topic' -CONF_PAYLOAD = 'mqtt_payload' +CONF_TOPIC = 'topic' +CONF_PAYLOAD = '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..7e014213d62 --- /dev/null +++ b/homeassistant/components/automation/numeric_state.py @@ -0,0 +1,91 @@ +""" +homeassistant.components.automation.numeric_state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers numeric state listening automation rules. +""" +import logging + +from homeassistant.helpers.event import track_state_change + + +CONF_ENTITY_ID = "entity_id" +CONF_BELOW = "below" +CONF_ABOVE = "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): + """ 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 None + + 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 None + + def if_numeric_state(): + """ Test numeric state condition. """ + state = hass.states.get(entity_id) + return state is not None and _in_range(state.state, above, below) + + return if_numeric_state + + +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..8baa0a01d46 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -10,22 +10,23 @@ from homeassistant.helpers.event import track_state_change from homeassistant.const import MATCH_ALL -CONF_ENTITY_ID = "state_entity_id" -CONF_FROM = "state_from" -CONF_TO = "state_to" +CONF_ENTITY_ID = "entity_id" +CONF_FROM = "from" +CONF_TO = "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) - to_state = config.get(CONF_TO, MATCH_ALL) + to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL def state_automation_listener(entity, from_s, to_s): """ Listens for state changes and calls action. """ @@ -35,3 +36,23 @@ def register(hass, config, action): hass, entity_id, state_automation_listener, from_state, to_state) return True + + +def if_action(hass, config): + """ 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 None + + state = str(state) + + def if_state(): + """ Test if condition. """ + return hass.states.is_state(entity_id, state) + + return if_state diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py new file mode 100644 index 00000000000..103df6c9b39 --- /dev/null +++ b/homeassistant/components/automation/sun.py @@ -0,0 +1,103 @@ +""" +homeassistant.components.automation.sun +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers sun based automation rules. +""" +import logging +from datetime import timedelta + +from homeassistant.components import sun +from homeassistant.helpers.event import track_point_in_utc_time +import homeassistant.util.dt as dt_util + +DEPENDENCIES = ['sun'] + +CONF_OFFSET = 'offset' +CONF_EVENT = 'event' + +EVENT_SUNSET = 'sunset' +EVENT_SUNRISE = 'sunrise' + +_LOGGER = logging.getLogger(__name__) + + +def trigger(hass, config, action): + """ Listen for events based on config. """ + event = config.get(CONF_EVENT) + + if event is None: + _LOGGER.error("Missing configuration key %s", CONF_EVENT) + return False + + event = event.lower() + if event not in (EVENT_SUNRISE, EVENT_SUNSET): + _LOGGER.error("Invalid value for %s: %s", CONF_EVENT, event) + return False + + if CONF_OFFSET in config: + raw_offset = config.get(CONF_OFFSET) + + negative_offset = False + if raw_offset.startswith('-'): + negative_offset = True + raw_offset = raw_offset[1:] + + try: + (hour, minute, second) = [int(x) for x in raw_offset.split(':')] + except ValueError: + _LOGGER.error('Could not parse offset %s', raw_offset) + return False + + offset = timedelta(hours=hour, minutes=minute, seconds=second) + + if negative_offset: + offset *= -1 + else: + offset = timedelta(0) + + # Do something to call action + if event == EVENT_SUNRISE: + trigger_sunrise(hass, action, offset) + else: + trigger_sunset(hass, action, offset) + + return True + + +def trigger_sunrise(hass, action, offset): + """ Trigger action at next sun rise. """ + def next_rise(): + """ Returns next sunrise. """ + next_time = sun.next_rising_utc(hass) + offset + + while next_time < dt_util.utcnow(): + next_time = next_time + timedelta(days=1) + + return next_time + + def sunrise_automation_listener(now): + """ Called when it's time for action. """ + track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + action() + + track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + + +def trigger_sunset(hass, action, offset): + """ Trigger action at next sun set. """ + def next_set(): + """ Returns next sunrise. """ + next_time = sun.next_setting_utc(hass) + offset + + while next_time < dt_util.utcnow(): + next_time = next_time + timedelta(days=1) + + return next_time + + def sunset_automation_listener(now): + """ Called when it's time for action. """ + track_point_in_utc_time(hass, sunset_automation_listener, next_set()) + action() + + track_point_in_utc_time(hass, sunset_automation_listener, next_set()) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 77bd40a7a41..1d97ccc135d 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -4,19 +4,41 @@ 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_HOURS = "hours" +CONF_MINUTES = "minutes" +CONF_SECONDS = "seconds" +CONF_BEFORE = "before" +CONF_AFTER = "after" +CONF_WEEKDAY = "weekday" + +WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + +_LOGGER = logging.getLogger(__name__) -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) - seconds = convert(config.get(CONF_SECONDS), int) + if CONF_AFTER in config: + after = dt_util.parse_time_str(config[CONF_AFTER]) + if after is None: + _error_time(config[CONF_AFTER], CONF_AFTER) + return False + hours, minutes, seconds = after.hour, after.minute, after.second + elif (CONF_HOURS in config or CONF_MINUTES in config + or CONF_SECONDS in config): + hours = convert(config.get(CONF_HOURS), int) + minutes = convert(config.get(CONF_MINUTES), int) + seconds = convert(config.get(CONF_SECONDS), int) + else: + _LOGGER.error('One of %s, %s, %s OR %s needs to be specified', + CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AFTER) + return False def time_automation_listener(now): """ Listens for time changes and calls action. """ @@ -26,3 +48,58 @@ def register(hass, config, action): hour=hours, minute=minutes, second=seconds) return True + + +def if_action(hass, config): + """ 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) + return None + + if before is not None: + before = dt_util.parse_time_str(before) + if before is None: + _error_time(before, CONF_BEFORE) + return None + + if after is not None: + after = dt_util.parse_time_str(after) + if after is None: + _error_time(after, CONF_AFTER) + return None + + def time_if(): + """ Validate time based if-condition """ + now = dt_util.now() + if before is not None and now > now.replace(hour=before.hour, + minute=before.minute): + return False + + if after is not None and now < now.replace(hour=after.hour, + minute=after.minute): + return False + + 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 False + + return True + + return time_if + + +def _error_time(value, key): + """ Helper method to print error. """ + _LOGGER.error( + "Received invalid value for '%s': %s", key, value) + if isinstance(value, int): + _LOGGER.error('Make sure you wrap time values in quotes') diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py new file mode 100644 index 00000000000..78fd0f4d2e1 --- /dev/null +++ b/homeassistant/components/camera/foscam.py @@ -0,0 +1,105 @@ +""" +homeassistant.components.camera.foscam +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This component provides basic support for Foscam IP cameras. + +As part of the basic support the following features will be provided: +-MJPEG video streaming + +To use this component, add the following to your configuration.yaml file. + +camera: + platform: foscam + name: Door Camera + ip: 192.168.0.123 + port: 88 + username: YOUR_USERNAME + password: YOUR_PASSWORD + +Variables: + +ip +*Required +The IP address of your Foscam device. + +username +*Required +The username of a visitor or operator of your camera. Oddly admin accounts +don't seem to have access to take snapshots. + +password +*Required +The password for accessing your camera. + +name +*Optional +This parameter allows you to override the name of your camera in homeassistant. + +port +*Optional +The port that the camera is running on. The default is 88. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.foscam.html +""" +import logging +from homeassistant.helpers import validate_config +from homeassistant.components.camera import DOMAIN +from homeassistant.components.camera import Camera +import requests +import re + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Adds a Foscam IP Camera. """ + if not validate_config({DOMAIN: config}, + {DOMAIN: ['username', 'password', 'ip']}, _LOGGER): + return None + + add_devices_callback([FoscamCamera(config)]) + + +# pylint: disable=too-many-instance-attributes +class FoscamCamera(Camera): + """ An implementation of a Foscam IP camera. """ + + def __init__(self, device_info): + super(FoscamCamera, self).__init__() + + ip_address = device_info.get('ip') + port = device_info.get('port', 88) + + self._base_url = 'http://' + ip_address + ':' + str(port) + '/' + self._username = device_info.get('username') + self._password = device_info.get('password') + self._snap_picture_url = self._base_url \ + + 'cgi-bin/CGIProxy.fcgi?cmd=snapPicture&usr=' \ + + self._username + '&pwd=' + self._password + self._name = device_info.get('name', 'Foscam Camera') + + _LOGGER.info('Using the following URL for %s: %s', + self._name, self._snap_picture_url) + + def camera_image(self): + """ Return a still image reponse from the camera. """ + + # send the request to snap a picture + response = requests.get(self._snap_picture_url) + + # parse the response to find the image file name + + pattern = re.compile('src="[.][.]/(.*[.]jpg)"') + filename = pattern.search(response.content.decode("utf-8")).group(1) + + # send request for the image + response = requests.get(self._base_url + filename) + + return response.content + + @property + def name(self): + """ Return the name of this device. """ + return self._name 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..27e9417ab5b 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,52 +1,92 @@ """ -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 + + # Maximum distance from home we consider people home + range_home: 100 """ -import logging -import threading -import os +# pylint: disable=too-many-instance-attributes, too-many-arguments +# pylint: disable=too-many-locals 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_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 +CONF_HOME_RANGE = 'home_range' +DEFAULT_HOME_RANGE = 100 + +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' +ATTR_GPS_ACCURACY = 'gps_accuracy' +ATTR_BATTERY = 'battery' + +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 +95,343 @@ 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, gps_accuracy=None, battery=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 = 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) - tracker_type = config[DOMAIN].get(CONF_PLATFORM) + devices = load_config(yaml_path, hass, consider_home, home_range) + tracker = DeviceTracker(hass, consider_home, track_new, home_range, + 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, ATTR_GPS_ACCURACY, ATTR_BATTERY)} + 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, 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 = consider_home + self.track_new = track_new + self.home_range = home_range + self.lock = threading.Lock() + + for device in devices: + if device.track: + device.update_ha_state() + + self.group = None + + def see(self, mac=None, dev_id=None, host_name=None, location_name=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: + 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 '') or util.slugify(mac) + else: + dev_id = str(dev_id).lower() + device = self.devices.get(dev_id) + + if device: + 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.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, gps_accuracy, battery) + 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.track and device.last_update_home and + device.stale(now)): + device.update_ha_state(True) + + +class Device(Entity): + """ Tracked device. """ + + 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, 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) + + # Timedelta object how long we consider a device home if it is not + # 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 + + # 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 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. """ + 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] + attr[ATTR_GPS_ACCURACY] = self.gps_accuracy + + if self.battery: + attr[ATTR_BATTERY] = self.battery + + 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, + gps_accuracy=0, battery=None): + """ Mark the device as seen. """ + self.last_seen = dt_util.utcnow() + self.host_name = host_name + self.location_name = location_name + self.gps_accuracy = gps_accuracy + self.battery = battery + if gps is None: + self.gps = None + else: + 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): + """ 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.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 + 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).lower(), + used_ids) + used_ids.add(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 - 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, home_range): + """ Load devices from YAML config file. """ + if not os.path.isfile(path): + return [] + return [ + Device(hass, consider_home, home_range, device.get('track', False), + str(dev_id).lower(), str(device.get('mac')).upper(), + device.get('name'), device.get('picture'), + device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) + 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/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/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/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 c21249fbc60..450019022e1 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.1'] 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/__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 8c6b05726da..5f913eae674 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 = "3a3ed81f9d66bf24e17f1d02b8403335" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 02d96975a0e..9277184b8a2 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -2277,7 +2277,490 @@ http://nicolasgallagher.com/micro-clearfix-hack/ .pika-table abbr { border-bottom: none; cursor: help; -}[[entryObj.name]][[entryObj.name]] [[entryObj.message]][[entryObj.name]][[entryObj.name]] [[entryObj.message]]No logbook entries found.HistoryHistory-Map© OpenStreetMap contributors. Tiles courtesy of MapQuest UpdateView on GitHubUpdated On:[[stateObj.attributes.date]]Release Notes:[[stateObj.attributes.message]]Remote SHA:[[stateObj.attributes.remote_sha]]Local SHA:[[stateObj.attributes.local_sha]]UpdateView on GitHubUpdated On:[[stateObj.attributes.date]]Release Notes:[[stateObj.attributes.message]]Remote SHA:[[stateObj.attributes.remote_sha]]Local SHA:[[stateObj.attributes.local_sha]]DisarmArm HomeArm AwayHome AssistantStatesHistoryLogbookLog OutStreaming updatesDeveloper ToolsHome AssistantStatesHistoryLogbookMapLog OutStreaming updatesDeveloper Tools