diff --git a/.coveragerc b/.coveragerc index 44d83549ec9..ea9f302fbb1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -15,8 +15,15 @@ omit = homeassistant/components/*/modbus.py homeassistant/components/*/tellstick.py + + homeassistant/components/tellduslive.py + homeassistant/components/*/tellduslive.py + homeassistant/components/*/vera.py + homeassistant/components/ecobee.py + homeassistant/components/*/ecobee.py + homeassistant/components/verisure.py homeassistant/components/*/verisure.py @@ -29,26 +36,34 @@ omit = homeassistant/components/rfxtrx.py homeassistant/components/*/rfxtrx.py - homeassistant/components/ifttt.py + homeassistant/components/binary_sensor/arest.py + homeassistant/components/binary_sensor/rest.py homeassistant/components/browser.py homeassistant/components/camera/* homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/ddwrt.py + homeassistant/components/device_tracker/fritz.py + homeassistant/components/device_tracker/icloud.py homeassistant/components/device_tracker/luci.py homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py + homeassistant/components/device_tracker/owntracks.py + homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tplink.py - homeassistant/components/device_tracker/snmp.py + homeassistant/components/device_tracker/ubus.py homeassistant/components/discovery.py homeassistant/components/downloader.py + homeassistant/components/ifttt.py + homeassistant/components/influxdb.py homeassistant/components/keyboard.py - homeassistant/components/light/hue.py - homeassistant/components/light/limitlessled.py homeassistant/components/light/blinksticklight.py + homeassistant/components/light/hue.py + homeassistant/components/light/hyperion.py + homeassistant/components/light/limitlessled.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/denon.py homeassistant/components/media_player/firetv.py @@ -56,12 +71,12 @@ omit = homeassistant/components/media_player/kodi.py homeassistant/components/media_player/mpd.py homeassistant/components/media_player/plex.py - homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/sonos.py - homeassistant/components/notify/file.py + homeassistant/components/media_player/squeezebox.py homeassistant/components/notify/instapush.py homeassistant/components/notify/nma.py homeassistant/components/notify/pushbullet.py + homeassistant/components/notify/pushetta.py homeassistant/components/notify/pushover.py homeassistant/components/notify/slack.py homeassistant/components/notify/smtp.py @@ -70,9 +85,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/cpuspeed.py homeassistant/components/sensor/dht.py + homeassistant/components/sensor/dweet.py homeassistant/components/sensor/efergy.py + homeassistant/components/sensor/eliqonline.py homeassistant/components/sensor/forecast.py homeassistant/components/sensor/glances.py homeassistant/components/sensor/mysensors.py @@ -84,17 +101,24 @@ omit = homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py + homeassistant/components/sensor/torque.py homeassistant/components/sensor/transmission.py + homeassistant/components/sensor/twitch.py homeassistant/components/sensor/worldclock.py homeassistant/components/switch/arest.py - homeassistant/components/switch/command_switch.py homeassistant/components/switch/edimax.py homeassistant/components/switch/hikvisioncam.py + homeassistant/components/switch/mystrom.py + homeassistant/components/switch/orvibo.py + homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_gpio.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wemo.py - homeassistant/components/thermostat/honeywell_round_connected.py + homeassistant/components/thermostat/heatmiser.py + homeassistant/components/thermostat/homematic.py + homeassistant/components/thermostat/honeywell.py homeassistant/components/thermostat/nest.py + homeassistant/components/thermostat/radiotherm.py [report] diff --git a/.gitignore b/.gitignore index 8935ffedc17..3ee71808ab1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ Icon dist build eggs +.eggs parts bin var diff --git a/.travis.yml b/.travis.yml index da3516554ef..4383d49f548 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,10 +2,16 @@ sudo: false language: python cache: directories: - - $HOME/virtualenv/python3.4.2/ + - $HOME/.cache/pip + # - "$HOME/virtualenv/python$TRAVIS_PYTHON_VERSION" python: - - "3.4" + - 3.4 + - 3.5 install: + # Validate requirements_all.txt on Python 3.4 + - if [[ $TRAVIS_PYTHON_VERSION == '3.4' ]]; then python3 setup.py -q develop 2>/dev/null; tput setaf 1; script/gen_requirements_all.py validate; tput sgr0; fi - script/bootstrap_server script: - script/cibuild +matrix: + fast_finish: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f646766a231..1606149a1c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,19 +17,18 @@ 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_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`. + - Add any new dependencies to `requirements_all.txt` if needed. Use `script/gen_requirements_all.py`. + - Update the `.coveragerc` file to exclude your platform if there are no tests available. + - Provide some documentation for [home-assistant.io](https://home-assistant.io/). It's OK to just add a docstring with configuration details (sample entry for `configuration.yaml` file and alike) to the file header as a start. Visit the [website documentation](https://home-assistant.io/developers/website/) for further information on contributing to [home-assistant.io](https://github.com/balloob/home-assistant.io). + - Make sure all your code passes ``pylint`` and ``flake8`` (PEP8 and some more) validation. To check your repository, run `./script/lint`. - Create a Pull Request against the [**dev**](https://github.com/balloob/home-assistant/tree/dev) branch of Home Assistant. - Check for comments and suggestions on your Pull Request and keep an eye on the [Travis output](https://travis-ci.org/balloob/home-assistant/). -If you've added a component: +If you add a platform for an existing component, there is usually no need for updating the frontend. Only if you've added a new component that should show up in the frontend, there are more steps needed: - Update the file [`home-assistant-icons.html`](https://github.com/balloob/home-assistant/blob/master/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html) with an icon for your domain ([pick one from this list](https://www.polymer-project.org/1.0/components/core-elements/demo.html#core-icon)). - - Update the demo component with two states that it provides - - Add your component to home-assistant.conf.example + - Update the demo component with two states that it provides. + - Add your component to `home-assistant.conf.example`. Since you've updated `home-assistant-icons.html`, you've made changes to the frontend: diff --git a/Dockerfile b/Dockerfile index 9554ec552d7..a1f9d459295 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,27 @@ -FROM python:3-onbuild +FROM python:3.4 MAINTAINER Paulus Schoutsen VOLUME /config -RUN pip3 install --no-cache-dir -r requirements_all.txt +RUN mkdir -p /usr/src/app +WORKDIR /usr/src/app # For the nmap tracker RUN apt-get update && \ apt-get install -y --no-install-recommends nmap net-tools && \ apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -# Open Z-Wave disabled because broken -#RUN apt-get update && \ -# apt-get install -y cython3 libudev-dev && \ -# apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ -# pip3 install cython && \ -# scripts/build_python_openzwave +COPY script/build_python_openzwave script/build_python_openzwave +RUN apt-get update && \ + apt-get install -y cython3 libudev-dev && \ + apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ + pip3 install "cython<0.23" && \ + script/build_python_openzwave + +COPY requirements_all.txt requirements_all.txt +RUN pip3 install --no-cache-dir -r requirements_all.txt + +# Copy source +COPY . . CMD [ "python", "-m", "homeassistant", "--config", "/config" ] diff --git a/LICENSE b/LICENSE index b3c5e1df750..42a425b4118 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 Paulus Schoutsen +Copyright (c) 2016 Paulus Schoutsen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/MANIFEST.in b/MANIFEST.in index 8233015e646..d04d86bae58 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include README.md +include README.rst include LICENSE graft homeassistant prune homeassistant/components/frontend/www_static/home-assistant-polymer diff --git a/README.md b/README.md deleted file mode 100644 index 2fed012402c..00000000000 --- a/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Home Assistant [![Build Status](https://travis-ci.org/balloob/home-assistant.svg?branch=master)](https://travis-ci.org/balloob/home-assistant) [![Coverage Status](https://img.shields.io/coveralls/balloob/home-assistant.svg)](https://coveralls.io/r/balloob/home-assistant?branch=master) [![Join the chat at https://gitter.im/balloob/home-assistant](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/balloob/home-assistant?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) - -[demo]: https://home-assistant.io/demo/ - -Home Assistant is a home automation platform running on Python 3. The goal of Home Assistant is to be able to track and control all devices at home and offer a platform for automating control. - -To get started: -```bash -python3 -m pip install homeassistant -hass --open-ui -``` - -Check out [the website](https://home-assistant.io) for [a demo][demo], installation instructions, tutorials and documentation. - -[![screenshot-states](https://raw.github.com/balloob/home-assistant/master/docs/screenshots.png)][demo] - -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/), [ASUSWRT](http://event.asus.com/2013/nw/ASUSWRT/) and any SNMP capable Linksys WAP/WRT - * - * [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), [Plex](https://plex.tv/), [Kodi (XBMC)](http://kodi.tv/), iTunes (by way of [itunes-api](https://github.com/maddox/itunes-api)), and Amazon Fire TV (by way of [python-firetv](https://github.com/happyleavesaoc/python-firetv)) - * 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/), [RFXtrx](http://www.rfxcom.com/), [Arduino](https://www.arduino.cc/), [Raspberry Pi](https://www.raspberrypi.org/), and [Modbus](http://www.modbus.org/) - * Interaction with [IFTTT](https://ifttt.com/) - * 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/) - -Built home automation on top of your devices: - - * Keep a precise history of every change to the state of your house - * Turn on the lights when people get home after sun set - * Turn on lights slowly during sun set to compensate for less light - * Turn off all lights and devices when everybody leaves the house - * Offers a [REST API](https://home-assistant.io/developers/api.html) and can interface with MQTT for easy integration with other projects like [OwnTracks](http://owntracks.org/) - * Allow sending notifications using [Instapush](https://instapush.im), [Notify My Android (NMA)](http://www.notifymyandroid.com/), [PushBullet](https://www.pushbullet.com/), [PushOver](https://pushover.net/), [Slack](https://slack.com/), [Telegram](https://telegram.org/), and [Jabber (XMPP)](http://xmpp.org) - -The system is built modular so support for other devices or actions can be implemented easily. See also the [section on architecture](https://home-assistant.io/developers/architecture.html) and the [section on creating your own components](https://home-assistant.io/developers/creating_components.html). - -If you run into issues while using Home Assistant or during development of a component, check the [Home Assistant help section](https://home-assistant.io/help/) how to reach us. diff --git a/README.rst b/README.rst new file mode 100644 index 00000000000..c66b3670f32 --- /dev/null +++ b/README.rst @@ -0,0 +1,98 @@ +Home Assistant |Build Status| |Coverage Status| |Join the chat at https://gitter.im/balloob/home-assistant| +=========================================================================================================== + +Home Assistant is a home automation platform running on Python 3. The +goal of Home Assistant is to be able to track and control all devices at +home and offer a platform for automating control. + +To get started: + +.. code:: bash + + python3 -m pip install homeassistant + hass --open-ui + +Check out `the website `__ for `a +demo `__, installation instructions, +tutorials and documentation. + +|screenshot-states| + +Examples of devices it can interface it: + +- Monitoring connected devices to a wireless router: + `OpenWrt `__, + `Tomato `__, + `Netgear `__, + `DD-WRT `__, + `TPLink `__, + `ASUSWRT `__ and any SNMP + capable Linksys WAP/WRT +- `Philips Hue `__ lights, + `WeMo `__ + switches, `Edimax `__ switches, + `Efergy `__ energy monitoring, and + `Tellstick `__ devices and + sensors +- `Google + Chromecasts `__, + `Music Player Daemon `__, `Logitech + Squeezebox `__, + `Plex `__, `Kodi (XBMC) `__, + iTunes (by way of + `itunes-api `__), and Amazon + Fire TV (by way of + `python-firetv `__) +- Support for + `ISY994 `__ + (Insteon and X10 devices), `Z-Wave `__, `Nest + Thermostats `__, + `RFXtrx `__, + `Arduino `__, `Raspberry + Pi `__, and + `Modbus `__ +- Interaction with `IFTTT `__ +- Integrate data from the `Bitcoin `__ network, + meteorological data from + `OpenWeatherMap `__ and + `Forecast.io `__, + `Transmission `__, or + `SABnzbd `__. +- `See full list of supported + devices `__ + +Built home automation on top of your devices: + +- Keep a precise history of every change to the state of your house +- Turn on the lights when people get home after sun set +- Turn on lights slowly during sun set to compensate for less light +- Turn off all lights and devices when everybody leaves the house +- Offers a `REST API `__ + and can interface with MQTT for easy integration with other projects + like `OwnTracks `__ +- Allow sending notifications using + `Instapush `__, `Notify My Android + (NMA) `__, + `PushBullet `__, + `PushOver `__, `Slack `__, + `Telegram `__, and `Jabber + (XMPP) `__ + +The system is built modular so support for other devices or actions can +be implemented easily. See also the `section on +architecture `__ +and the `section on creating your own +components `__. + +If you run into issues while using Home Assistant or during development +of a component, check the `Home Assistant help +section `__ how to reach us. + +.. |Build Status| image:: https://travis-ci.org/balloob/home-assistant.svg?branch=master + :target: https://travis-ci.org/balloob/home-assistant +.. |Coverage Status| image:: https://img.shields.io/coveralls/balloob/home-assistant.svg + :target: https://coveralls.io/r/balloob/home-assistant?branch=master +.. |Join the chat at https://gitter.im/balloob/home-assistant| image:: https://badges.gitter.im/Join%20Chat.svg + :target: https://gitter.im/balloob/home-assistant?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge +.. |screenshot-states| image:: https://raw.github.com/balloob/home-assistant/master/docs/screenshots.png + :target: https://home-assistant.io/demo/ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index daee13914fd..b704fc082ac 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -9,11 +9,12 @@ After bootstrapping you can add your own components or start by calling homeassistant.start_home_assistant(bus) """ -import os -import sys +from collections import defaultdict import logging import logging.handlers -from collections import defaultdict +import os +import shutil +import sys import homeassistant.core as core import homeassistant.util.dt as date_util @@ -25,7 +26,7 @@ import homeassistant.components as core_components import homeassistant.components.group as group from homeassistant.helpers.entity import Entity from homeassistant.const import ( - EVENT_COMPONENT_LOADED, CONF_LATITUDE, CONF_LONGITUDE, + __version__, EVENT_COMPONENT_LOADED, CONF_LATITUDE, CONF_LONGITUDE, CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE, CONF_CUSTOMIZE, TEMP_CELCIUS, TEMP_FAHRENHEIT) @@ -34,6 +35,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT = 'component' PLATFORM_FORMAT = '{}.{}' +ERROR_LOG_FILENAME = 'home-assistant.log' def setup_component(hass, domain, config=None): @@ -80,7 +82,7 @@ def _setup_component(hass, domain, config): return True component = loader.get_component(domain) - missing_deps = [dep for dep in component.DEPENDENCIES + missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', []) if dep not in hass.config.components] if missing_deps: @@ -104,7 +106,7 @@ def _setup_component(hass, domain, config): # Assumption: if a component does not depend on groups # it communicates with devices - if group.DOMAIN not in component.DEPENDENCIES: + if group.DOMAIN not in getattr(component, 'DEPENDENCIES', []): hass.pool.add_worker() hass.bus.fire( @@ -131,14 +133,13 @@ def prepare_setup_platform(hass, config, domain, platform_name): return platform # Load dependencies - if hasattr(platform, 'DEPENDENCIES'): - for component in platform.DEPENDENCIES: - if not setup_component(hass, component, config): - _LOGGER.error( - 'Unable to prepare setup for platform %s because ' - 'dependency %s could not be initialized', platform_path, - component) - return None + for component in getattr(platform, 'DEPENDENCIES', []): + if not setup_component(hass, component, config): + _LOGGER.error( + 'Unable to prepare setup for platform %s because ' + 'dependency %s could not be initialized', platform_path, + component) + return None if not _handle_requirements(hass, platform, platform_path): return None @@ -167,6 +168,7 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, hass.config.config_dir = config_dir mount_local_lib_path(config_dir) + process_ha_config_upgrade(hass) process_ha_core_config(hass, config.get(core.DOMAIN, {})) if enable_log: @@ -252,7 +254,7 @@ def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None): "Colorlog package not found, console coloring disabled") # Log errors to a file if we have write access to file or config dir - err_log_path = hass.config.path('home-assistant.log') + err_log_path = hass.config.path(ERROR_LOG_FILENAME) err_path_exists = os.path.isfile(err_log_path) # Check if we can write to the error log if it exists or that @@ -273,13 +275,38 @@ def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None): datefmt='%y-%m-%d %H:%M:%S')) logger = logging.getLogger('') logger.addHandler(err_handler) - logger.setLevel(logging.INFO) # this sets the minimum log level + logger.setLevel(logging.INFO) else: _LOGGER.error( 'Unable to setup error log %s (access denied)', err_log_path) +def process_ha_config_upgrade(hass): + """ Upgrade config if necessary. """ + version_path = hass.config.path('.HA_VERSION') + + try: + with open(version_path, 'rt') as inp: + conf_version = inp.readline().strip() + except FileNotFoundError: + # Last version to not have this file + conf_version = '0.7.7' + + if conf_version == __version__: + return + + _LOGGER.info('Upgrading config directory from %s to %s', conf_version, + __version__) + + lib_path = hass.config.path('lib') + if os.path.isdir(lib_path): + shutil.rmtree(lib_path) + + with open(version_path, 'wt') as outp: + outp.write(__version__) + + def process_ha_core_config(hass, config): """ Processes the [homeassistant] section from the config. """ hac = hass.config diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index e5e917c5250..e0b008cab5e 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -1,7 +1,6 @@ """ homeassistant.components ~~~~~~~~~~~~~~~~~~~~~~~~ - This package contains components that can be plugged into Home Assistant. Component design guidelines: @@ -12,7 +11,6 @@ Each component that tracks states should create state entity names in the format ".". Each component should publish services only under its own domain. - """ import itertools as it import logging diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index d3289e08e62..3f5e6362fb6 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -15,7 +15,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent DOMAIN = 'alarm_control_panel' -DEPENDENCIES = [] SCAN_INTERVAL = 30 ENTITY_ID_FORMAT = DOMAIN + '.{}' diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py new file mode 100644 index 00000000000..0ace53167de --- /dev/null +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -0,0 +1,13 @@ +""" +homeassistant.components.alarm_control_panel.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Demo platform that has two fake alarm control panels. +""" +import homeassistant.components.alarm_control_panel.manual as manual + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Demo alarm control panels. """ + add_devices([ + manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10), + ]) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index c379dca3199..63bc989f3df 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -4,7 +4,7 @@ homeassistant.components.alarm_control_panel.manual Support for manual alarms. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.manual.html +https://home-assistant.io/components/alarm_control_panel.manual/ """ import logging import datetime @@ -18,8 +18,6 @@ from homeassistant.const import ( _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 @@ -76,9 +74,10 @@ class ManualAlarm(alarm.AlarmControlPanel): return STATE_ALARM_PENDING if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._trigger_time > dt_util.utcnow(): + if self._state_ts + self._pending_time > dt_util.utcnow(): return STATE_ALARM_PENDING - elif dt_util.utcnow() >= self._state_ts + (2 * self._trigger_time): + elif (self._state_ts + self._pending_time + + self._trigger_time) < dt_util.utcnow(): return STATE_ALARM_DISARMED return self._state @@ -134,11 +133,11 @@ class ManualAlarm(alarm.AlarmControlPanel): if self._trigger_time: track_point_in_time( self._hass, self.update_ha_state, - self._state_ts + self._trigger_time) + self._state_ts + self._pending_time) track_point_in_time( self._hass, self.update_ha_state, - self._state_ts + 2 * self._trigger_time) + self._state_ts + self._pending_time + self._trigger_time) def _validate_code(self, code, state): """ Validate given code. """ diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index e070babd080..168b220db1a 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -4,7 +4,7 @@ homeassistant.components.alarm_control_panel.mqtt This platform enables the possibility to control a MQTT alarm. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.mqtt.html +https://home-assistant.io/components/alarm_control_panel.mqtt/ """ import logging import homeassistant.components.mqtt as mqtt diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index 9e0475592bd..cc9f8dde69d 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -2,6 +2,9 @@ homeassistant.components.alarm_control_panel.verisure ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Interfaces with Verisure alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/verisure/ """ import logging @@ -26,7 +29,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): alarms.extend([ VerisureAlarm(value) - for value in verisure.get_alarm_status().values() + for value in verisure.ALARM_STATUS.values() if verisure.SHOW_ALARM ]) @@ -39,7 +42,6 @@ class VerisureAlarm(alarm.AlarmControlPanel): def __init__(self, alarm_status): self._id = alarm_status.id - self._device = verisure.MY_PAGES.DEVICE_ALARM self._state = STATE_UNKNOWN @property @@ -59,36 +61,36 @@ class VerisureAlarm(alarm.AlarmControlPanel): def update(self): """ Update alarm status """ - verisure.update() + verisure.update_alarm() - if verisure.STATUS[self._device][self._id].status == 'unarmed': + if verisure.ALARM_STATUS[self._id].status == 'unarmed': self._state = STATE_ALARM_DISARMED - elif verisure.STATUS[self._device][self._id].status == 'armedhome': + elif verisure.ALARM_STATUS[self._id].status == 'armedhome': self._state = STATE_ALARM_ARMED_HOME - elif verisure.STATUS[self._device][self._id].status == 'armedaway': + elif verisure.ALARM_STATUS[self._id].status == 'armedaway': self._state = STATE_ALARM_ARMED_AWAY - elif verisure.STATUS[self._device][self._id].status != 'pending': + elif verisure.ALARM_STATUS[self._id].status != 'pending': _LOGGER.error( 'Unknown alarm state %s', - verisure.STATUS[self._device][self._id].status) + verisure.ALARM_STATUS[self._id].status) def alarm_disarm(self, code=None): """ Send disarm command. """ - verisure.MY_PAGES.set_alarm_status( - code, - verisure.MY_PAGES.ALARM_DISARMED) - _LOGGER.warning('disarming') + verisure.MY_PAGES.alarm.set(code, 'DISARMED') + _LOGGER.info('verisure alarm disarming') + verisure.MY_PAGES.alarm.wait_while_pending() + verisure.update_alarm() def alarm_arm_home(self, code=None): """ Send arm home command. """ - verisure.MY_PAGES.set_alarm_status( - code, - verisure.MY_PAGES.ALARM_ARMED_HOME) - _LOGGER.warning('arming home') + verisure.MY_PAGES.alarm.set(code, 'ARMED_HOME') + _LOGGER.info('verisure alarm arming home') + verisure.MY_PAGES.alarm.wait_while_pending() + verisure.update_alarm() def alarm_arm_away(self, code=None): """ Send arm away command. """ - verisure.MY_PAGES.set_alarm_status( - code, - verisure.MY_PAGES.ALARM_ARMED_AWAY) - _LOGGER.warning('arming away') + verisure.MY_PAGES.alarm.set(code, 'ARMED_AWAY') + _LOGGER.info('verisure alarm arming away') + verisure.MY_PAGES.alarm.wait_while_pending() + verisure.update_alarm() diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py new file mode 100644 index 00000000000..0b06f3c9a79 --- /dev/null +++ b/homeassistant/components/alexa.py @@ -0,0 +1,186 @@ +""" +components.alexa +~~~~~~~~~~~~~~~~ +Component to offer a service end point for an Alexa skill. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import enum +import logging + +from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.util import template + +DOMAIN = 'alexa' +DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) +_CONFIG = {} + +API_ENDPOINT = '/api/alexa' + +CONF_INTENTS = 'intents' +CONF_CARD = 'card' +CONF_SPEECH = 'speech' + + +def setup(hass, config): + """ Activate Alexa component. """ + _CONFIG.update(config[DOMAIN].get(CONF_INTENTS, {})) + + hass.http.register_path('POST', API_ENDPOINT, _handle_alexa, True) + + return True + + +def _handle_alexa(handler, path_match, data): + """ Handle Alexa. """ + _LOGGER.debug('Received Alexa request: %s', data) + + req = data.get('request') + + if req is None: + _LOGGER.error('Received invalid data from Alexa: %s', data) + handler.write_json_message( + "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) + return + + req_type = req['type'] + + if req_type == 'SessionEndedRequest': + handler.send_response(HTTP_OK) + handler.end_headers() + return + + intent = req.get('intent') + response = AlexaResponse(handler.server.hass, intent) + + if req_type == 'LaunchRequest': + response.add_speech( + SpeechType.plaintext, + "Hello, and welcome to the future. How may I help?") + handler.write_json(response.as_dict()) + return + + if req_type != 'IntentRequest': + _LOGGER.warning('Received unsupported request: %s', req_type) + return + + intent_name = intent['name'] + config = _CONFIG.get(intent_name) + + if config is None: + _LOGGER.warning('Received unknown intent %s', intent_name) + response.add_speech( + SpeechType.plaintext, + "This intent is not yet configured within Home Assistant.") + handler.write_json(response.as_dict()) + return + + speech = config.get(CONF_SPEECH) + card = config.get(CONF_CARD) + + # pylint: disable=unsubscriptable-object + if speech is not None: + response.add_speech(SpeechType[speech['type']], speech['text']) + + if card is not None: + response.add_card(CardType[card['type']], card['title'], + card['content']) + + handler.write_json(response.as_dict()) + + +class SpeechType(enum.Enum): + """ Alexa speech types. """ + plaintext = "PlainText" + ssml = "SSML" + + +class CardType(enum.Enum): + """ Alexa card types. """ + simple = "Simple" + link_account = "LinkAccount" + + +class AlexaResponse(object): + """ Helps generating the response for Alexa. """ + + def __init__(self, hass, intent=None): + self.hass = hass + self.speech = None + self.card = None + self.reprompt = None + self.session_attributes = {} + self.should_end_session = True + if intent is not None and 'slots' in intent: + self.variables = {key: value['value'] for key, value + in intent['slots'].items() if 'value' in value} + else: + self.variables = {} + + def add_card(self, card_type, title, content): + """ Add a card to the response. """ + assert self.card is None + + card = { + "type": card_type.value + } + + if card_type == CardType.link_account: + self.card = card + return + + card["title"] = self._render(title), + card["content"] = self._render(content) + self.card = card + + def add_speech(self, speech_type, text): + """ Add speech to the response. """ + assert self.speech is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.speech = { + 'type': speech_type.value, + key: self._render(text) + } + + def add_reprompt(self, speech_type, text): + """ Add repromopt if user does not answer. """ + assert self.reprompt is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.reprompt = { + 'type': speech_type.value, + key: self._render(text) + } + + def as_dict(self): + """ Returns response in an Alexa valid dict. """ + response = { + 'shouldEndSession': self.should_end_session + } + + if self.card is not None: + response['card'] = self.card + + if self.speech is not None: + response['outputSpeech'] = self.speech + + if self.reprompt is not None: + response['reprompt'] = { + 'outputSpeech': self.reprompt + } + + return { + 'version': '1.0', + 'sessionAttributes': self.session_attributes, + 'response': response, + } + + def _render(self, template_string): + """ Render a response, adding data from intent if available. """ + return template.render(self.hass, template_string, self.variables) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index e4c794df424..11f1826549e 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -1,8 +1,10 @@ """ homeassistant.components.api ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides a Rest API for Home Assistant. + +For more details about the RESTful API, please refer to the documentation at +https://home-assistant.io/developers/api/ """ import re import logging @@ -10,15 +12,19 @@ import threading import json import homeassistant.core as ha +from homeassistant.exceptions import TemplateError from homeassistant.helpers.state import TrackStates import homeassistant.remote as rem +from homeassistant.util import template +from homeassistant.bootstrap import ERROR_LOG_FILENAME from homeassistant.const import ( URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, URL_API_STREAM, URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, URL_API_COMPONENTS, - URL_API_CONFIG, URL_API_BOOTSTRAP, - EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, + URL_API_CONFIG, URL_API_BOOTSTRAP, URL_API_ERROR_LOG, URL_API_LOG_OUT, + URL_API_TEMPLATE, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, - HTTP_UNPROCESSABLE_ENTITY) + HTTP_UNPROCESSABLE_ENTITY, HTTP_HEADER_CONTENT_TYPE, + CONTENT_TYPE_TEXT_PLAIN) DOMAIN = 'api' @@ -33,10 +39,6 @@ _LOGGER = logging.getLogger(__name__) def setup(hass, config): """ Register the API with the HTTP interface. """ - if 'http' not in hass.config.components: - _LOGGER.error('Dependency http is not loaded') - return False - # /api - for validation purposes hass.http.register_path('GET', URL_API, _handle_get_api) @@ -87,6 +89,14 @@ def setup(hass, config): hass.http.register_path( 'GET', URL_API_COMPONENTS, _handle_get_api_components) + hass.http.register_path('GET', URL_API_ERROR_LOG, + _handle_get_api_error_log) + + hass.http.register_path('POST', URL_API_LOG_OUT, _handle_post_api_log_out) + + hass.http.register_path('POST', URL_API_TEMPLATE, + _handle_post_api_template) + return True @@ -102,6 +112,7 @@ def _handle_get_api_stream(handler, path_match, data): wfile = handler.wfile write_lock = threading.Lock() block = threading.Event() + session_id = None restrict = data.get('restrict') if restrict: @@ -115,28 +126,35 @@ def _handle_get_api_stream(handler, path_match, data): try: wfile.write(msg.encode("UTF-8")) wfile.flush() - except IOError: + except (IOError, ValueError): + # IOError: socket errors + # ValueError: raised when 'I/O operation on closed file' block.set() def forward_events(event): """ Forwards events to the open request. """ nonlocal gracefully_closed - if block.is_set() or event.event_type == EVENT_TIME_CHANGED or \ - restrict and event.event_type not in restrict: + if block.is_set() or event.event_type == EVENT_TIME_CHANGED: return elif event.event_type == EVENT_HOMEASSISTANT_STOP: gracefully_closed = True block.set() return + handler.server.sessions.extend_validation(session_id) write_message(json.dumps(event, cls=rem.JSONEncoder)) handler.send_response(HTTP_OK) handler.send_header('Content-type', 'text/event-stream') + session_id = handler.set_session_cookie_header() handler.end_headers() - hass.bus.listen(MATCH_ALL, forward_events) + if restrict: + for event in restrict: + hass.bus.listen(event, forward_events) + else: + hass.bus.listen(MATCH_ALL, forward_events) while True: write_message(STREAM_PING_PAYLOAD) @@ -150,7 +168,11 @@ def _handle_get_api_stream(handler, path_match, data): _LOGGER.info("Found broken event stream to %s, cleaning up", handler.client_address[0]) - hass.bus.remove_listener(MATCH_ALL, forward_events) + if restrict: + for event in restrict: + hass.bus.remove_listener(event, forward_events) + else: + hass.bus.remove_listener(MATCH_ALL, forward_events) def _handle_get_api_config(handler, path_match, data): @@ -339,6 +361,35 @@ def _handle_get_api_components(handler, path_match, data): handler.write_json(handler.server.hass.config.components) +def _handle_get_api_error_log(handler, path_match, data): + """ Returns the logged errors for this session. """ + handler.write_file(handler.server.hass.config.path(ERROR_LOG_FILENAME), + False) + + +def _handle_post_api_log_out(handler, path_match, data): + """ Log user out. """ + handler.send_response(HTTP_OK) + handler.destroy_session() + handler.end_headers() + + +def _handle_post_api_template(handler, path_match, data): + """ Log user out. """ + template_string = data.get('template', '') + + try: + rendered = template.render(handler.server.hass, template_string) + + handler.send_response(HTTP_OK) + handler.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN) + handler.end_headers() + handler.wfile.write(rendered.encode('utf-8')) + except TemplateError as e: + handler.write_json_message(str(e), HTTP_UNPROCESSABLE_ENTITY) + return + + def _services_json(hass): """ Generate services data to JSONify. """ return [{"domain": key, "services": value} diff --git a/homeassistant/components/arduino.py b/homeassistant/components/arduino.py index cbb319e2541..88967ec1f74 100644 --- a/homeassistant/components/arduino.py +++ b/homeassistant/components/arduino.py @@ -4,26 +4,8 @@ components.arduino Arduino component that connects to a directly attached Arduino board which runs with the Firmata firmware. -Configuration: - -To use the Arduino board you will need to add something like the following -to your configuration.yaml file. - -arduino: - port: /dev/ttyACM0 - -Variables: - -port -*Required -The port where is your board connected to your Home Assistant system. -If you are using an original Arduino the port will be named ttyACM*. The exact -number can be determined with 'ls /dev/ttyACM*' or check your 'dmesg'/ -'journalctl -f' output. Keep in mind that Arduino clones are often using a -different name for the port (e.g. '/dev/ttyUSB*'). - -A word of caution: The Arduino is not storing states. This means that with -every initialization the pins are set to off/low. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/arduino/ """ import logging @@ -37,7 +19,6 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) DOMAIN = "arduino" -DEPENDENCIES = [] REQUIREMENTS = ['PyMata==2.07a'] BOARD = None _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index b734728e59b..23d83f554ca 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,8 +1,10 @@ """ homeassistant.components.automation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Allows to setup simple automation rules via the config file. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/automation/ """ import logging @@ -121,7 +123,7 @@ def _migrate_old_config(config): _LOGGER.warning( 'You are using an old configuration format. Please upgrade: ' - 'https://home-assistant.io/components/automation.html') + 'https://home-assistant.io/components/automation/') new_conf = { CONF_TRIGGER: dict(config), diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index c172b8e0e11..7fc33df0031 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -4,7 +4,7 @@ homeassistant.components.automation.event Offers event listening automation rules. For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation.html#event-trigger +at https://home-assistant.io/components/automation/#event-trigger """ import logging diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 706d97824b4..8ea5f1bc6e5 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -4,7 +4,7 @@ homeassistant.components.automation.mqtt Offers MQTT listening automation rules. For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation.html#mqtt-trigger +at https://home-assistant.io/components/automation/#mqtt-trigger """ import logging diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 1ddfb91a334..f2baf760748 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -1,14 +1,16 @@ """ homeassistant.components.automation.numeric_state -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Offers numeric state listening automation rules. For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation.html#numeric-state-trigger +at https://home-assistant.io/components/automation/#numeric-state-trigger """ import logging +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.helpers.event import track_state_change +from homeassistant.util import template CONF_ENTITY_ID = "entity_id" @@ -28,6 +30,7 @@ def trigger(hass, config, action): below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) + value_template = config.get(CONF_VALUE_TEMPLATE) if below is None and above is None: _LOGGER.error("Missing configuration key." @@ -35,13 +38,20 @@ def trigger(hass, config, action): CONF_BELOW, CONF_ABOVE) return False + if value_template is not None: + renderer = lambda value: template.render(hass, + value_template, + {'state': value}) + else: + renderer = lambda value: value.state + # 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)): + if _in_range(above, below, renderer(to_s)) and \ + (from_s is None or not _in_range(above, below, renderer(from_s))): action() track_state_change( @@ -61,6 +71,7 @@ def if_action(hass, config): below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) + value_template = config.get(CONF_VALUE_TEMPLATE) if below is None and above is None: _LOGGER.error("Missing configuration key." @@ -68,21 +79,28 @@ def if_action(hass, config): CONF_BELOW, CONF_ABOVE) return None + if value_template is not None: + renderer = lambda value: template.render(hass, + value_template, + {'state': value}) + else: + renderer = lambda value: value.state + 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 state is not None and _in_range(above, below, renderer(state)) return if_numeric_state -def _in_range(value, range_start, range_end): +def _in_range(range_start, range_end, value): """ Checks if value is inside the range """ - try: value = float(value) except ValueError: - _LOGGER.warn("Missing value in numeric check") + _LOGGER.warning("Value returned from template is not a number: %s", + value) return False if range_start is not None and range_end is not None: diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 52379355d6b..bcf498f509a 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -4,7 +4,7 @@ homeassistant.components.automation.state Offers state listening automation rules. For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation.html#state-trigger +at https://home-assistant.io/components/automation/#state-trigger """ import logging diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index c72474ae4dd..394dc904be1 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -4,7 +4,7 @@ homeassistant.components.automation.sun Offers sun based automation rules. For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation.html#sun-trigger +at https://home-assistant.io/components/automation/#sun-trigger """ import logging from datetime import timedelta @@ -17,6 +17,10 @@ DEPENDENCIES = ['sun'] CONF_OFFSET = 'offset' CONF_EVENT = 'event' +CONF_BEFORE = "before" +CONF_BEFORE_OFFSET = "before_offset" +CONF_AFTER = "after" +CONF_AFTER_OFFSET = "after_offset" EVENT_SUNSET = 'sunset' EVENT_SUNRISE = 'sunrise' @@ -37,26 +41,9 @@ def trigger(hass, config, action): _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) + offset = _parse_offset(config.get(CONF_OFFSET)) + if offset is False: + return False # Do something to call action if event == EVENT_SUNRISE: @@ -67,6 +54,65 @@ def trigger(hass, config, action): return True +def if_action(hass, config): + """ Wraps action method with sun based condition. """ + before = config.get(CONF_BEFORE) + after = config.get(CONF_AFTER) + + # Make sure required configuration keys are present + if before is None and after is None: + logging.getLogger(__name__).error( + "Missing if-condition configuration key %s or %s", + CONF_BEFORE, CONF_AFTER) + return None + + # Make sure configuration keys have the right value + if before not in (None, EVENT_SUNRISE, EVENT_SUNSET) or \ + after not in (None, EVENT_SUNRISE, EVENT_SUNSET): + logging.getLogger(__name__).error( + "%s and %s can only be set to %s or %s", + CONF_BEFORE, CONF_AFTER, EVENT_SUNRISE, EVENT_SUNSET) + return None + + before_offset = _parse_offset(config.get(CONF_BEFORE_OFFSET)) + after_offset = _parse_offset(config.get(CONF_AFTER_OFFSET)) + if before_offset is False or after_offset is False: + return None + + if before is None: + before_func = lambda: None + elif before == EVENT_SUNRISE: + before_func = lambda: sun.next_rising_utc(hass) + before_offset + else: + before_func = lambda: sun.next_setting_utc(hass) + before_offset + + if after is None: + after_func = lambda: None + elif after == EVENT_SUNRISE: + after_func = lambda: sun.next_rising_utc(hass) + after_offset + else: + after_func = lambda: sun.next_setting_utc(hass) + after_offset + + def time_if(): + """ Validate time based if-condition """ + + now = dt_util.utcnow() + before = before_func() + after = after_func() + + 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 + + return True + + return time_if + + def trigger_sunrise(hass, action, offset): """ Trigger action at next sun rise. """ def next_rise(): @@ -103,3 +149,26 @@ def trigger_sunset(hass, action, offset): action() track_point_in_utc_time(hass, sunset_automation_listener, next_set()) + + +def _parse_offset(raw_offset): + if raw_offset is None: + return timedelta(0) + + 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 + + return offset diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py new file mode 100644 index 00000000000..8615538c42a --- /dev/null +++ b/homeassistant/components/automation/template.py @@ -0,0 +1,65 @@ +""" +homeassistant.components.automation.template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Offers template automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/components/automation/#template-trigger +""" +import logging + +from homeassistant.const import CONF_VALUE_TEMPLATE, EVENT_STATE_CHANGED +from homeassistant.exceptions import TemplateError +from homeassistant.util import template + +_LOGGER = logging.getLogger(__name__) + + +def trigger(hass, config, action): + """ Listen for state changes based on `config`. """ + value_template = config.get(CONF_VALUE_TEMPLATE) + + if value_template is None: + _LOGGER.error("Missing configuration key %s", CONF_VALUE_TEMPLATE) + return False + + # Local variable to keep track of if the action has already been triggered + already_triggered = False + + def event_listener(event): + """ Listens for state changes and calls action. """ + nonlocal already_triggered + template_result = _check_template(hass, value_template) + + # Check to see if template returns true + if template_result and not already_triggered: + already_triggered = True + action() + elif not template_result: + already_triggered = False + + hass.bus.listen(EVENT_STATE_CHANGED, event_listener) + return True + + +def if_action(hass, config): + """ Wraps action method with state based condition. """ + + value_template = config.get(CONF_VALUE_TEMPLATE) + + if value_template is None: + _LOGGER.error("Missing configuration key %s", CONF_VALUE_TEMPLATE) + return False + + return lambda: _check_template(hass, value_template) + + +def _check_template(hass, value_template): + """ Checks if result of template is true """ + try: + value = template.render(hass, value_template, {}) + except TemplateError: + _LOGGER.exception('Error parsing template') + return False + + return value.lower() == 'true' diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 2f05c6f4390..7fc2c0d40e2 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -4,7 +4,7 @@ homeassistant.components.automation.time Offers time listening automation rules. For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation.html#time-trigger +at https://home-assistant.io/components/automation/#time-trigger """ import logging diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index 28d1c8456f0..f0f800bd313 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -4,7 +4,7 @@ homeassistant.components.automation.zone Offers zone automation rules. For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation.html#zone-trigger +at https://home-assistant.io/components/automation/#zone-trigger """ import logging @@ -46,6 +46,7 @@ def trigger(hass, config, action): from_match = _in_zone(hass, zone_entity_id, from_s) if from_s else None to_match = _in_zone(hass, zone_entity_id, to_s) + # pylint: disable=too-many-boolean-expressions if event == EVENT_ENTER and not from_match and to_match or \ event == EVENT_LEAVE and from_match and not to_match: action() diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py new file mode 100644 index 00000000000..ccfd57aff8c --- /dev/null +++ b/homeassistant/components/binary_sensor/__init__.py @@ -0,0 +1,49 @@ +""" +homeassistant.components.binary_sensor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Component to interface with binary sensors (sensors which only know two states) +that can be monitored. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor/ +""" +import logging + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.const import (STATE_ON, STATE_OFF) + +DOMAIN = 'binary_sensor' +SCAN_INTERVAL = 30 + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + + +def setup(hass, config): + """ Track states and offer events for binary sensors. """ + component = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + + component.setup(config) + + return True + + +# pylint: disable=no-self-use +class BinarySensorDevice(Entity): + """ Represents a binary sensor. """ + + @property + def is_on(self): + """ True if the binary sensor is on. """ + return None + + @property + def state(self): + """ Returns the state of the binary sensor. """ + return STATE_ON if self.is_on else STATE_OFF + + @property + def friendly_state(self): + """ Returns the friendly state of the binary sensor. """ + return None diff --git a/homeassistant/components/binary_sensor/arest.py b/homeassistant/components/binary_sensor/arest.py new file mode 100644 index 00000000000..7eafca9f2ae --- /dev/null +++ b/homeassistant/components/binary_sensor/arest.py @@ -0,0 +1,107 @@ +""" +homeassistant.components.binary_sensor.arest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The arest sensor will consume an exposed aREST API of a device. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.arest/ +""" +from datetime import timedelta +import logging + +import requests + +from homeassistant.util import Throttle +from homeassistant.components.binary_sensor import BinarySensorDevice + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +CONF_RESOURCE = 'resource' +CONF_PIN = 'pin' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the aREST binary sensor. """ + + resource = config.get(CONF_RESOURCE) + pin = config.get(CONF_PIN) + + if None in (resource, pin): + _LOGGER.error('Not all required config keys present: %s', + ', '.join((CONF_RESOURCE, CONF_PIN))) + return False + + try: + response = requests.get(resource, timeout=10).json() + except requests.exceptions.MissingSchema: + _LOGGER.error('Missing resource or schema in configuration. ' + 'Add http:// to your URL.') + return False + except requests.exceptions.ConnectionError: + _LOGGER.error('No route to device at %s. ' + 'Please check the IP address in the configuration file.', + resource) + return False + + arest = ArestData(resource, pin) + + add_devices([ArestBinarySensor(arest, + resource, + config.get('name', response['name']), + pin)]) + + +# pylint: disable=too-many-instance-attributes, too-many-arguments +class ArestBinarySensor(BinarySensorDevice): + """ Implements an aREST binary sensor for a pin. """ + + def __init__(self, arest, resource, name, pin): + self.arest = arest + self._resource = resource + self._name = name + self._pin = pin + self.update() + + if self._pin is not None: + request = requests.get('{}/mode/{}/i'.format + (self._resource, self._pin), timeout=10) + if request.status_code is not 200: + _LOGGER.error("Can't set mode. Is device offline?") + + @property + def name(self): + """ The name of the binary sensor. """ + return self._name + + @property + def is_on(self): + """ True if the binary sensor is on. """ + return bool(self.arest.data.get('state')) + + def update(self): + """ Gets the latest data from aREST API. """ + self.arest.update() + + +# pylint: disable=too-few-public-methods +class ArestData(object): + """ Class for handling the data retrieval for pins. """ + + def __init__(self, resource, pin): + self._resource = resource + self._pin = pin + self.data = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from aREST device. """ + try: + response = requests.get('{}/digital/{}'.format( + self._resource, self._pin), timeout=10) + self.data = {'state': response.json()['return_value']} + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device '%s'. Is device offline?", + self._resource) diff --git a/homeassistant/components/binary_sensor/demo.py b/homeassistant/components/binary_sensor/demo.py new file mode 100644 index 00000000000..087d7405d9b --- /dev/null +++ b/homeassistant/components/binary_sensor/demo.py @@ -0,0 +1,37 @@ +""" +homeassistant.components.binary_sensor.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Demo platform that has two fake binary sensors. +""" +from homeassistant.components.binary_sensor import BinarySensorDevice + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Demo binary sensors. """ + add_devices([ + DemoBinarySensor('Basement Floor Wet', False), + DemoBinarySensor('Movement Backyard', True), + ]) + + +class DemoBinarySensor(BinarySensorDevice): + """ A Demo binary sensor. """ + + def __init__(self, name, state): + self._name = name + self._state = state + + @property + def should_poll(self): + """ No polling needed for a demo binary sensor. """ + return False + + @property + def name(self): + """ Returns the name of the binary sensor. """ + return self._name + + @property + def is_on(self): + """ True if the binary sensor is on. """ + return self._state diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py new file mode 100644 index 00000000000..916f1226c82 --- /dev/null +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -0,0 +1,84 @@ +""" +homeassistant.components.binary_sensor.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to configure a MQTT binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.mqtt/ +""" +import logging + +from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.util import template +import homeassistant.components.mqtt as mqtt + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'MQTT Binary sensor' +DEFAULT_QOS = 0 +DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_OFF = 'OFF' + +DEPENDENCIES = ['mqtt'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Add MQTT binary sensor. """ + + if config.get('state_topic') is None: + _LOGGER.error('Missing required variable: state_topic') + return False + + add_devices([MqttBinarySensor( + hass, + config.get('name', DEFAULT_NAME), + config.get('state_topic', None), + config.get('qos', DEFAULT_QOS), + config.get('payload_on', DEFAULT_PAYLOAD_ON), + config.get('payload_off', DEFAULT_PAYLOAD_OFF), + config.get(CONF_VALUE_TEMPLATE))]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MqttBinarySensor(BinarySensorDevice): + """ Represents a binary sensor that is updated by MQTT. """ + def __init__(self, hass, name, state_topic, qos, payload_on, payload_off, + value_template): + self._hass = hass + self._name = name + self._state = False + self._state_topic = state_topic + self._payload_on = payload_on + self._payload_off = payload_off + self._qos = qos + + def message_received(topic, payload, qos): + """ A new MQTT message has been received. """ + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) + if payload == self._payload_on: + self._state = True + self.update_ha_state() + elif payload == self._payload_off: + self._state = False + self.update_ha_state() + + mqtt.subscribe(hass, self._state_topic, message_received, self._qos) + + @property + def should_poll(self): + """ No polling needed. """ + return False + + @property + def name(self): + """ The name of the binary sensor. """ + return self._name + + @property + def is_on(self): + """ True if the binary sensor is on. """ + return self._state diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py new file mode 100644 index 00000000000..6cb6ede5e50 --- /dev/null +++ b/homeassistant/components/binary_sensor/rest.py @@ -0,0 +1,144 @@ +""" +homeassistant.components.binary_sensor.rest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The rest binary sensor will consume responses sent by an exposed REST API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rest/ +""" +from datetime import timedelta +import logging +import requests + +from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.util import template, Throttle +from homeassistant.components.binary_sensor import BinarySensorDevice + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'REST Binary Sensor' +DEFAULT_METHOD = 'GET' + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +# pylint: disable=unused-variable +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the REST binary sensor. """ + + use_get = False + use_post = False + + resource = config.get('resource', None) + method = config.get('method', DEFAULT_METHOD) + payload = config.get('payload', None) + verify_ssl = config.get('verify_ssl', True) + + if method == 'GET': + use_get = True + elif method == 'POST': + use_post = True + + try: + if use_get: + response = requests.get(resource, timeout=10, verify=verify_ssl) + elif use_post: + response = requests.post(resource, data=payload, timeout=10, + verify=verify_ssl) + if not response.ok: + _LOGGER.error("Response status is '%s'", response.status_code) + return False + except requests.exceptions.MissingSchema: + _LOGGER.error("Missing resource or schema in configuration. " + "Add http:// or https:// to your URL") + return False + except requests.exceptions.ConnectionError: + _LOGGER.error('No route to resource/endpoint: %s', resource) + return False + + if use_get: + rest = RestDataGet(resource, verify_ssl) + elif use_post: + rest = RestDataPost(resource, payload, verify_ssl) + + add_devices([RestBinarySensor(hass, + rest, + config.get('name', DEFAULT_NAME), + config.get(CONF_VALUE_TEMPLATE))]) + + +# pylint: disable=too-many-arguments +class RestBinarySensor(BinarySensorDevice): + """ Implements a REST binary sensor. """ + + def __init__(self, hass, rest, name, value_template): + self._hass = hass + self.rest = rest + self._name = name + self._state = False + self._value_template = value_template + self.update() + + @property + def name(self): + """ The name of the binary sensor. """ + return self._name + + @property + def is_on(self): + """ True if the binary sensor is on. """ + if self.rest.data is False: + return False + else: + if self._value_template is not None: + self.rest.data = template.render_with_possible_json_value( + self._hass, self._value_template, self.rest.data, False) + return bool(int(self.rest.data)) + + def update(self): + """ Gets the latest data from REST API and updates the state. """ + self.rest.update() + + +# pylint: disable=too-few-public-methods +class RestDataGet(object): + """ Class for handling the data retrieval with GET method. """ + + def __init__(self, resource, verify_ssl): + self._resource = resource + self._verify_ssl = verify_ssl + self.data = False + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from REST service with GET method. """ + try: + response = requests.get(self._resource, timeout=10, + verify=self._verify_ssl) + self.data = response.text + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to resource/endpoint: %s", self._resource) + self.data = False + + +# pylint: disable=too-few-public-methods +class RestDataPost(object): + """ Class for handling the data retrieval with POST method. """ + + def __init__(self, resource, payload, verify_ssl): + self._resource = resource + self._payload = payload + self._verify_ssl = verify_ssl + self.data = False + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from REST service with POST method. """ + try: + response = requests.post(self._resource, data=self._payload, + timeout=10, verify=self._verify_ssl) + self.data = response.text + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to resource/endpoint: %s", self._resource) + self.data = False diff --git a/homeassistant/components/browser.py b/homeassistant/components/browser.py index c5a55afad40..88548e2a1b3 100644 --- a/homeassistant/components/browser.py +++ b/homeassistant/components/browser.py @@ -1,12 +1,13 @@ """ homeassistant.components.browser ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides functionality to launch a webbrowser on the host machine. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/browser/ """ DOMAIN = "browser" -DEPENDENCIES = [] SERVICE_BROWSE_URL = "browse_url" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index e34d4169fa2..fc5c739c888 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -4,38 +4,23 @@ homeassistant.components.camera ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Component to interface with various cameras. -The following features are supported: - - Returning recorded camera images and streams - - Proxying image requests via HA for external access - - Converting a still image url into a live video stream - -Upcoming features - - Recording - - Snapshot - - Motion Detection Recording(for supported cameras) - - Automatic Configuration(for supported cameras) - - Creation of child entities for supported functions - - Collating motion event images passed via FTP into time based events - - A service for calling camera functions - - Camera movement(panning) - - Zoom - - Light/Nightvision toggling - - Support for more devices - - Expanded documentation +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/camera/ """ -import requests import logging -import time import re +import time + +import requests + from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.const import ( ATTR_ENTITY_PICTURE, HTTP_NOT_FOUND, ATTR_ENTITY_ID, ) -from homeassistant.helpers.entity_component import EntityComponent - DOMAIN = 'camera' DEPENDENCIES = ['http'] @@ -74,7 +59,7 @@ MJPEG_START_HEADER = 'Content-type: {0}\r\n\r\n' # pylint: disable=too-many-branches def setup(hass, config): - """ Track states and offer events for sensors. """ + """ Track states and offer events for cameras. """ component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, @@ -96,16 +81,21 @@ def setup(hass, config): def _proxy_camera_image(handler, path_match, data): """ Proxies the camera image via the HA server. """ entity_id = path_match.group(ATTR_ENTITY_ID) + camera = component.entities.get(entity_id) - camera = None - if entity_id in component.entities.keys(): - camera = component.entities[entity_id] - - if camera: - response = camera.camera_image() - handler.wfile.write(response) - else: + if camera is None: handler.send_response(HTTP_NOT_FOUND) + handler.end_headers() + return + + response = camera.camera_image() + + if response is None: + handler.send_response(HTTP_NOT_FOUND) + handler.end_headers() + return + + handler.wfile.write(response) hass.http.register_path( 'GET', @@ -114,18 +104,16 @@ def setup(hass, config): # pylint: disable=unused-argument def _proxy_camera_mjpeg_stream(handler, path_match, data): - """ Proxies the camera image as an mjpeg stream via the HA server. + """ + Proxies the camera image as an mjpeg stream via the HA server. This function takes still images from the IP camera and turns them into an MJPEG stream. This means that HA can return a live video stream even with only a still image URL available. """ entity_id = path_match.group(ATTR_ENTITY_ID) + camera = component.entities.get(entity_id) - camera = None - if entity_id in component.entities.keys(): - camera = component.entities[entity_id] - - if not camera: + if camera is None: handler.send_response(HTTP_NOT_FOUND) handler.end_headers() return @@ -143,9 +131,9 @@ def setup(hass, config): # MJPEG_START_HEADER.format() while True: - img_bytes = camera.camera_image() - + if img_bytes is None: + continue headers_str = '\r\n'.join(( 'Content-length: {}'.format(len(img_bytes)), 'Content-type: image/jpeg', @@ -159,12 +147,12 @@ def setup(hass, config): handler.request.sendall( bytes('--jpgboundary\r\n', 'utf-8')) + time.sleep(0.5) + except (requests.RequestException, IOError): camera.is_streaming = False camera.update_ha_state() - camera.is_streaming = False - hass.http.register_path( 'GET', re.compile( @@ -175,7 +163,7 @@ def setup(hass, config): class Camera(Entity): - """ The base class for camera components """ + """ The base class for camera components. """ def __init__(self): self.is_streaming = False @@ -183,23 +171,23 @@ class Camera(Entity): @property # pylint: disable=no-self-use def is_recording(self): - """ Returns true if the device is recording """ + """ Returns true if the device is recording. """ return False @property # pylint: disable=no-self-use def brand(self): - """ Should return a string of the camera brand """ + """ Should return a string of the camera brand. """ return None @property # pylint: disable=no-self-use def model(self): - """ Returns string of camera model """ + """ Returns string of camera model. """ return None def camera_image(self): - """ Return bytes of camera image """ + """ Return bytes of camera image. """ raise NotImplementedError() @property diff --git a/homeassistant/components/camera/demo.py b/homeassistant/components/camera/demo.py new file mode 100644 index 00000000000..0ad992db86d --- /dev/null +++ b/homeassistant/components/camera/demo.py @@ -0,0 +1,37 @@ +""" +homeassistant.components.camera.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Demo platform that has a fake camera. +""" +import os +from homeassistant.components.camera import Camera +import homeassistant.util.dt as dt_util + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Demo camera. """ + add_devices([ + DemoCamera('Demo camera') + ]) + + +class DemoCamera(Camera): + """ A Demo camera. """ + + def __init__(self, name): + super().__init__() + self._name = name + + def camera_image(self): + """ Return a faked still image response. """ + now = dt_util.utcnow() + + image_path = os.path.join(os.path.dirname(__file__), + 'demo_{}.jpg'.format(now.second % 4)) + with open(image_path, 'rb') as file: + return file.read() + + @property + def name(self): + """ Return the name of this device. """ + return self._name diff --git a/homeassistant/components/camera/demo_0.jpg b/homeassistant/components/camera/demo_0.jpg new file mode 100644 index 00000000000..ff87d5179f8 Binary files /dev/null and b/homeassistant/components/camera/demo_0.jpg differ diff --git a/homeassistant/components/camera/demo_1.jpg b/homeassistant/components/camera/demo_1.jpg new file mode 100644 index 00000000000..06166fffa85 Binary files /dev/null and b/homeassistant/components/camera/demo_1.jpg differ diff --git a/homeassistant/components/camera/demo_2.jpg b/homeassistant/components/camera/demo_2.jpg new file mode 100644 index 00000000000..71356479ab0 Binary files /dev/null and b/homeassistant/components/camera/demo_2.jpg differ diff --git a/homeassistant/components/camera/demo_3.jpg b/homeassistant/components/camera/demo_3.jpg new file mode 100644 index 00000000000..06166fffa85 Binary files /dev/null and b/homeassistant/components/camera/demo_3.jpg differ diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 24a42cbf883..b210e1a2f1b 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -4,14 +4,14 @@ homeassistant.components.camera.foscam This component provides basic support for Foscam IP cameras. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.foscam.html +https://home-assistant.io/components/camera.foscam/ """ import logging -from homeassistant.helpers import validate_config -from homeassistant.components.camera import DOMAIN -from homeassistant.components.camera import Camera + import requests -import re + +from homeassistant.helpers import validate_config +from homeassistant.components.camera import DOMAIN, Camera _LOGGER = logging.getLogger(__name__) @@ -40,7 +40,7 @@ class FoscamCamera(Camera): 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=' \ + + 'cgi-bin/CGIProxy.fcgi?cmd=snapPicture2&usr=' \ + self._username + '&pwd=' + self._password self._name = device_info.get('name', 'Foscam Camera') @@ -50,17 +50,9 @@ class FoscamCamera(Camera): def camera_image(self): """ Return a still image reponse from the camera. """ - # send the request to snap a picture + # Send the request to snap a picture and return raw jpg data 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 diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 74d2d0102d3..c81febccc86 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -4,14 +4,15 @@ homeassistant.components.camera.generic Support for IP Cameras. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.generic.html +https://home-assistant.io/components/camera.generic/ """ import logging -from requests.auth import HTTPBasicAuth -from homeassistant.helpers import validate_config -from homeassistant.components.camera import DOMAIN -from homeassistant.components.camera import Camera + import requests +from requests.auth import HTTPBasicAuth + +from homeassistant.helpers import validate_config +from homeassistant.components.camera import DOMAIN, Camera _LOGGER = logging.getLogger(__name__) @@ -40,13 +41,21 @@ class GenericCamera(Camera): self._still_image_url = device_info['still_image_url'] def camera_image(self): - """ Return a still image reponse from the camera. """ + """ Return a still image response from the camera. """ if self._username and self._password: - response = requests.get( - self._still_image_url, - auth=HTTPBasicAuth(self._username, self._password)) + try: + response = requests.get( + self._still_image_url, + auth=HTTPBasicAuth(self._username, self._password)) + except requests.exceptions.RequestException as error: + _LOGGER.error('Error getting camera image: %s', error) + return None else: - response = requests.get(self._still_image_url) + try: + response = requests.get(self._still_image_url) + except requests.exceptions.RequestException as error: + _LOGGER.error('Error getting camera image: %s', error) + return None return response.content diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py new file mode 100644 index 00000000000..0d59c8d60c7 --- /dev/null +++ b/homeassistant/components/camera/mjpeg.py @@ -0,0 +1,72 @@ +""" +homeassistant.components.camera.mjpeg +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for IP Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.mjpeg/ +""" +from contextlib import closing +import logging + +import requests +from requests.auth import HTTPBasicAuth + +from homeassistant.helpers import validate_config +from homeassistant.components.camera import DOMAIN, Camera + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Adds a mjpeg IP Camera. """ + if not validate_config({DOMAIN: config}, {DOMAIN: ['mjpeg_url']}, + _LOGGER): + return None + + add_devices_callback([MjpegCamera(config)]) + + +# pylint: disable=too-many-instance-attributes +class MjpegCamera(Camera): + """ + A generic implementation of an IP camera that is reachable over a URL. + """ + + def __init__(self, device_info): + super().__init__() + self._name = device_info.get('name', 'Mjpeg Camera') + self._username = device_info.get('username') + self._password = device_info.get('password') + self._mjpeg_url = device_info['mjpeg_url'] + + def camera_image(self): + """ Return a still image response from the camera. """ + + def process_response(response): + """ Take in a response object, return the jpg from it. """ + data = b'' + for chunk in response.iter_content(1024): + data += chunk + jpg_start = data.find(b'\xff\xd8') + jpg_end = data.find(b'\xff\xd9') + if jpg_start != -1 and jpg_end != -1: + jpg = data[jpg_start:jpg_end + 2] + return jpg + + if self._username and self._password: + with closing(requests.get(self._mjpeg_url, + auth=HTTPBasicAuth(self._username, + self._password), + stream=True)) as response: + return process_response(response) + else: + with closing(requests.get(self._mjpeg_url, + stream=True)) as response: + return process_response(response) + + @property + def name(self): + """ Return the name of this device. """ + return self._name diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 8bec580abf9..515daffc71c 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -15,7 +15,6 @@ from homeassistant.helpers import generate_entity_id from homeassistant.const import EVENT_TIME_CHANGED DOMAIN = "configurator" -DEPENDENCIES = [] ENTITY_ID_FORMAT = DOMAIN + ".{}" SERVICE_CONFIGURE = "configure" diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index fd2ad60d211..18ddf8fcc8d 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -1,19 +1,20 @@ """ homeassistant.components.conversation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides functionality to have conversations with Home Assistant. -This is more a proof of concept. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/conversation/ """ import logging import re + from homeassistant import core from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) DOMAIN = "conversation" -DEPENDENCIES = [] SERVICE_PROCESS = "process" @@ -21,9 +22,13 @@ ATTR_TEXT = "text" REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') +REQUIREMENTS = ['fuzzywuzzy==0.8.0'] + def setup(hass, config): """ Registers the process service. """ + from fuzzywuzzy import process as fuzzyExtract + logger = logging.getLogger(__name__) def process(service): @@ -42,9 +47,11 @@ def setup(hass, config): name, command = match.groups() - entity_ids = [ - state.entity_id for state in hass.states.all() - if state.name.lower() == name] + entities = {state.entity_id: state.name for state in hass.states.all()} + + entity_ids = fuzzyExtract.extractOne(name, + entities, + score_cutoff=65)[2] if not entity_ids: logger.error( diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 388a869ae0c..8b4b3fcce6c 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -10,14 +10,26 @@ import homeassistant.core as ha import homeassistant.bootstrap as bootstrap import homeassistant.loader as loader from homeassistant.const import ( - CONF_PLATFORM, ATTR_ENTITY_PICTURE, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME) + CONF_PLATFORM, ATTR_ENTITY_ID) DOMAIN = "demo" -DEPENDENCIES = ['introduction', 'conversation'] +DEPENDENCIES = ['conversation', 'introduction', 'zone'] COMPONENTS_WITH_DEMO_PLATFORM = [ - 'switch', 'light', 'sensor', 'thermostat', 'media_player', 'notify'] + 'alarm_control_panel', + 'binary_sensor', + 'camera', + 'device_tracker', + 'light', + 'lock', + 'media_player', + 'notify', + 'rollershutter', + 'sensor', + 'switch', + 'thermostat', +] def setup(hass, config): @@ -41,9 +53,10 @@ def setup(hass, config): bootstrap.setup_component(hass, 'sun') # Setup demo platforms + demo_config = config.copy() for component in COMPONENTS_WITH_DEMO_PLATFORM: - bootstrap.setup_component( - hass, component, {component: {CONF_PLATFORM: 'demo'}}) + demo_config[component] = {CONF_PLATFORM: 'demo'} + bootstrap.setup_component(hass, component, demo_config) # Setup room groups lights = sorted(hass.states.entity_ids('light')) @@ -54,23 +67,6 @@ def setup(hass, config): group.setup_group(hass, 'bedroom', [lights[0], switches[1], media_players[0]]) - # Setup IP Camera - bootstrap.setup_component( - hass, 'camera', - {'camera': { - 'platform': 'generic', - 'name': 'IP Camera', - 'still_image_url': 'http://home-assistant.io/demo/webcam.jpg', - }}) - - # Setup alarm_control_panel - bootstrap.setup_component( - hass, 'alarm_control_panel', - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'Test Alarm', - }}) - # Setup scripts bootstrap.setup_component( hass, 'script', @@ -110,25 +106,6 @@ def setup(hass, config): }}, ]}) - # Setup fake device tracker - hass.states.set("device_tracker.paulus", "home", - {ATTR_ENTITY_PICTURE: - "http://graph.facebook.com/297400035/picture", - ATTR_FRIENDLY_NAME: 'Paulus'}) - hass.states.set("device_tracker.anne_therese", "not_home", - {ATTR_FRIENDLY_NAME: 'Anne Therese', - 'latitude': hass.config.latitude + 0.002, - 'longitude': hass.config.longitude + 0.002}) - - hass.states.set("group.all_devices", "home", - { - "auto": True, - ATTR_ENTITY_ID: [ - "device_tracker.paulus", - "device_tracker.anne_therese" - ] - }) - # Setup configurator configurator_ids = [] diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index 67da9e26a82..4acf60bc0a2 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -1,9 +1,11 @@ """ homeassistant.components.device_sun_light_trigger ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Provides functionality to turn on lights based on the state of the sun and +devices. -Provides functionality to turn on lights based on -the state of the sun and devices. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/device_sun_light_trigger/ """ import logging from datetime import timedelta diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 9fe18585418..204d845084c 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,25 +1,10 @@ """ 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 +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/device_tracker/ """ # pylint: disable=too-many-instance-attributes, too-many-arguments # pylint: disable=too-many-locals diff --git a/homeassistant/components/device_tracker/actiontec.py b/homeassistant/components/device_tracker/actiontec.py index a2f94f34c3a..f363acf1902 100644 --- a/homeassistant/components/device_tracker/actiontec.py +++ b/homeassistant/components/device_tracker/actiontec.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning an Actiontec MI424WR (Verizon FIOS) router for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.actiontec.html +https://home-assistant.io/components/device_tracker.actiontec/ """ import logging from datetime import timedelta @@ -17,20 +17,19 @@ import telnetlib import homeassistant.util.dt as dt_util from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers import validate_config -from homeassistant.util import Throttle, convert +from homeassistant.util import Throttle from homeassistant.components.device_tracker import DOMAIN # Return cached results if last scan was less then this time ago MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) -# Interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" - _LOGGER = logging.getLogger(__name__) _LEASES_REGEX = re.compile( r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})' + - r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))') + r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' + + r'\svalid\sfor:\s(?P(-?\d+))' + + r'\ssec') # pylint: disable=unused-argument @@ -40,9 +39,7 @@ def get_scanner(hass, config): {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, _LOGGER): return None - scanner = ActiontecDeviceScanner(config[DOMAIN]) - return scanner if scanner.success_init else None Device = namedtuple("Device", ["mac", "ip", "last_update"]) @@ -58,19 +55,11 @@ class ActiontecDeviceScanner(object): self.host = config[CONF_HOST] self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0) - self.home_interval = timedelta(minutes=minutes) - self.lock = threading.Lock() - self.last_results = [] - - # Test the router is accessible data = self.get_actiontec_data() self.success_init = data is not None _LOGGER.info("actiontec scanner initialized") - if self.home_interval: - _LOGGER.info("home_interval set to: %s", self.home_interval) def scan_devices(self): """ @@ -100,27 +89,13 @@ class ActiontecDeviceScanner(object): return False with self.lock: - exclude_targets = set() - exclude_target_list = [] now = dt_util.now() - if self.home_interval: - for host in self.last_results: - if host.last_update + self.home_interval > now: - exclude_targets.add(host) - if len(exclude_targets) > 0: - exclude_target_list = [t.ip for t in exclude_targets] - actiontec_data = self.get_actiontec_data() if not actiontec_data: return False - self.last_results = [] - for client in exclude_target_list: - if client in actiontec_data: - actiontec_data.pop(client) - for name, data in actiontec_data.items(): - device = Device(data['mac'], name, now) - self.last_results.append(device) - self.last_results.extend(exclude_targets) + self.last_results = [Device(data['mac'], name, now) + for name, data in actiontec_data.items() + if data['timevalid'] > -60] _LOGGER.info("actiontec scan successful") return True @@ -153,6 +128,7 @@ class ActiontecDeviceScanner(object): if match is not None: devices[match.group('ip')] = { 'ip': match.group('ip'), - 'mac': match.group('mac').upper() + 'mac': match.group('mac').upper(), + 'timevalid': int(match.group('timevalid')) } return devices diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index d46264fa264..82183e1495c 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning a Aruba Access Point for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.aruba.html +https://home-assistant.io/components/device_tracker.aruba/ """ import logging from datetime import timedelta diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 5284d45835b..b90e1ee4448 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -4,32 +4,8 @@ homeassistant.components.device_tracker.asuswrt Device tracker platform that supports scanning a ASUSWRT router for device presence. -This device tracker needs telnet to be enabled on the router. - -Configuration: - -To use the ASUSWRT tracker you will need to add something like the following -to your configuration.yaml file. - -device_tracker: - platform: asuswrt - host: YOUR_ROUTER_IP - username: YOUR_ADMIN_USERNAME - password: YOUR_ADMIN_PASSWORD - -Variables: - -host -*Required -The IP address of your router, e.g. 192.168.1.1. - -username -*Required -The username of an user with administrative privileges, usually 'admin'. - -password -*Required -The password for your given admin account. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.asuswrt/ """ import logging from datetime import timedelta @@ -158,13 +134,16 @@ class AsusWrtDeviceScanner(object): for lease in leases_result: match = _LEASES_REGEX.search(lease.decode('utf-8')) + if not match: + _LOGGER.warning("Could not parse lease row: %s", lease) + continue + # For leases where the client doesn't set a hostname, ensure # it is blank and not '*', which breaks the entity_id down # the line - if match: - host = match.group('host') - if host == '*': - host = '' + host = match.group('host') + if host == '*': + host = '' devices[match.group('ip')] = { 'host': host, @@ -175,6 +154,9 @@ class AsusWrtDeviceScanner(object): for neighbor in neighbors: match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) - if match and match.group('ip') in devices: + if not match: + _LOGGER.warning("Could not parse neighbor row: %s", neighbor) + continue + if match.group('ip') in devices: devices[match.group('ip')]['status'] = match.group('status') return devices diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py index d8734a55a17..268c4e5a22f 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning a DD-WRT router for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.ddwrt.html +https://home-assistant.io/components/device_tracker.ddwrt/ """ import logging from datetime import timedelta diff --git a/homeassistant/components/device_tracker/demo.py b/homeassistant/components/device_tracker/demo.py index e8cf906be9e..43b7915ee3c 100644 --- a/homeassistant/components/device_tracker/demo.py +++ b/homeassistant/components/device_tracker/demo.py @@ -1,7 +1,6 @@ """ homeassistant.components.device_tracker.demo ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Demo platform for the device tracker. device_tracker: diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py new file mode 100644 index 00000000000..73c2e98792f --- /dev/null +++ b/homeassistant/components/device_tracker/fritz.py @@ -0,0 +1,122 @@ +""" +homeassistant.components.device_tracker.fritz +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a FRITZ!Box router for device +presence. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.fritz/ +""" +import logging +from datetime import timedelta + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import validate_config +from homeassistant.util import Throttle +from homeassistant.components.device_tracker import DOMAIN + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) + +_LOGGER = logging.getLogger(__name__) + + +# noinspection PyUnusedLocal +def get_scanner(hass, config): + """ Validates config and returns FritzBoxScanner. """ + if not validate_config(config, + {DOMAIN: []}, + _LOGGER): + return None + + scanner = FritzBoxScanner(config[DOMAIN]) + return scanner if scanner.success_init else None + + +# pylint: disable=too-many-instance-attributes +class FritzBoxScanner(object): + """ + This class queries a FRITZ!Box router. It is using the + fritzconnection library for communication with the router. + + The API description can be found under: + https://pypi.python.org/pypi/fritzconnection/0.4.6 + + This scanner retrieves the list of known hosts and checks their + corresponding states (on, or off). + + Due to a bug of the fritzbox api (router side) it is not possible + to track more than 16 hosts. + """ + def __init__(self, config): + self.last_results = [] + self.host = '169.254.1.1' # This IP is valid for all fritzboxes + self.username = 'admin' + self.password = '' + self.success_init = True + + # Try to import the fritzconnection library + try: + # noinspection PyPackageRequirements,PyUnresolvedReferences + import fritzconnection as fc + except ImportError: + _LOGGER.exception("""Failed to import Python library + fritzconnection. Please run + /setup to install it.""") + self.success_init = False + return + + # Check for user specific configuration + if CONF_HOST in config.keys(): + self.host = config[CONF_HOST] + if CONF_USERNAME in config.keys(): + self.username = config[CONF_USERNAME] + if CONF_PASSWORD in config.keys(): + self.password = config[CONF_PASSWORD] + + # Establish a connection to the FRITZ!Box + try: + self.fritz_box = fc.FritzHosts(address=self.host, + user=self.username, + password=self.password) + except (ValueError, TypeError): + self.fritz_box = None + + # At this point it is difficult to tell if a connection is established. + # So just check for null objects ... + if self.fritz_box is None or not self.fritz_box.modelname: + self.success_init = False + + if self.success_init: + _LOGGER.info("Successfully connected to %s", + self.fritz_box.modelname) + self._update_info() + else: + _LOGGER.error("Failed to establish connection to FRITZ!Box " + "with IP: %s", self.host) + + def scan_devices(self): + """ Scan for new devices and return a list of found device ids. """ + self._update_info() + active_hosts = [] + for known_host in self.last_results: + if known_host["status"] == "1": + active_hosts.append(known_host["mac"]) + return active_hosts + + def get_device_name(self, mac): + """ Returns the name of the given device or None if is not known. """ + ret = self.fritz_box.get_specific_host_entry(mac)["NewHostName"] + if ret == {}: + return None + return ret + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ Retrieves latest information from the FRITZ!Box. """ + if not self.success_init: + return False + + _LOGGER.info("Scanning") + self.last_results = self.fritz_box.get_hosts_info() + return True diff --git a/homeassistant/components/device_tracker/geofancy.py b/homeassistant/components/device_tracker/geofancy.py deleted file mode 100644 index 91d3978326b..00000000000 --- a/homeassistant/components/device_tracker/geofancy.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -homeassistant.components.device_tracker.geofancy -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Geofancy platform for the device tracker. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.geofancy.html -""" - -from homeassistant.const import ( - HTTP_UNPROCESSABLE_ENTITY, HTTP_INTERNAL_SERVER_ERROR) - -DEPENDENCIES = ['http'] - -_SEE = 0 - -URL_API_GEOFANCY_ENDPOINT = "/api/geofancy" - - -def setup_scanner(hass, config, see): - """ Set up an endpoint for the Geofancy app. """ - - # Use a global variable to keep setup_scanner compact when using a callback - global _SEE - _SEE = see - - # POST would be semantically better, but that currently does not work - # since Geofancy sends the data as key1=value1&key2=value2 - # in the request body, while Home Assistant expects json there. - - hass.http.register_path( - 'GET', URL_API_GEOFANCY_ENDPOINT, _handle_get_api_geofancy) - - return True - - -def _handle_get_api_geofancy(handler, path_match, data): - """ Geofancy message received. """ - - if not isinstance(data, dict): - handler.write_json_message( - "Error while parsing Geofancy message.", - HTTP_INTERNAL_SERVER_ERROR) - return - if 'latitude' not in data or 'longitude' not in data: - handler.write_json_message( - "Location not specified.", - HTTP_UNPROCESSABLE_ENTITY) - return - if 'device' not in data or 'id' not in data: - handler.write_json_message( - "Device id or location id not specified.", - HTTP_UNPROCESSABLE_ENTITY) - return - - try: - gps_coords = (float(data['latitude']), float(data['longitude'])) - except ValueError: - # If invalid latitude / longitude format - handler.write_json_message( - "Invalid latitude / longitude format.", - HTTP_UNPROCESSABLE_ENTITY) - return - - # entity id's in Home Assistant must be alphanumerical - device_uuid = data['device'] - device_entity_id = device_uuid.replace('-', '') - - _SEE(dev_id=device_entity_id, gps=gps_coords, location_name=data['id']) - - handler.write_json_message("Geofancy message processed") diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py new file mode 100644 index 00000000000..76046552551 --- /dev/null +++ b/homeassistant/components/device_tracker/icloud.py @@ -0,0 +1,87 @@ +""" +homeassistant.components.device_tracker.icloud +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning iCloud devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.icloud/ +""" +import logging + +import re +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.event import track_utc_time_change + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pyicloud==0.7.2'] + +CONF_INTERVAL = 'interval' +DEFAULT_INTERVAL = 8 + + +def setup_scanner(hass, config, see): + """ Set up the iCloud Scanner. """ + from pyicloud import PyiCloudService + from pyicloud.exceptions import PyiCloudFailedLoginException + from pyicloud.exceptions import PyiCloudNoDevicesException + + # Get the username and password from the configuration + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + if username is None or password is None: + _LOGGER.error('Must specify a username and password') + return False + + try: + _LOGGER.info('Logging into iCloud Account') + # Attempt the login to iCloud + api = PyiCloudService(username, + password, + verify=True) + except PyiCloudFailedLoginException as error: + _LOGGER.exception('Error logging into iCloud Service: %s', error) + return False + + def keep_alive(now): + """ Keeps authenticating iCloud connection. """ + api.authenticate() + _LOGGER.info("Authenticate against iCloud") + + track_utc_time_change(hass, keep_alive, second=0) + + def update_icloud(now): + """ Authenticate against iCloud and scan for devices. """ + try: + # The session timeouts if we are not using it so we + # have to re-authenticate. This will send an email. + api.authenticate() + # Loop through every device registered with the iCloud account + for device in api.devices: + status = device.status() + location = device.location() + # If the device has a location add it. If not do nothing + if location: + see( + dev_id=re.sub(r"(\s|\W|')", + '', + status['name']), + host_name=status['name'], + gps=(location['latitude'], location['longitude']), + battery=status['batteryLevel']*100, + gps_accuracy=location['horizontalAccuracy'] + ) + else: + # No location found for the device so continue + continue + except PyiCloudNoDevicesException: + _LOGGER.info('No iCloud Devices found!') + + track_utc_time_change( + hass, update_icloud, + minute=range(0, 60, config.get(CONF_INTERVAL, DEFAULT_INTERVAL)), + second=0 + ) + + return True diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py new file mode 100644 index 00000000000..e7532d1075d --- /dev/null +++ b/homeassistant/components/device_tracker/locative.py @@ -0,0 +1,105 @@ +""" +homeassistant.components.device_tracker.locative +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Locative platform for the device tracker. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.locative/ +""" +import logging +from functools import partial + +from homeassistant.const import ( + HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) +from homeassistant.components.device_tracker import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +URL_API_LOCATIVE_ENDPOINT = "/api/locative" + + +def setup_scanner(hass, config, see): + """ Set up an endpoint for the Locative app. """ + + # POST would be semantically better, but that currently does not work + # since Locative sends the data as key1=value1&key2=value2 + # in the request body, while Home Assistant expects json there. + + hass.http.register_path( + 'GET', URL_API_LOCATIVE_ENDPOINT, + partial(_handle_get_api_locative, hass, see)) + + return True + + +def _handle_get_api_locative(hass, see, handler, path_match, data): + """ Locative message received. """ + + if not _check_data(handler, data): + return + + device = data['device'].replace('-', '') + location_name = data['id'].lower() + direction = data['trigger'] + + if direction == 'enter': + see(dev_id=device, location_name=location_name) + handler.write_text("Setting location to {}".format(location_name)) + + elif direction == 'exit': + current_state = hass.states.get( + "{}.{}".format(DOMAIN, device)).state + + if current_state == location_name: + see(dev_id=device, location_name=STATE_NOT_HOME) + handler.write_text("Setting location to not home") + else: + # Ignore the message if it is telling us to exit a zone that we + # aren't currently in. This occurs when a zone is entered before + # the previous zone was exited. The enter message will be sent + # first, then the exit message will be sent second. + handler.write_text( + 'Ignoring exit from {} (already in {})'.format( + location_name, current_state)) + + elif direction == 'test': + # In the app, a test message can be sent. Just return something to + # the user to let them know that it works. + handler.write_text("Received test message.") + + else: + handler.write_text( + "Received unidentified message: {}".format(direction), + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.error("Received unidentified message from Locative: %s", + direction) + + +def _check_data(handler, data): + if 'latitude' not in data or 'longitude' not in data: + handler.write_text("Latitude and longitude not specified.", + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.error("Latitude and longitude not specified.") + return False + + if 'device' not in data: + handler.write_text("Device id not specified.", + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.error("Device id not specified.") + return False + + if 'id' not in data: + handler.write_text("Location id not specified.", + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.error("Location id not specified.") + return False + + if 'trigger' not in data: + handler.write_text("Trigger is not specified.", + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.error("Trigger is not specified.") + return False + + return True diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index 2ce032f90fd..8b3e4eeb3c8 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning a OpenWRT router for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.luci.html +https://home-assistant.io/components/device_tracker.luci/ """ import logging import json diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py index f78cb3420f5..929deaae669 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -4,7 +4,7 @@ homeassistant.components.device_tracker.mqtt MQTT platform for the device tracker. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.mqtt.html +https://home-assistant.io/components/device_tracker.mqtt/ """ import logging from homeassistant import util diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 2d138cf5c70..5d20e98e992 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning a Netgear router for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.netgear.html +https://home-assistant.io/components/device_tracker.netgear/ """ import logging from datetime import timedelta diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index fe6b814b96f..bc8e8768be0 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -4,7 +4,7 @@ homeassistant.components.device_tracker.nmap Device tracker platform that supports scanning a network with nmap. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.nmap_scanner.html +https://home-assistant.io/components/device_tracker.nmap_scanner/ """ import logging from datetime import timedelta @@ -98,7 +98,7 @@ class NmapDeviceScanner(object): from nmap import PortScanner, PortScannerError scanner = PortScanner() - options = "-F --host-timeout 5" + options = "-F --host-timeout 5s" if self.home_interval: boundary = dt_util.now() - self.home_interval diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 78fd42f1566..b98c3a1636c 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -4,7 +4,7 @@ homeassistant.components.device_tracker.owntracks OwnTracks platform for the device tracker. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.owntracks.html +https://home-assistant.io/components/device_tracker.owntracks/ """ import json import logging diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 21bcdfb2a93..868f701673a 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -5,7 +5,7 @@ Device tracker platform that supports fetching WiFi associations through SNMP. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.snmp.html +https://home-assistant.io/components/device_tracker.snmp/ """ import logging from datetime import timedelta @@ -45,9 +45,12 @@ class SnmpScanner(object): This class queries any SNMP capable Acces Point for connected devices. """ def __init__(self, config): - self.host = config[CONF_HOST] - self.community = config[CONF_COMMUNITY] - self.baseoid = config[CONF_BASEOID] + from pysnmp.entity.rfc3413.oneliner import cmdgen + self.snmp = cmdgen.CommandGenerator() + + self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161)) + self.community = cmdgen.CommunityData(config[CONF_COMMUNITY]) + self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID]) self.lock = threading.Lock() @@ -91,16 +94,11 @@ class SnmpScanner(object): def get_snmp_data(self): """ Fetch mac addresses from WAP via SNMP. """ - from pysnmp.entity.rfc3413.oneliner import cmdgen devices = [] - snmp = cmdgen.CommandGenerator() - errindication, errstatus, errindex, restable = snmp.nextCmd( - cmdgen.CommunityData(self.community), - cmdgen.UdpTransportTarget((self.host, 161)), - cmdgen.MibVariable(self.baseoid) - ) + errindication, errstatus, errindex, restable = self.snmp.nextCmd( + self.community, self.host, self.baseoid) if errindication: _LOGGER.error("SNMPLIB error: %s", errindication) diff --git a/homeassistant/components/device_tracker/thomson.py b/homeassistant/components/device_tracker/thomson.py index c6679e6c320..657bb910da2 100644 --- a/homeassistant/components/device_tracker/thomson.py +++ b/homeassistant/components/device_tracker/thomson.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning a THOMSON router for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.thomson.html +https://home-assistant.io/components/device_tracker.thomson/ """ import logging from datetime import timedelta diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index df0c9c8d93d..c87a50f0981 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning a Tomato router for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.tomato.html +https://home-assistant.io/components/device_tracker.tomato/ """ import logging import json diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 3769229f101..46556b3eca4 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -5,7 +5,7 @@ Device tracker platform that supports scanning a TP-Link router for device presence. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.tplink.html +https://home-assistant.io/components/device_tracker.tplink/ """ import base64 import logging diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py new file mode 100644 index 00000000000..0355680a31d --- /dev/null +++ b/homeassistant/components/device_tracker/ubus.py @@ -0,0 +1,173 @@ +""" +homeassistant.components.device_tracker.ubus +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a OpenWRT router for device +presence. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.ubus/ +""" +import logging +import json +from datetime import timedelta +import re +import threading +import requests + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import validate_config +from homeassistant.util import Throttle +from homeassistant.components.device_tracker import DOMAIN + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) + +_LOGGER = logging.getLogger(__name__) + + +def get_scanner(hass, config): + """ Validates config and returns a Luci scanner. """ + if not validate_config(config, + {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return None + + scanner = UbusDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +# pylint: disable=too-many-instance-attributes +class UbusDeviceScanner(object): + """ + This class queries a wireless router running OpenWrt firmware + for connected devices. Adapted from Tomato scanner. + + Configure your routers' ubus ACL based on following instructions: + + http://wiki.openwrt.org/doc/techref/ubus + + Read only access will be fine. + + To use this class you have to install rpcd-mod-file package + in your OpenWrt router: + + opkg install rpcd-mod-file + + """ + + def __init__(self, config): + host = config[CONF_HOST] + username, password = config[CONF_USERNAME], config[CONF_PASSWORD] + + self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") + self.lock = threading.Lock() + self.last_results = {} + self.url = 'http://{}/ubus'.format(host) + + self.session_id = _get_session_id(self.url, username, password) + self.hostapd = [] + self.leasefile = None + self.mac2name = None + self.success_init = self.session_id is not None + + def scan_devices(self): + """ + Scans for new devices and return a list containing found device ids. + """ + + self._update_info() + + return self.last_results + + def get_device_name(self, device): + """ Returns the name of the given device or None if we don't know. """ + + with self.lock: + if self.leasefile is None: + result = _req_json_rpc(self.url, self.session_id, + 'call', 'uci', 'get', + config="dhcp", type="dnsmasq") + if result: + values = result["values"].values() + self.leasefile = next(iter(values))["leasefile"] + else: + return + + if self.mac2name is None: + result = _req_json_rpc(self.url, self.session_id, + 'call', 'file', 'read', + path=self.leasefile) + if result: + self.mac2name = dict() + for line in result["data"].splitlines(): + hosts = line.split(" ") + self.mac2name[hosts[1].upper()] = hosts[3] + else: + # Error, handled in the _req_json_rpc + return + + return self.mac2name.get(device.upper(), None) + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the Luci router is up to date. + Returns boolean if scanning successful. + """ + if not self.success_init: + return False + + with self.lock: + _LOGGER.info("Checking ARP") + + if not self.hostapd: + hostapd = _req_json_rpc(self.url, self.session_id, + 'list', 'hostapd.*', '') + self.hostapd.extend(hostapd.keys()) + + self.last_results = [] + results = 0 + for hostapd in self.hostapd: + result = _req_json_rpc(self.url, self.session_id, + 'call', hostapd, 'get_clients') + + if result: + results = results + 1 + self.last_results.extend(result['clients'].keys()) + + return bool(results) + + +def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): + """ Perform one JSON RPC operation. """ + + data = json.dumps({"jsonrpc": "2.0", + "id": 1, + "method": rpcmethod, + "params": [session_id, + subsystem, + method, + params]}) + + try: + res = requests.post(url, data=data, timeout=5) + + except requests.exceptions.Timeout: + return + + if res.status_code == 200: + response = res.json() + + if rpcmethod == "call": + return response["result"][1] + else: + return response["result"] + + +def _get_session_id(url, username, password): + """ Get authentication token for the given host+username+password. """ + res = _req_json_rpc(url, "00000000000000000000000000000000", 'call', + 'session', 'login', username=username, + password=password) + return res["ubus_rpc_session"] diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 089db3fb324..cfd6ffd55eb 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -1,7 +1,6 @@ """ homeassistant.components.discovery ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Starts a service to scan in intervals for new devices. Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. @@ -18,8 +17,7 @@ from homeassistant.const import ( ATTR_SERVICE, ATTR_DISCOVERED) DOMAIN = "discovery" -DEPENDENCIES = [] -REQUIREMENTS = ['netdisco==0.4.2'] +REQUIREMENTS = ['netdisco==0.5.2'] SCAN_INTERVAL = 300 # seconds @@ -28,6 +26,7 @@ SERVICE_HUE = 'philips_hue' SERVICE_CAST = 'google_cast' SERVICE_NETGEAR = 'netgear_router' SERVICE_SONOS = 'sonos' +SERVICE_PLEX = 'plex_mediaserver' SERVICE_HANDLERS = { SERVICE_WEMO: "switch", @@ -35,6 +34,7 @@ SERVICE_HANDLERS = { SERVICE_HUE: "light", SERVICE_NETGEAR: 'device_tracker', SERVICE_SONOS: 'media_player', + SERVICE_PLEX: 'media_player', } @@ -88,6 +88,7 @@ def setup(hass, config): ATTR_DISCOVERED: info }) + # pylint: disable=unused-argument def start_discovery(event): """ Start discovering. """ netdisco = DiscoveryService(SCAN_INTERVAL) diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 6978dbd7fa9..655bf7d4eb6 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -1,8 +1,10 @@ """ homeassistant.components.downloader ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides functionality to download files. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/downloader/ """ import os import logging @@ -13,7 +15,6 @@ from homeassistant.helpers import validate_config from homeassistant.util import sanitize_filename DOMAIN = "downloader" -DEPENDENCIES = [] SERVICE_DOWNLOAD_FILE = "download_file" @@ -42,6 +43,10 @@ def setup(hass, config): download_path = config[DOMAIN][CONF_DOWNLOAD_DIR] + # If path is relative, we assume relative to HASS config dir + if not os.path.isabs(download_path): + download_path = hass.config.path(download_path) + if not os.path.isdir(download_path): logger.error( diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py new file mode 100644 index 00000000000..f1ce746b48e --- /dev/null +++ b/homeassistant/components/ecobee.py @@ -0,0 +1,155 @@ +""" +homeassistant.components.ecobee +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Ecobee Component + +This component adds support for Ecobee3 Wireless Thermostats. +You will need to setup developer access to your thermostat, +and create and API key on the ecobee website. + +The first time you run this component you will see a configuration +component card in Home Assistant. This card will contain a PIN code +that you will need to use to authorize access to your thermostat. You +can do this at https://www.ecobee.com/consumerportal/index.html +Click My Apps, Add application, Enter Pin and click Authorize. + +After authorizing the application click the button in the configuration +card. Now your thermostat and sensors should shown in home-assistant. + +You can use the optional hold_temp parameter to set whether or not holds +are set indefintely or until the next scheduled event. + +ecobee: + api_key: asdfasdfasdfasdfasdfaasdfasdfasdfasdf + hold_temp: True + +""" + +from datetime import timedelta +import logging +import os + +from homeassistant.loader import get_component +from homeassistant import bootstrap +from homeassistant.util import Throttle +from homeassistant.const import ( + EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, CONF_API_KEY) + +DOMAIN = "ecobee" +DISCOVER_THERMOSTAT = "ecobee.thermostat" +DISCOVER_SENSORS = "ecobee.sensor" +NETWORK = None +HOLD_TEMP = 'hold_temp' + +REQUIREMENTS = [ + 'https://github.com/nkgilley/python-ecobee-api/archive/' + '92a2f330cbaf601d0618456fdd97e5a8c42c1c47.zip#python-ecobee==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +ECOBEE_CONFIG_FILE = 'ecobee.conf' +_CONFIGURING = {} + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) + + +def request_configuration(network, hass, config): + """ Request configuration steps from the user. """ + configurator = get_component('configurator') + if 'ecobee' in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING['ecobee'], "Failed to register, please try again.") + + return + + # pylint: disable=unused-argument + def ecobee_configuration_callback(callback_data): + """ Actions to do when our configuration callback is called. """ + network.request_tokens() + network.update() + setup_ecobee(hass, network, config) + + _CONFIGURING['ecobee'] = configurator.request_config( + hass, "Ecobee", ecobee_configuration_callback, + description=( + 'Please authorize this app at https://www.ecobee.com/consumer' + 'portal/index.html with pin code: ' + network.pin), + description_image="/static/images/config_ecobee_thermostat.png", + submit_caption="I have authorized the app." + ) + + +def setup_ecobee(hass, network, config): + """ Setup ecobee thermostat """ + # If ecobee has a PIN then it needs to be configured. + if network.pin is not None: + request_configuration(network, hass, config) + return + + if 'ecobee' in _CONFIGURING: + configurator = get_component('configurator') + configurator.request_done(_CONFIGURING.pop('ecobee')) + + # Ensure component is loaded + bootstrap.setup_component(hass, 'thermostat', config) + bootstrap.setup_component(hass, 'sensor', config) + + hold_temp = config[DOMAIN].get(HOLD_TEMP, False) + + # Fire thermostat discovery event + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { + ATTR_SERVICE: DISCOVER_THERMOSTAT, + ATTR_DISCOVERED: {'hold_temp': hold_temp} + }) + + # Fire sensor discovery event + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { + ATTR_SERVICE: DISCOVER_SENSORS, + ATTR_DISCOVERED: {} + }) + + +# pylint: disable=too-few-public-methods +class EcobeeData(object): + """ Gets the latest data and update the states. """ + + def __init__(self, config_file): + from pyecobee import Ecobee + self.ecobee = Ecobee(config_file) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Get the latest data from pyecobee. """ + self.ecobee.update() + _LOGGER.info("ecobee data updated successfully.") + + +def setup(hass, config): + """ + Setup Ecobee. + Will automatically load thermostat and sensor components to support + devices discovered on the network. + """ + # pylint: disable=global-statement, import-error + global NETWORK + + if 'ecobee' in _CONFIGURING: + return + + from pyecobee import config_from_file + + # Create ecobee.conf if it doesn't exist + if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): + if config[DOMAIN].get(CONF_API_KEY) is None: + _LOGGER.error("No ecobee api_key found in config.") + return + jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)} + config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) + + NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE)) + + setup_ecobee(hass, NETWORK.ecobee, config) + + return True diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b327e510cd8..98f0435e9f3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -8,10 +8,9 @@ import re import os import logging -from . import version +from . import version, mdi_version import homeassistant.util as util from homeassistant.const import URL_ROOT, HTTP_OK -from homeassistant.config import get_default_config_dir DOMAIN = 'frontend' DEPENDENCIES = ['api'] @@ -22,8 +21,9 @@ _LOGGER = logging.getLogger(__name__) FRONTEND_URLS = [ URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState', - '/devEvent'] -STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)') + '/devEvent', '/devInfo', '/devTemplate', '/states'] + +_FINGERPRINT = re.compile(r'^(\w+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) def setup(hass, config): @@ -35,7 +35,8 @@ def setup(hass, config): for url in FRONTEND_URLS: hass.http.register_path('GET', url, _handle_get_root, False) - hass.http.register_path('GET', STATES_URL, _handle_get_root, False) + hass.http.register_path('GET', '/service_worker.js', + _handle_get_service_worker, False) # Static files hass.http.register_path( @@ -52,8 +53,7 @@ def setup(hass, config): def _handle_get_root(handler, path_match, data): - """ Renders the debug interface. """ - + """ Renders the frontend. """ handler.send_response(HTTP_OK) handler.send_header('Content-type', 'text/html; charset=utf-8') handler.end_headers() @@ -64,7 +64,7 @@ def _handle_get_root(handler, path_match, data): app_url = "frontend-{}.html".format(version.VERSION) # auto login if no password was set, else check api_password param - auth = ('no_password_set' if handler.server.no_password_set + auth = ('no_password_set' if handler.server.api_password is None else data.get('api_password', '')) with open(INDEX_PATH) as template_file: @@ -72,17 +72,30 @@ def _handle_get_root(handler, path_match, data): template_html = template_html.replace('{{ app_url }}', app_url) template_html = template_html.replace('{{ auth }}', auth) + template_html = template_html.replace('{{ icons }}', mdi_version.VERSION) handler.wfile.write(template_html.encode("UTF-8")) +def _handle_get_service_worker(handler, path_match, data): + """ Returns service worker for the frontend. """ + if handler.server.development: + sw_path = "home-assistant-polymer/build/service_worker.js" + else: + sw_path = "service_worker.js" + + handler.write_file(os.path.join(os.path.dirname(__file__), 'www_static', + sw_path)) + + def _handle_get_static(handler, path_match, data): """ Returns a static file for the frontend. """ req_file = util.sanitize_path(path_match.group('file')) - # Strip md5 hash out of frontend filename - if re.match(r'^frontend-[A-Za-z0-9]{32}\.html$', req_file): - req_file = "frontend.html" + # Strip md5 hash out + fingerprinted = _FINGERPRINT.match(req_file) + if fingerprinted: + req_file = "{}.{}".format(*fingerprinted.groups()) path = os.path.join(os.path.dirname(__file__), 'www_static', req_file) @@ -95,8 +108,6 @@ def _handle_get_local(handler, path_match, data): """ req_file = util.sanitize_path(path_match.group('file')) - path = os.path.join(get_default_config_dir(), 'www', req_file) - if not os.path.isfile(path): - return False + path = handler.server.hass.config.path('www', req_file) handler.write_file(path) diff --git a/homeassistant/components/frontend/index.html.template b/homeassistant/components/frontend/index.html.template index 8906e8902a0..e21d00e86bc 100644 --- a/homeassistant/components/frontend/index.html.template +++ b/homeassistant/components/frontend/index.html.template @@ -1,22 +1,19 @@ - + Home Assistant - - - + + + href='/static/favicon-apple-180x180.png'> - + + -
- -
Initializing
-
- - - +
+ + diff --git a/homeassistant/components/frontend/mdi_version.py b/homeassistant/components/frontend/mdi_version.py new file mode 100644 index 00000000000..a8106ecd77e --- /dev/null +++ b/homeassistant/components/frontend/mdi_version.py @@ -0,0 +1,2 @@ +""" DO NOT MODIFY. Auto-generated by update_mdi script """ +VERSION = "7d76081c37634d36af21f5cc1ca79408" diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 98deab3f447..64845a350ca 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 = "90c41bfbaa56f9a1c88db27a54f7d36b" +VERSION = "be08c5a3ce12040bbdba2db83cb1a568" diff --git a/homeassistant/components/frontend/www_static/favicon-384x384.png b/homeassistant/components/frontend/www_static/favicon-384x384.png new file mode 100644 index 00000000000..51f67770790 Binary files /dev/null and b/homeassistant/components/frontend/www_static/favicon-384x384.png differ diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 18bb5c557f9..8df0a4724a0 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,13 +1,586 @@ - \ No newline at end of file + } \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index c91fcccf29c..50aadaf880a 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit c91fcccf29c977bb0f8e1143fb26fc75613b6a0f +Subproject commit 50aadaf880a9cb36bf144540171ff5fa029e9eaf diff --git a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png b/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png new file mode 100644 index 00000000000..e62a4165c9b Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png differ diff --git a/homeassistant/components/frontend/www_static/images/config_plex_mediaserver.png b/homeassistant/components/frontend/www_static/images/config_plex_mediaserver.png new file mode 100644 index 00000000000..97a1b4b352c Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/config_plex_mediaserver.png differ diff --git a/homeassistant/components/frontend/www_static/manifest.json b/homeassistant/components/frontend/www_static/manifest.json index 69143ce5179..3767a4b1c5b 100644 --- a/homeassistant/components/frontend/www_static/manifest.json +++ b/homeassistant/components/frontend/www_static/manifest.json @@ -3,12 +3,17 @@ "short_name": "Assistant", "start_url": "/", "display": "standalone", + "theme_color": "#03A9F4", "icons": [ { - "src": "\/static\/favicon-192x192.png", + "src": "/static/favicon-192x192.png", "sizes": "192x192", - "type": "image\/png", - "density": "4.0" + "type": "image/png", + }, + { + "src": "/static/favicon-384x384.png", + "sizes": "384x384", + "type": "image/png", } ] } diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html new file mode 100644 index 00000000000..13b003806b3 --- /dev/null +++ b/homeassistant/components/frontend/www_static/mdi.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js new file mode 100644 index 00000000000..eb9c41c3abc --- /dev/null +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -0,0 +1,5 @@ +!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={exports:{},id:r,loaded:!1};return e[r].call(s.exports,s,s.exports,t),s.loaded=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([/*!*************************************!*\ + !*** ./src/service-worker/index.js ***! + \*************************************/ +function(e,t,n){"use strict";var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){var r=fetch(e.request).then(function(e){return t.put(s,e.clone()),e});return n||r})}))})}]); +//# sourceMappingURL=service_worker.js.map \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/splash.png b/homeassistant/components/frontend/www_static/splash.png deleted file mode 100644 index 582140a2bc3..00000000000 Binary files a/homeassistant/components/frontend/www_static/splash.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js index ec6063b7a58..91841eec55e 100644 --- a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js +++ b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js @@ -7,6 +7,6 @@ * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt */ -// @version 0.7.12 -window.WebComponents=window.WebComponents||{},function(e){var t=e.flags||{},n="webcomponents-lite.js",r=document.querySelector('script[src*="'+n+'"]');if(!t.noOpts){if(location.search.slice(1).split("&").forEach(function(e){var n,r=e.split("=");r[0]&&(n=r[0].match(/wc-(.+)/))&&(t[n[1]]=r[1]||!0)}),r)for(var o,i=0;o=r.attributes[i];i++)"src"!==o.name&&(t[o.name]=o.value||!0);if(t.log){var a=t.log.split(",");t.log={},a.forEach(function(e){t.log[e]=!0})}else t.log={}}t.shadow=t.shadow||t.shadowdom||t.polyfill,t.shadow="native"===t.shadow?!1:t.shadow||!HTMLElement.prototype.createShadowRoot,t.register&&(window.CustomElements=window.CustomElements||{flags:{}},window.CustomElements.flags.register=t.register),e.flags=t}(window.WebComponents),function(e){"use strict";function t(e){return void 0!==h[e]}function n(){s.call(this),this._isInvalid=!0}function r(e){return""==e&&n.call(this),e.toLowerCase()}function o(e){var t=e.charCodeAt(0);return t>32&&127>t&&-1==[34,35,60,62,63,96].indexOf(t)?e:encodeURIComponent(e)}function i(e){var t=e.charCodeAt(0);return t>32&&127>t&&-1==[34,35,60,62,96].indexOf(t)?e:encodeURIComponent(e)}function a(e,a,s){function c(e){g.push(e)}var d=a||"scheme start",u=0,l="",_=!1,w=!1,g=[];e:for(;(e[u-1]!=f||0==u)&&!this._isInvalid;){var b=e[u];switch(d){case"scheme start":if(!b||!m.test(b)){if(a){c("Invalid scheme.");break e}l="",d="no scheme";continue}l+=b.toLowerCase(),d="scheme";break;case"scheme":if(b&&v.test(b))l+=b.toLowerCase();else{if(":"!=b){if(a){if(f==b)break e;c("Code point not allowed in scheme: "+b);break e}l="",u=0,d="no scheme";continue}if(this._scheme=l,l="",a)break e;t(this._scheme)&&(this._isRelative=!0),d="file"==this._scheme?"relative":this._isRelative&&s&&s._scheme==this._scheme?"relative or authority":this._isRelative?"authority first slash":"scheme data"}break;case"scheme data":"?"==b?(this._query="?",d="query"):"#"==b?(this._fragment="#",d="fragment"):f!=b&&" "!=b&&"\n"!=b&&"\r"!=b&&(this._schemeData+=o(b));break;case"no scheme":if(s&&t(s._scheme)){d="relative";continue}c("Missing scheme."),n.call(this);break;case"relative or authority":if("/"!=b||"/"!=e[u+1]){c("Expected /, got: "+b),d="relative";continue}d="authority ignore slashes";break;case"relative":if(this._isRelative=!0,"file"!=this._scheme&&(this._scheme=s._scheme),f==b){this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query=s._query,this._username=s._username,this._password=s._password;break e}if("/"==b||"\\"==b)"\\"==b&&c("\\ is an invalid code point."),d="relative slash";else if("?"==b)this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query="?",this._username=s._username,this._password=s._password,d="query";else{if("#"!=b){var y=e[u+1],E=e[u+2];("file"!=this._scheme||!m.test(b)||":"!=y&&"|"!=y||f!=E&&"/"!=E&&"\\"!=E&&"?"!=E&&"#"!=E)&&(this._host=s._host,this._port=s._port,this._username=s._username,this._password=s._password,this._path=s._path.slice(),this._path.pop()),d="relative path";continue}this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query=s._query,this._fragment="#",this._username=s._username,this._password=s._password,d="fragment"}break;case"relative slash":if("/"!=b&&"\\"!=b){"file"!=this._scheme&&(this._host=s._host,this._port=s._port,this._username=s._username,this._password=s._password),d="relative path";continue}"\\"==b&&c("\\ is an invalid code point."),d="file"==this._scheme?"file host":"authority ignore slashes";break;case"authority first slash":if("/"!=b){c("Expected '/', got: "+b),d="authority ignore slashes";continue}d="authority second slash";break;case"authority second slash":if(d="authority ignore slashes","/"!=b){c("Expected '/', got: "+b);continue}break;case"authority ignore slashes":if("/"!=b&&"\\"!=b){d="authority";continue}c("Expected authority, got: "+b);break;case"authority":if("@"==b){_&&(c("@ already seen."),l+="%40"),_=!0;for(var L=0;L>>0)+(t++ +"__")};n.prototype={set:function(t,n){var r=t[this.name];return r&&r[0]===t?r[1]=n:e(t,this.name,{value:[t,n],writable:!0}),this},get:function(e){var t;return(t=e[this.name])&&t[0]===e?t[1]:void 0},"delete":function(e){var t=e[this.name];return t&&t[0]===e?(t[0]=t[1]=void 0,!0):!1},has:function(e){var t=e[this.name];return t?t[0]===e:!1}},window.WeakMap=n}(),function(e){function t(e){b.push(e),g||(g=!0,m(r))}function n(e){return window.ShadowDOMPolyfill&&window.ShadowDOMPolyfill.wrapIfNeeded(e)||e}function r(){g=!1;var e=b;b=[],e.sort(function(e,t){return e.uid_-t.uid_});var t=!1;e.forEach(function(e){var n=e.takeRecords();o(e),n.length&&(e.callback_(n,e),t=!0)}),t&&r()}function o(e){e.nodes_.forEach(function(t){var n=v.get(t);n&&n.forEach(function(t){t.observer===e&&t.removeTransientObservers()})})}function i(e,t){for(var n=e;n;n=n.parentNode){var r=v.get(n);if(r)for(var o=0;o0){var o=n[r-1],i=p(o,e);if(i)return void(n[r-1]=i)}else t(this.observer);n[r]=e},addListeners:function(){this.addListeners_(this.target)},addListeners_:function(e){var t=this.options;t.attributes&&e.addEventListener("DOMAttrModified",this,!0),t.characterData&&e.addEventListener("DOMCharacterDataModified",this,!0),t.childList&&e.addEventListener("DOMNodeInserted",this,!0),(t.childList||t.subtree)&&e.addEventListener("DOMNodeRemoved",this,!0)},removeListeners:function(){this.removeListeners_(this.target)},removeListeners_:function(e){var t=this.options;t.attributes&&e.removeEventListener("DOMAttrModified",this,!0),t.characterData&&e.removeEventListener("DOMCharacterDataModified",this,!0),t.childList&&e.removeEventListener("DOMNodeInserted",this,!0),(t.childList||t.subtree)&&e.removeEventListener("DOMNodeRemoved",this,!0)},addTransientObserver:function(e){if(e!==this.target){this.addListeners_(e),this.transientObservedNodes.push(e);var t=v.get(e);t||v.set(e,t=[]),t.push(this)}},removeTransientObservers:function(){var e=this.transientObservedNodes;this.transientObservedNodes=[],e.forEach(function(e){this.removeListeners_(e);for(var t=v.get(e),n=0;np&&(h=s[p]);p++)a(h)?(c++,n()):(h.addEventListener("load",r),h.addEventListener("error",i));else n()}function a(e){return l?e.__loaded||e["import"]&&"loading"!==e["import"].readyState:e.__importParsed}function s(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)c(t)&&d(t)}function c(e){return"link"===e.localName&&"import"===e.rel}function d(e){var t=e["import"];t?o({target:e}):(e.addEventListener("load",o),e.addEventListener("error",o))}var u="import",l=Boolean(u in document.createElement("link")),h=Boolean(window.ShadowDOMPolyfill),p=function(e){return h?window.ShadowDOMPolyfill.wrapIfNeeded(e):e},f=p(document),m={get:function(){var e=window.HTMLImports.currentScript||document.currentScript||("complete"!==document.readyState?document.scripts[document.scripts.length-1]:null);return p(e)},configurable:!0};Object.defineProperty(document,"_currentScript",m),Object.defineProperty(f,"_currentScript",m);var v=/Trident/.test(navigator.userAgent),_=v?"complete":"interactive",w="readystatechange";l&&(new MutationObserver(function(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)t.addedNodes&&s(t.addedNodes)}).observe(document.head,{childList:!0}),function(){if("loading"===document.readyState)for(var e,t=document.querySelectorAll("link[rel=import]"),n=0,r=t.length;r>n&&(e=t[n]);n++)d(e)}()),t(function(e){window.HTMLImports.ready=!0,window.HTMLImports.readyTime=(new Date).getTime();var t=f.createEvent("CustomEvent");t.initCustomEvent("HTMLImportsLoaded",!0,!0,e),f.dispatchEvent(t)}),e.IMPORT_LINK_TYPE=u,e.useNative=l,e.rootDocument=f,e.whenReady=t,e.isIE=v}(window.HTMLImports),function(e){var t=[],n=function(e){t.push(e)},r=function(){t.forEach(function(t){t(e)})};e.addModule=n,e.initializeModules=r}(window.HTMLImports),window.HTMLImports.addModule(function(e){var t=/(url\()([^)]*)(\))/g,n=/(@import[\s]+(?!url\())([^;]*)(;)/g,r={resolveUrlsInStyle:function(e,t){var n=e.ownerDocument,r=n.createElement("a");return e.textContent=this.resolveUrlsInCssText(e.textContent,t,r),e},resolveUrlsInCssText:function(e,r,o){var i=this.replaceUrls(e,o,r,t);return i=this.replaceUrls(i,o,r,n)},replaceUrls:function(e,t,n,r){return e.replace(r,function(e,r,o,i){var a=o.replace(/["']/g,"");return n&&(a=new URL(a,n).href),t.href=a,a=t.href,r+"'"+a+"'"+i})}};e.path=r}),window.HTMLImports.addModule(function(e){var t={async:!0,ok:function(e){return e.status>=200&&e.status<300||304===e.status||0===e.status},load:function(n,r,o){var i=new XMLHttpRequest;return(e.flags.debug||e.flags.bust)&&(n+="?"+Math.random()),i.open("GET",n,t.async),i.addEventListener("readystatechange",function(e){if(4===i.readyState){var n=i.getResponseHeader("Location"),a=null;if(n)var a="/"===n.substr(0,1)?location.origin+n:n;r.call(o,!t.ok(i)&&i,i.response||i.responseText,a)}}),i.send(),i},loadDocument:function(e,t,n){this.load(e,t,n).responseType="document"}};e.xhr=t}),window.HTMLImports.addModule(function(e){var t=e.xhr,n=e.flags,r=function(e,t){this.cache={},this.onload=e,this.oncomplete=t,this.inflight=0,this.pending={}};r.prototype={addNodes:function(e){this.inflight+=e.length;for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)this.require(t);this.checkDone()},addNode:function(e){this.inflight++,this.require(e),this.checkDone()},require:function(e){var t=e.src||e.href;e.__nodeUrl=t,this.dedupe(t,e)||this.fetch(t,e)},dedupe:function(e,t){if(this.pending[e])return this.pending[e].push(t),!0;return this.cache[e]?(this.onload(e,t,this.cache[e]),this.tail(),!0):(this.pending[e]=[t],!1)},fetch:function(e,r){if(n.load&&console.log("fetch",e,r),e)if(e.match(/^data:/)){var o=e.split(","),i=o[0],a=o[1];a=i.indexOf(";base64")>-1?atob(a):decodeURIComponent(a),setTimeout(function(){this.receive(e,r,null,a)}.bind(this),0)}else{var s=function(t,n,o){this.receive(e,r,t,n,o)}.bind(this);t.load(e,s)}else setTimeout(function(){this.receive(e,r,{error:"href must be specified"},null)}.bind(this),0)},receive:function(e,t,n,r,o){this.cache[e]=r;for(var i,a=this.pending[e],s=0,c=a.length;c>s&&(i=a[s]);s++)this.onload(e,i,r,n,o),this.tail();this.pending[e]=null},tail:function(){--this.inflight,this.checkDone()},checkDone:function(){this.inflight||this.oncomplete()}},e.Loader=r}),window.HTMLImports.addModule(function(e){var t=function(e){this.addCallback=e,this.mo=new MutationObserver(this.handler.bind(this))};t.prototype={handler:function(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)"childList"===t.type&&t.addedNodes.length&&this.addedNodes(t.addedNodes)},addedNodes:function(e){this.addCallback&&this.addCallback(e);for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)t.children&&t.children.length&&this.addedNodes(t.children)},observe:function(e){this.mo.observe(e,{childList:!0,subtree:!0})}},e.Observer=t}),window.HTMLImports.addModule(function(e){function t(e){return"link"===e.localName&&e.rel===u}function n(e){var t=r(e);return"data:text/javascript;charset=utf-8,"+encodeURIComponent(t)}function r(e){return e.textContent+o(e)}function o(e){var t=e.ownerDocument;t.__importedScripts=t.__importedScripts||0;var n=e.ownerDocument.baseURI,r=t.__importedScripts?"-"+t.__importedScripts:"";return t.__importedScripts++,"\n//# sourceURL="+n+r+".js\n"}function i(e){var t=e.ownerDocument.createElement("style");return t.textContent=e.textContent,a.resolveUrlsInStyle(t),t}var a=e.path,s=e.rootDocument,c=e.flags,d=e.isIE,u=e.IMPORT_LINK_TYPE,l="link[rel="+u+"]",h={documentSelectors:l,importsSelectors:[l,"link[rel=stylesheet]:not([type])","style:not([type])","script:not([type])",'script[type="application/javascript"]','script[type="text/javascript"]'].join(","),map:{link:"parseLink",script:"parseScript",style:"parseStyle"},dynamicElements:[],parseNext:function(){var e=this.nextToParse();e&&this.parse(e)},parse:function(e){if(this.isParsed(e))return void(c.parse&&console.log("[%s] is already parsed",e.localName));var t=this[this.map[e.localName]];t&&(this.markParsing(e),t.call(this,e))},parseDynamic:function(e,t){this.dynamicElements.push(e),t||this.parseNext()},markParsing:function(e){c.parse&&console.log("parsing",e),this.parsingElement=e},markParsingComplete:function(e){e.__importParsed=!0,this.markDynamicParsingComplete(e),e.__importElement&&(e.__importElement.__importParsed=!0,this.markDynamicParsingComplete(e.__importElement)),this.parsingElement=null,c.parse&&console.log("completed",e)},markDynamicParsingComplete:function(e){var t=this.dynamicElements.indexOf(e);t>=0&&this.dynamicElements.splice(t,1)},parseImport:function(e){if(e["import"]=e.__doc,window.HTMLImports.__importsParsingHook&&window.HTMLImports.__importsParsingHook(e),e["import"]&&(e["import"].__importParsed=!0),this.markParsingComplete(e),e.dispatchEvent(e.__resource&&!e.__error?new CustomEvent("load",{bubbles:!1}):new CustomEvent("error",{bubbles:!1})),e.__pending)for(var t;e.__pending.length;)t=e.__pending.shift(),t&&t({target:e});this.parseNext()},parseLink:function(e){t(e)?this.parseImport(e):(e.href=e.href,this.parseGeneric(e))},parseStyle:function(e){var t=e;e=i(e),t.__appliedElement=e,e.__importElement=t,this.parseGeneric(e)},parseGeneric:function(e){this.trackElement(e),this.addElementToDocument(e)},rootImportForElement:function(e){for(var t=e;t.ownerDocument.__importLink;)t=t.ownerDocument.__importLink;return t},addElementToDocument:function(e){var t=this.rootImportForElement(e.__importElement||e);t.parentNode.insertBefore(e,t)},trackElement:function(e,t){var n=this,r=function(o){e.removeEventListener("load",r),e.removeEventListener("error",r),t&&t(o),n.markParsingComplete(e),n.parseNext()};if(e.addEventListener("load",r),e.addEventListener("error",r),d&&"style"===e.localName){var o=!1;if(-1==e.textContent.indexOf("@import"))o=!0;else if(e.sheet){o=!0;for(var i,a=e.sheet.cssRules,s=a?a.length:0,c=0;s>c&&(i=a[c]);c++)i.type===CSSRule.IMPORT_RULE&&(o=o&&Boolean(i.styleSheet))}o&&setTimeout(function(){e.dispatchEvent(new CustomEvent("load",{bubbles:!1}))})}},parseScript:function(t){var r=document.createElement("script");r.__importElement=t,r.src=t.src?t.src:n(t),e.currentScript=t,this.trackElement(r,function(t){r.parentNode&&r.parentNode.removeChild(r),e.currentScript=null}),this.addElementToDocument(r)},nextToParse:function(){return this._mayParse=[],!this.parsingElement&&(this.nextToParseInDoc(s)||this.nextToParseDynamic())},nextToParseInDoc:function(e,n){if(e&&this._mayParse.indexOf(e)<0){this._mayParse.push(e);for(var r,o=e.querySelectorAll(this.parseSelectorsForNode(e)),i=0,a=o.length;a>i&&(r=o[i]);i++)if(!this.isParsed(r))return this.hasResource(r)?t(r)?this.nextToParseInDoc(r.__doc,r):r:void 0}return n},nextToParseDynamic:function(){return this.dynamicElements[0]},parseSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===s?this.documentSelectors:this.importsSelectors},isParsed:function(e){return e.__importParsed},needsDynamicParsing:function(e){return this.dynamicElements.indexOf(e)>=0},hasResource:function(e){return t(e)&&void 0===e.__doc?!1:!0}};e.parser=h,e.IMPORT_SELECTOR=l}),window.HTMLImports.addModule(function(e){function t(e){return n(e,a)}function n(e,t){return"link"===e.localName&&e.getAttribute("rel")===t}function r(e){return!!Object.getOwnPropertyDescriptor(e,"baseURI")}function o(e,t){var n=document.implementation.createHTMLDocument(a);n._URL=t;var o=n.createElement("base");o.setAttribute("href",t),n.baseURI||r(n)||Object.defineProperty(n,"baseURI",{value:t});var i=n.createElement("meta");return i.setAttribute("charset","utf-8"),n.head.appendChild(i),n.head.appendChild(o),n.body.innerHTML=e,window.HTMLTemplateElement&&HTMLTemplateElement.bootstrap&&HTMLTemplateElement.bootstrap(n),n}var i=e.flags,a=e.IMPORT_LINK_TYPE,s=e.IMPORT_SELECTOR,c=e.rootDocument,d=e.Loader,u=e.Observer,l=e.parser,h={documents:{},documentPreloadSelectors:s,importsPreloadSelectors:[s].join(","),loadNode:function(e){p.addNode(e)},loadSubtree:function(e){var t=this.marshalNodes(e);p.addNodes(t)},marshalNodes:function(e){return e.querySelectorAll(this.loadSelectorsForNode(e))},loadSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===c?this.documentPreloadSelectors:this.importsPreloadSelectors},loaded:function(e,n,r,a,s){if(i.load&&console.log("loaded",e,n),n.__resource=r,n.__error=a,t(n)){var c=this.documents[e];void 0===c&&(c=a?null:o(r,s||e),c&&(c.__importLink=n,this.bootDocument(c)),this.documents[e]=c),n.__doc=c}l.parseNext()},bootDocument:function(e){this.loadSubtree(e),this.observer.observe(e),l.parseNext()},loadedAll:function(){l.parseNext()}},p=new d(h.loaded.bind(h),h.loadedAll.bind(h));if(h.observer=new u,!document.baseURI){var f={get:function(){var e=document.querySelector("base");return e?e.href:window.location.href},configurable:!0};Object.defineProperty(document,"baseURI",f),Object.defineProperty(c,"baseURI",f)}e.importer=h,e.importLoader=p}),window.HTMLImports.addModule(function(e){var t=e.parser,n=e.importer,r={added:function(e){for(var r,o,i,a,s=0,c=e.length;c>s&&(a=e[s]);s++)r||(r=a.ownerDocument,o=t.isParsed(r)),i=this.shouldLoadNode(a),i&&n.loadNode(a),this.shouldParseNode(a)&&o&&t.parseDynamic(a,i)},shouldLoadNode:function(e){return 1===e.nodeType&&o.call(e,n.loadSelectorsForNode(e))},shouldParseNode:function(e){return 1===e.nodeType&&o.call(e,t.parseSelectorsForNode(e))}};n.observer.addCallback=r.added.bind(r);var o=HTMLElement.prototype.matches||HTMLElement.prototype.matchesSelector||HTMLElement.prototype.webkitMatchesSelector||HTMLElement.prototype.mozMatchesSelector||HTMLElement.prototype.msMatchesSelector}),function(e){function t(){window.HTMLImports.importer.bootDocument(o)}var n=e.initializeModules,r=e.isIE;if(!e.useNative){r&&"function"!=typeof window.CustomEvent&&(window.CustomEvent=function(e,t){t=t||{};var n=document.createEvent("CustomEvent");return n.initCustomEvent(e,Boolean(t.bubbles),Boolean(t.cancelable),t.detail),n.preventDefault=function(){Object.defineProperty(this,"defaultPrevented",{get:function(){return!0}})},n},window.CustomEvent.prototype=window.Event.prototype),n();var o=e.rootDocument;"complete"===document.readyState||"interactive"===document.readyState&&!window.attachEvent?t():document.addEventListener("DOMContentLoaded",t)}}(window.HTMLImports),window.CustomElements=window.CustomElements||{flags:{}},function(e){var t=e.flags,n=[],r=function(e){n.push(e)},o=function(){n.forEach(function(t){t(e)})};e.addModule=r,e.initializeModules=o,e.hasNative=Boolean(document.registerElement),e.isIE=/Trident/.test(navigator.userAgent),e.useNative=!t.register&&e.hasNative&&!window.ShadowDOMPolyfill&&(!window.HTMLImports||window.HTMLImports.useNative)}(window.CustomElements),window.CustomElements.addModule(function(e){function t(e,t){n(e,function(e){return t(e)?!0:void r(e,t)}),r(e,t)}function n(e,t,r){var o=e.firstElementChild;if(!o)for(o=e.firstChild;o&&o.nodeType!==Node.ELEMENT_NODE;)o=o.nextSibling;for(;o;)t(o,r)!==!0&&n(o,t,r),o=o.nextElementSibling;return null}function r(e,n){for(var r=e.shadowRoot;r;)t(r,n),r=r.olderShadowRoot}function o(e,t){i(e,t,[])}function i(e,t,n){if(e=window.wrap(e),!(n.indexOf(e)>=0)){n.push(e);for(var r,o=e.querySelectorAll("link[rel="+a+"]"),s=0,c=o.length;c>s&&(r=o[s]);s++)r["import"]&&i(r["import"],t,n);t(e)}}var a=window.HTMLImports?window.HTMLImports.IMPORT_LINK_TYPE:"none";e.forDocumentTree=o,e.forSubtree=t}),window.CustomElements.addModule(function(e){function t(e,t){return n(e,t)||r(e,t)}function n(t,n){return e.upgrade(t,n)?!0:void(n&&a(t))}function r(e,t){g(e,function(e){return n(e,t)?!0:void 0})}function o(e){L.push(e),E||(E=!0,setTimeout(i))}function i(){E=!1;for(var e,t=L,n=0,r=t.length;r>n&&(e=t[n]);n++)e();L=[]}function a(e){y?o(function(){s(e)}):s(e)}function s(e){e.__upgraded__&&!e.__attached&&(e.__attached=!0,e.attachedCallback&&e.attachedCallback())}function c(e){d(e),g(e,function(e){d(e)})}function d(e){y?o(function(){u(e)}):u(e)}function u(e){e.__upgraded__&&e.__attached&&(e.__attached=!1,e.detachedCallback&&e.detachedCallback())}function l(e){for(var t=e,n=window.wrap(document);t;){if(t==n)return!0;t=t.parentNode||t.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&t.host}}function h(e){if(e.shadowRoot&&!e.shadowRoot.__watched){w.dom&&console.log("watching shadow-root for: ",e.localName);for(var t=e.shadowRoot;t;)m(t),t=t.olderShadowRoot}}function p(e,n){if(w.dom){var r=n[0];if(r&&"childList"===r.type&&r.addedNodes&&r.addedNodes){for(var o=r.addedNodes[0];o&&o!==document&&!o.host;)o=o.parentNode;var i=o&&(o.URL||o._URL||o.host&&o.host.localName)||"";i=i.split("/?").shift().split("/").pop()}console.group("mutations (%d) [%s]",n.length,i||"")}var a=l(e);n.forEach(function(e){"childList"===e.type&&(M(e.addedNodes,function(e){e.localName&&t(e,a)}),M(e.removedNodes,function(e){e.localName&&c(e)}))}),w.dom&&console.groupEnd()}function f(e){for(e=window.wrap(e),e||(e=window.wrap(document));e.parentNode;)e=e.parentNode;var t=e.__observer;t&&(p(e,t.takeRecords()),i())}function m(e){if(!e.__observer){var t=new MutationObserver(p.bind(this,e));t.observe(e,{childList:!0,subtree:!0}),e.__observer=t}}function v(e){e=window.wrap(e),w.dom&&console.group("upgradeDocument: ",e.baseURI.split("/").pop());var n=e===window.wrap(document);t(e,n),m(e),w.dom&&console.groupEnd()}function _(e){b(e,v)}var w=e.flags,g=e.forSubtree,b=e.forDocumentTree,y=!window.MutationObserver||window.MutationObserver===window.JsMutationObserver;e.hasPolyfillMutations=y;var E=!1,L=[],M=Array.prototype.forEach.call.bind(Array.prototype.forEach),T=Element.prototype.createShadowRoot;T&&(Element.prototype.createShadowRoot=function(){var e=T.call(this);return window.CustomElements.watchShadow(this),e}),e.watchShadow=h,e.upgradeDocumentTree=_,e.upgradeDocument=v,e.upgradeSubtree=r,e.upgradeAll=t,e.attached=a,e.takeRecords=f}),window.CustomElements.addModule(function(e){function t(t,r){if(!t.__upgraded__&&t.nodeType===Node.ELEMENT_NODE){var o=t.getAttribute("is"),i=e.getRegisteredDefinition(t.localName)||e.getRegisteredDefinition(o);if(i&&(o&&i.tag==t.localName||!o&&!i["extends"]))return n(t,i,r)}}function n(t,n,o){return a.upgrade&&console.group("upgrade:",t.localName),n.is&&t.setAttribute("is",n.is),r(t,n),t.__upgraded__=!0,i(t),o&&e.attached(t),e.upgradeSubtree(t,o),a.upgrade&&console.groupEnd(),t}function r(e,t){Object.__proto__?e.__proto__=t.prototype:(o(e,t.prototype,t["native"]),e.__proto__=t.prototype)}function o(e,t,n){for(var r={},o=t;o!==n&&o!==HTMLElement.prototype;){for(var i,a=Object.getOwnPropertyNames(o),s=0;i=a[s];s++)r[i]||(Object.defineProperty(e,i,Object.getOwnPropertyDescriptor(o,i)),r[i]=1);o=Object.getPrototypeOf(o)}}function i(e){e.createdCallback&&e.createdCallback()}var a=e.flags;e.upgrade=t,e.upgradeWithDefinition=n,e.implementPrototype=r}),window.CustomElements.addModule(function(e){function t(t,r){var c=r||{};if(!t)throw new Error("document.registerElement: first argument `name` must not be empty");if(t.indexOf("-")<0)throw new Error("document.registerElement: first argument ('name') must contain a dash ('-'). Argument provided was '"+String(t)+"'.");if(o(t))throw new Error("Failed to execute 'registerElement' on 'Document': Registration failed for type '"+String(t)+"'. The type name is invalid.");if(d(t))throw new Error("DuplicateDefinitionError: a type with name '"+String(t)+"' is already registered");return c.prototype||(c.prototype=Object.create(HTMLElement.prototype)),c.__name=t.toLowerCase(),c.lifecycle=c.lifecycle||{},c.ancestry=i(c["extends"]),a(c),s(c),n(c.prototype),u(c.__name,c),c.ctor=l(c),c.ctor.prototype=c.prototype,c.prototype.constructor=c.ctor,e.ready&&_(document),c.ctor}function n(e){if(!e.setAttribute._polyfilled){ -var t=e.setAttribute;e.setAttribute=function(e,n){r.call(this,e,n,t)};var n=e.removeAttribute;e.removeAttribute=function(e){r.call(this,e,null,n)},e.setAttribute._polyfilled=!0}}function r(e,t,n){e=e.toLowerCase();var r=this.getAttribute(e);n.apply(this,arguments);var o=this.getAttribute(e);this.attributeChangedCallback&&o!==r&&this.attributeChangedCallback(e,r,o)}function o(e){for(var t=0;t=0&&b(r,HTMLElement),r)}function f(e,t){var n=e[t];e[t]=function(){var e=n.apply(this,arguments);return w(e),e}}var m,v=e.isIE,_=e.upgradeDocumentTree,w=e.upgradeAll,g=e.upgradeWithDefinition,b=e.implementPrototype,y=e.useNative,E=["annotation-xml","color-profile","font-face","font-face-src","font-face-uri","font-face-format","font-face-name","missing-glyph"],L={},M="http://www.w3.org/1999/xhtml",T=document.createElement.bind(document),N=document.createElementNS.bind(document);m=Object.__proto__||y?function(e,t){return e instanceof t}:function(e,t){if(e instanceof t)return!0;for(var n=e;n;){if(n===t.prototype)return!0;n=n.__proto__}return!1},f(Node.prototype,"cloneNode"),f(document,"importNode"),v&&!function(){var e=document.importNode;document.importNode=function(){var t=e.apply(document,arguments);if(t.nodeType==t.DOCUMENT_FRAGMENT_NODE){var n=document.createDocumentFragment();return n.appendChild(t),n}return t}}(),document.registerElement=t,document.createElement=p,document.createElementNS=h,e.registry=L,e["instanceof"]=m,e.reservedTagList=E,e.getRegisteredDefinition=d,document.register=document.registerElement}),function(e){function t(){a(window.wrap(document)),window.CustomElements.ready=!0;var e=window.requestAnimationFrame||function(e){setTimeout(e,16)};e(function(){setTimeout(function(){window.CustomElements.readyTime=Date.now(),window.HTMLImports&&(window.CustomElements.elapsed=window.CustomElements.readyTime-window.HTMLImports.readyTime),document.dispatchEvent(new CustomEvent("WebComponentsReady",{bubbles:!0}))})})}var n=e.useNative,r=e.initializeModules,o=e.isIE;if(n){var i=function(){};e.watchShadow=i,e.upgrade=i,e.upgradeAll=i,e.upgradeDocumentTree=i,e.upgradeSubtree=i,e.takeRecords=i,e["instanceof"]=function(e,t){return e instanceof t}}else r();var a=e.upgradeDocumentTree,s=e.upgradeDocument;if(window.wrap||(window.ShadowDOMPolyfill?(window.wrap=window.ShadowDOMPolyfill.wrapIfNeeded,window.unwrap=window.ShadowDOMPolyfill.unwrapIfNeeded):window.wrap=window.unwrap=function(e){return e}),window.HTMLImports&&(window.HTMLImports.__importsParsingHook=function(e){e["import"]&&s(wrap(e["import"]))}),o&&"function"!=typeof window.CustomEvent&&(window.CustomEvent=function(e,t){t=t||{};var n=document.createEvent("CustomEvent");return n.initCustomEvent(e,Boolean(t.bubbles),Boolean(t.cancelable),t.detail),n.preventDefault=function(){Object.defineProperty(this,"defaultPrevented",{get:function(){return!0}})},n},window.CustomEvent.prototype=window.Event.prototype),"complete"===document.readyState||e.flags.eager)t();else if("interactive"!==document.readyState||window.attachEvent||window.HTMLImports&&!window.HTMLImports.ready){var c=window.HTMLImports&&!window.HTMLImports.ready?"HTMLImportsLoaded":"DOMContentLoaded";window.addEventListener(c,t)}else t()}(window.CustomElements),"undefined"==typeof HTMLTemplateElement&&!function(){function e(e){switch(e){case"&":return"&";case"<":return"<";case">":return">";case" ":return" "}}function t(t){return t.replace(a,e)}var n="template",r=document.implementation.createHTMLDocument("template"),o=!0;HTMLTemplateElement=function(){},HTMLTemplateElement.prototype=Object.create(HTMLElement.prototype),HTMLTemplateElement.decorate=function(e){e.content||(e.content=r.createDocumentFragment());for(var n;n=e.firstChild;)e.content.appendChild(n);if(o)try{Object.defineProperty(e,"innerHTML",{get:function(){for(var e="",n=this.content.firstChild;n;n=n.nextSibling)e+=n.outerHTML||t(n.data);return e},set:function(e){for(r.body.innerHTML=e,HTMLTemplateElement.bootstrap(r);this.content.firstChild;)this.content.removeChild(this.content.firstChild);for(;r.body.firstChild;)this.content.appendChild(r.body.firstChild)},configurable:!0})}catch(i){o=!1}},HTMLTemplateElement.bootstrap=function(e){for(var t,r=e.querySelectorAll(n),o=0,i=r.length;i>o&&(t=r[o]);o++)HTMLTemplateElement.decorate(t)},window.addEventListener("DOMContentLoaded",function(){HTMLTemplateElement.bootstrap(document)});var i=document.createElement;document.createElement=function(){"use strict";var e=i.apply(document,arguments);return"template"==e.localName&&HTMLTemplateElement.decorate(e),e};var a=/[&\u00A0<>]/g}(),function(e){var t=document.createElement("style");t.textContent="body {transition: opacity ease-in 0.2s; } \nbody[unresolved] {opacity: 0; display: block; overflow: hidden; position: relative; } \n";var n=document.querySelector("head");n.insertBefore(t,n.firstChild)}(window.WebComponents); \ No newline at end of file +// @version 0.7.20 +!function(){window.WebComponents=window.WebComponents||{flags:{}};var e="webcomponents-lite.js",t=document.querySelector('script[src*="'+e+'"]'),n={};if(!n.noOpts){if(location.search.slice(1).split("&").forEach(function(e){var t,r=e.split("=");r[0]&&(t=r[0].match(/wc-(.+)/))&&(n[t[1]]=r[1]||!0)}),t)for(var r,o=0;r=t.attributes[o];o++)"src"!==r.name&&(n[r.name]=r.value||!0);if(n.log&&n.log.split){var i=n.log.split(",");n.log={},i.forEach(function(e){n.log[e]=!0})}else n.log={}}n.register&&(window.CustomElements=window.CustomElements||{flags:{}},window.CustomElements.flags.register=n.register),WebComponents.flags=n}(),function(e){"use strict";function t(e){return void 0!==h[e]}function n(){s.call(this),this._isInvalid=!0}function r(e){return""==e&&n.call(this),e.toLowerCase()}function o(e){var t=e.charCodeAt(0);return t>32&&127>t&&-1==[34,35,60,62,63,96].indexOf(t)?e:encodeURIComponent(e)}function i(e){var t=e.charCodeAt(0);return t>32&&127>t&&-1==[34,35,60,62,96].indexOf(t)?e:encodeURIComponent(e)}function a(e,a,s){function c(e){g.push(e)}var d=a||"scheme start",u=0,l="",w=!1,_=!1,g=[];e:for(;(e[u-1]!=f||0==u)&&!this._isInvalid;){var b=e[u];switch(d){case"scheme start":if(!b||!m.test(b)){if(a){c("Invalid scheme.");break e}l="",d="no scheme";continue}l+=b.toLowerCase(),d="scheme";break;case"scheme":if(b&&v.test(b))l+=b.toLowerCase();else{if(":"!=b){if(a){if(f==b)break e;c("Code point not allowed in scheme: "+b);break e}l="",u=0,d="no scheme";continue}if(this._scheme=l,l="",a)break e;t(this._scheme)&&(this._isRelative=!0),d="file"==this._scheme?"relative":this._isRelative&&s&&s._scheme==this._scheme?"relative or authority":this._isRelative?"authority first slash":"scheme data"}break;case"scheme data":"?"==b?(this._query="?",d="query"):"#"==b?(this._fragment="#",d="fragment"):f!=b&&" "!=b&&"\n"!=b&&"\r"!=b&&(this._schemeData+=o(b));break;case"no scheme":if(s&&t(s._scheme)){d="relative";continue}c("Missing scheme."),n.call(this);break;case"relative or authority":if("/"!=b||"/"!=e[u+1]){c("Expected /, got: "+b),d="relative";continue}d="authority ignore slashes";break;case"relative":if(this._isRelative=!0,"file"!=this._scheme&&(this._scheme=s._scheme),f==b){this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query=s._query,this._username=s._username,this._password=s._password;break e}if("/"==b||"\\"==b)"\\"==b&&c("\\ is an invalid code point."),d="relative slash";else if("?"==b)this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query="?",this._username=s._username,this._password=s._password,d="query";else{if("#"!=b){var y=e[u+1],E=e[u+2];("file"!=this._scheme||!m.test(b)||":"!=y&&"|"!=y||f!=E&&"/"!=E&&"\\"!=E&&"?"!=E&&"#"!=E)&&(this._host=s._host,this._port=s._port,this._username=s._username,this._password=s._password,this._path=s._path.slice(),this._path.pop()),d="relative path";continue}this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query=s._query,this._fragment="#",this._username=s._username,this._password=s._password,d="fragment"}break;case"relative slash":if("/"!=b&&"\\"!=b){"file"!=this._scheme&&(this._host=s._host,this._port=s._port,this._username=s._username,this._password=s._password),d="relative path";continue}"\\"==b&&c("\\ is an invalid code point."),d="file"==this._scheme?"file host":"authority ignore slashes";break;case"authority first slash":if("/"!=b){c("Expected '/', got: "+b),d="authority ignore slashes";continue}d="authority second slash";break;case"authority second slash":if(d="authority ignore slashes","/"!=b){c("Expected '/', got: "+b);continue}break;case"authority ignore slashes":if("/"!=b&&"\\"!=b){d="authority";continue}c("Expected authority, got: "+b);break;case"authority":if("@"==b){w&&(c("@ already seen."),l+="%40"),w=!0;for(var L=0;L>>0)+(t++ +"__")};n.prototype={set:function(t,n){var r=t[this.name];return r&&r[0]===t?r[1]=n:e(t,this.name,{value:[t,n],writable:!0}),this},get:function(e){var t;return(t=e[this.name])&&t[0]===e?t[1]:void 0},"delete":function(e){var t=e[this.name];return t&&t[0]===e?(t[0]=t[1]=void 0,!0):!1},has:function(e){var t=e[this.name];return t?t[0]===e:!1}},window.WeakMap=n}(),function(e){function t(e){b.push(e),g||(g=!0,m(r))}function n(e){return window.ShadowDOMPolyfill&&window.ShadowDOMPolyfill.wrapIfNeeded(e)||e}function r(){g=!1;var e=b;b=[],e.sort(function(e,t){return e.uid_-t.uid_});var t=!1;e.forEach(function(e){var n=e.takeRecords();o(e),n.length&&(e.callback_(n,e),t=!0)}),t&&r()}function o(e){e.nodes_.forEach(function(t){var n=v.get(t);n&&n.forEach(function(t){t.observer===e&&t.removeTransientObservers()})})}function i(e,t){for(var n=e;n;n=n.parentNode){var r=v.get(n);if(r)for(var o=0;o0){var o=n[r-1],i=p(o,e);if(i)return void(n[r-1]=i)}else t(this.observer);n[r]=e},addListeners:function(){this.addListeners_(this.target)},addListeners_:function(e){var t=this.options;t.attributes&&e.addEventListener("DOMAttrModified",this,!0),t.characterData&&e.addEventListener("DOMCharacterDataModified",this,!0),t.childList&&e.addEventListener("DOMNodeInserted",this,!0),(t.childList||t.subtree)&&e.addEventListener("DOMNodeRemoved",this,!0)},removeListeners:function(){this.removeListeners_(this.target)},removeListeners_:function(e){var t=this.options;t.attributes&&e.removeEventListener("DOMAttrModified",this,!0),t.characterData&&e.removeEventListener("DOMCharacterDataModified",this,!0),t.childList&&e.removeEventListener("DOMNodeInserted",this,!0),(t.childList||t.subtree)&&e.removeEventListener("DOMNodeRemoved",this,!0)},addTransientObserver:function(e){if(e!==this.target){this.addListeners_(e),this.transientObservedNodes.push(e);var t=v.get(e);t||v.set(e,t=[]),t.push(this)}},removeTransientObservers:function(){var e=this.transientObservedNodes;this.transientObservedNodes=[],e.forEach(function(e){this.removeListeners_(e);for(var t=v.get(e),n=0;n":return">";case" ":return" "}}function t(t){return t.replace(a,e)}var n="template",r=document.implementation.createHTMLDocument("template"),o=!0;HTMLTemplateElement=function(){},HTMLTemplateElement.prototype=Object.create(HTMLElement.prototype),HTMLTemplateElement.decorate=function(e){if(!e.content){e.content=r.createDocumentFragment();for(var n;n=e.firstChild;)e.content.appendChild(n);if(o)try{Object.defineProperty(e,"innerHTML",{get:function(){for(var e="",n=this.content.firstChild;n;n=n.nextSibling)e+=n.outerHTML||t(n.data);return e},set:function(e){for(r.body.innerHTML=e,HTMLTemplateElement.bootstrap(r);this.content.firstChild;)this.content.removeChild(this.content.firstChild);for(;r.body.firstChild;)this.content.appendChild(r.body.firstChild)},configurable:!0})}catch(i){o=!1}HTMLTemplateElement.bootstrap(e.content)}},HTMLTemplateElement.bootstrap=function(e){for(var t,r=e.querySelectorAll(n),o=0,i=r.length;i>o&&(t=r[o]);o++)HTMLTemplateElement.decorate(t)},document.addEventListener("DOMContentLoaded",function(){HTMLTemplateElement.bootstrap(document)});var i=document.createElement;document.createElement=function(){"use strict";var e=i.apply(document,arguments);return"template"==e.localName&&HTMLTemplateElement.decorate(e),e};var a=/[&\u00A0<>]/g}(),function(e){"use strict";if(!window.performance){var t=Date.now();window.performance={now:function(){return Date.now()-t}}}window.requestAnimationFrame||(window.requestAnimationFrame=function(){var e=window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame;return e?function(t){return e(function(){t(performance.now())})}:function(e){return window.setTimeout(e,1e3/60)}}()),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(){return window.webkitCancelAnimationFrame||window.mozCancelAnimationFrame||function(e){clearTimeout(e)}}());var n=function(){var e=document.createEvent("Event");return e.initEvent("foo",!0,!0),e.preventDefault(),e.defaultPrevented}();if(!n){var r=Event.prototype.preventDefault;Event.prototype.preventDefault=function(){this.cancelable&&(r.call(this),Object.defineProperty(this,"defaultPrevented",{get:function(){return!0},configurable:!0}))}}var o=/Trident/.test(navigator.userAgent);if((!window.CustomEvent||o&&"function"!=typeof window.CustomEvent)&&(window.CustomEvent=function(e,t){t=t||{};var n=document.createEvent("CustomEvent");return n.initCustomEvent(e,Boolean(t.bubbles),Boolean(t.cancelable),t.detail),n},window.CustomEvent.prototype=window.Event.prototype),!window.Event||o&&"function"!=typeof window.Event){var i=window.Event;window.Event=function(e,t){t=t||{};var n=document.createEvent("Event");return n.initEvent(e,Boolean(t.bubbles),Boolean(t.cancelable)),n},window.Event.prototype=i.prototype}}(window.WebComponents),window.HTMLImports=window.HTMLImports||{flags:{}},function(e){function t(e,t){t=t||f,r(function(){i(e,t)},t)}function n(e){return"complete"===e.readyState||e.readyState===w}function r(e,t){if(n(t))e&&e();else{var o=function(){("complete"===t.readyState||t.readyState===w)&&(t.removeEventListener(_,o),r(e,t))};t.addEventListener(_,o)}}function o(e){e.target.__loaded=!0}function i(e,t){function n(){c==d&&e&&e({allImports:s,loadedImports:u,errorImports:l})}function r(e){o(e),u.push(this),c++,n()}function i(e){l.push(this),c++,n()}var s=t.querySelectorAll("link[rel=import]"),c=0,d=s.length,u=[],l=[];if(d)for(var h,p=0;d>p&&(h=s[p]);p++)a(h)?(u.push(this),c++,n()):(h.addEventListener("load",r),h.addEventListener("error",i));else n()}function a(e){return l?e.__loaded||e["import"]&&"loading"!==e["import"].readyState:e.__importParsed}function s(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)c(t)&&d(t)}function c(e){return"link"===e.localName&&"import"===e.rel}function d(e){var t=e["import"];t?o({target:e}):(e.addEventListener("load",o),e.addEventListener("error",o))}var u="import",l=Boolean(u in document.createElement("link")),h=Boolean(window.ShadowDOMPolyfill),p=function(e){return h?window.ShadowDOMPolyfill.wrapIfNeeded(e):e},f=p(document),m={get:function(){var e=window.HTMLImports.currentScript||document.currentScript||("complete"!==document.readyState?document.scripts[document.scripts.length-1]:null);return p(e)},configurable:!0};Object.defineProperty(document,"_currentScript",m),Object.defineProperty(f,"_currentScript",m);var v=/Trident/.test(navigator.userAgent),w=v?"complete":"interactive",_="readystatechange";l&&(new MutationObserver(function(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)t.addedNodes&&s(t.addedNodes)}).observe(document.head,{childList:!0}),function(){if("loading"===document.readyState)for(var e,t=document.querySelectorAll("link[rel=import]"),n=0,r=t.length;r>n&&(e=t[n]);n++)d(e)}()),t(function(e){window.HTMLImports.ready=!0,window.HTMLImports.readyTime=(new Date).getTime();var t=f.createEvent("CustomEvent");t.initCustomEvent("HTMLImportsLoaded",!0,!0,e),f.dispatchEvent(t)}),e.IMPORT_LINK_TYPE=u,e.useNative=l,e.rootDocument=f,e.whenReady=t,e.isIE=v}(window.HTMLImports),function(e){var t=[],n=function(e){t.push(e)},r=function(){t.forEach(function(t){t(e)})};e.addModule=n,e.initializeModules=r}(window.HTMLImports),window.HTMLImports.addModule(function(e){var t=/(url\()([^)]*)(\))/g,n=/(@import[\s]+(?!url\())([^;]*)(;)/g,r={resolveUrlsInStyle:function(e,t){var n=e.ownerDocument,r=n.createElement("a");return e.textContent=this.resolveUrlsInCssText(e.textContent,t,r),e},resolveUrlsInCssText:function(e,r,o){var i=this.replaceUrls(e,o,r,t);return i=this.replaceUrls(i,o,r,n)},replaceUrls:function(e,t,n,r){return e.replace(r,function(e,r,o,i){var a=o.replace(/["']/g,"");return n&&(a=new URL(a,n).href),t.href=a,a=t.href,r+"'"+a+"'"+i})}};e.path=r}),window.HTMLImports.addModule(function(e){var t={async:!0,ok:function(e){return e.status>=200&&e.status<300||304===e.status||0===e.status},load:function(n,r,o){var i=new XMLHttpRequest;return(e.flags.debug||e.flags.bust)&&(n+="?"+Math.random()),i.open("GET",n,t.async),i.addEventListener("readystatechange",function(e){if(4===i.readyState){var n=null;try{var a=i.getResponseHeader("Location");a&&(n="/"===a.substr(0,1)?location.origin+a:a)}catch(e){console.error(e.message)}r.call(o,!t.ok(i)&&i,i.response||i.responseText,n)}}),i.send(),i},loadDocument:function(e,t,n){this.load(e,t,n).responseType="document"}};e.xhr=t}),window.HTMLImports.addModule(function(e){var t=e.xhr,n=e.flags,r=function(e,t){this.cache={},this.onload=e,this.oncomplete=t,this.inflight=0,this.pending={}};r.prototype={addNodes:function(e){this.inflight+=e.length;for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)this.require(t);this.checkDone()},addNode:function(e){this.inflight++,this.require(e),this.checkDone()},require:function(e){var t=e.src||e.href;e.__nodeUrl=t,this.dedupe(t,e)||this.fetch(t,e)},dedupe:function(e,t){if(this.pending[e])return this.pending[e].push(t),!0;return this.cache[e]?(this.onload(e,t,this.cache[e]),this.tail(),!0):(this.pending[e]=[t],!1)},fetch:function(e,r){if(n.load&&console.log("fetch",e,r),e)if(e.match(/^data:/)){var o=e.split(","),i=o[0],a=o[1];a=i.indexOf(";base64")>-1?atob(a):decodeURIComponent(a),setTimeout(function(){this.receive(e,r,null,a)}.bind(this),0)}else{var s=function(t,n,o){this.receive(e,r,t,n,o)}.bind(this);t.load(e,s)}else setTimeout(function(){this.receive(e,r,{error:"href must be specified"},null)}.bind(this),0)},receive:function(e,t,n,r,o){this.cache[e]=r;for(var i,a=this.pending[e],s=0,c=a.length;c>s&&(i=a[s]);s++)this.onload(e,i,r,n,o),this.tail();this.pending[e]=null},tail:function(){--this.inflight,this.checkDone()},checkDone:function(){this.inflight||this.oncomplete()}},e.Loader=r}),window.HTMLImports.addModule(function(e){var t=function(e){this.addCallback=e,this.mo=new MutationObserver(this.handler.bind(this))};t.prototype={handler:function(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)"childList"===t.type&&t.addedNodes.length&&this.addedNodes(t.addedNodes)},addedNodes:function(e){this.addCallback&&this.addCallback(e);for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)t.children&&t.children.length&&this.addedNodes(t.children)},observe:function(e){this.mo.observe(e,{childList:!0,subtree:!0})}},e.Observer=t}),window.HTMLImports.addModule(function(e){function t(e){return"link"===e.localName&&e.rel===u}function n(e){var t=r(e);return"data:text/javascript;charset=utf-8,"+encodeURIComponent(t)}function r(e){return e.textContent+o(e)}function o(e){var t=e.ownerDocument;t.__importedScripts=t.__importedScripts||0;var n=e.ownerDocument.baseURI,r=t.__importedScripts?"-"+t.__importedScripts:"";return t.__importedScripts++,"\n//# sourceURL="+n+r+".js\n"}function i(e){var t=e.ownerDocument.createElement("style");return t.textContent=e.textContent,a.resolveUrlsInStyle(t),t}var a=e.path,s=e.rootDocument,c=e.flags,d=e.isIE,u=e.IMPORT_LINK_TYPE,l="link[rel="+u+"]",h={documentSelectors:l,importsSelectors:[l,"link[rel=stylesheet]:not([type])","style:not([type])","script:not([type])",'script[type="application/javascript"]','script[type="text/javascript"]'].join(","),map:{link:"parseLink",script:"parseScript",style:"parseStyle"},dynamicElements:[],parseNext:function(){var e=this.nextToParse();e&&this.parse(e)},parse:function(e){if(this.isParsed(e))return void(c.parse&&console.log("[%s] is already parsed",e.localName));var t=this[this.map[e.localName]];t&&(this.markParsing(e),t.call(this,e))},parseDynamic:function(e,t){this.dynamicElements.push(e),t||this.parseNext()},markParsing:function(e){c.parse&&console.log("parsing",e),this.parsingElement=e},markParsingComplete:function(e){e.__importParsed=!0,this.markDynamicParsingComplete(e),e.__importElement&&(e.__importElement.__importParsed=!0,this.markDynamicParsingComplete(e.__importElement)),this.parsingElement=null,c.parse&&console.log("completed",e)},markDynamicParsingComplete:function(e){var t=this.dynamicElements.indexOf(e);t>=0&&this.dynamicElements.splice(t,1)},parseImport:function(e){if(e["import"]=e.__doc,window.HTMLImports.__importsParsingHook&&window.HTMLImports.__importsParsingHook(e),e["import"]&&(e["import"].__importParsed=!0),this.markParsingComplete(e),e.__resource&&!e.__error?e.dispatchEvent(new CustomEvent("load",{bubbles:!1})):e.dispatchEvent(new CustomEvent("error",{bubbles:!1})),e.__pending)for(var t;e.__pending.length;)t=e.__pending.shift(),t&&t({target:e});this.parseNext()},parseLink:function(e){t(e)?this.parseImport(e):(e.href=e.href,this.parseGeneric(e))},parseStyle:function(e){var t=e;e=i(e),t.__appliedElement=e,e.__importElement=t,this.parseGeneric(e)},parseGeneric:function(e){this.trackElement(e),this.addElementToDocument(e)},rootImportForElement:function(e){for(var t=e;t.ownerDocument.__importLink;)t=t.ownerDocument.__importLink;return t},addElementToDocument:function(e){var t=this.rootImportForElement(e.__importElement||e);t.parentNode.insertBefore(e,t)},trackElement:function(e,t){var n=this,r=function(o){e.removeEventListener("load",r),e.removeEventListener("error",r),t&&t(o),n.markParsingComplete(e),n.parseNext()};if(e.addEventListener("load",r),e.addEventListener("error",r),d&&"style"===e.localName){var o=!1;if(-1==e.textContent.indexOf("@import"))o=!0;else if(e.sheet){o=!0;for(var i,a=e.sheet.cssRules,s=a?a.length:0,c=0;s>c&&(i=a[c]);c++)i.type===CSSRule.IMPORT_RULE&&(o=o&&Boolean(i.styleSheet))}o&&setTimeout(function(){e.dispatchEvent(new CustomEvent("load",{bubbles:!1}))})}},parseScript:function(t){var r=document.createElement("script");r.__importElement=t,r.src=t.src?t.src:n(t),e.currentScript=t,this.trackElement(r,function(t){r.parentNode&&r.parentNode.removeChild(r),e.currentScript=null}),this.addElementToDocument(r)},nextToParse:function(){return this._mayParse=[],!this.parsingElement&&(this.nextToParseInDoc(s)||this.nextToParseDynamic())},nextToParseInDoc:function(e,n){if(e&&this._mayParse.indexOf(e)<0){this._mayParse.push(e);for(var r,o=e.querySelectorAll(this.parseSelectorsForNode(e)),i=0,a=o.length;a>i&&(r=o[i]);i++)if(!this.isParsed(r))return this.hasResource(r)?t(r)?this.nextToParseInDoc(r.__doc,r):r:void 0}return n},nextToParseDynamic:function(){return this.dynamicElements[0]},parseSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===s?this.documentSelectors:this.importsSelectors},isParsed:function(e){return e.__importParsed},needsDynamicParsing:function(e){return this.dynamicElements.indexOf(e)>=0},hasResource:function(e){return t(e)&&void 0===e.__doc?!1:!0}};e.parser=h,e.IMPORT_SELECTOR=l}),window.HTMLImports.addModule(function(e){function t(e){return n(e,a)}function n(e,t){return"link"===e.localName&&e.getAttribute("rel")===t}function r(e){return!!Object.getOwnPropertyDescriptor(e,"baseURI")}function o(e,t){var n=document.implementation.createHTMLDocument(a);n._URL=t;var o=n.createElement("base");o.setAttribute("href",t),n.baseURI||r(n)||Object.defineProperty(n,"baseURI",{value:t});var i=n.createElement("meta");return i.setAttribute("charset","utf-8"),n.head.appendChild(i),n.head.appendChild(o),n.body.innerHTML=e,window.HTMLTemplateElement&&HTMLTemplateElement.bootstrap&&HTMLTemplateElement.bootstrap(n),n}var i=e.flags,a=e.IMPORT_LINK_TYPE,s=e.IMPORT_SELECTOR,c=e.rootDocument,d=e.Loader,u=e.Observer,l=e.parser,h={documents:{},documentPreloadSelectors:s,importsPreloadSelectors:[s].join(","),loadNode:function(e){p.addNode(e)},loadSubtree:function(e){var t=this.marshalNodes(e);p.addNodes(t)},marshalNodes:function(e){return e.querySelectorAll(this.loadSelectorsForNode(e))},loadSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===c?this.documentPreloadSelectors:this.importsPreloadSelectors},loaded:function(e,n,r,a,s){if(i.load&&console.log("loaded",e,n),n.__resource=r,n.__error=a,t(n)){var c=this.documents[e];void 0===c&&(c=a?null:o(r,s||e),c&&(c.__importLink=n,this.bootDocument(c)),this.documents[e]=c),n.__doc=c}l.parseNext()},bootDocument:function(e){this.loadSubtree(e),this.observer.observe(e),l.parseNext()},loadedAll:function(){l.parseNext()}},p=new d(h.loaded.bind(h),h.loadedAll.bind(h));if(h.observer=new u,!document.baseURI){var f={get:function(){var e=document.querySelector("base");return e?e.href:window.location.href},configurable:!0};Object.defineProperty(document,"baseURI",f),Object.defineProperty(c,"baseURI",f)}e.importer=h,e.importLoader=p}),window.HTMLImports.addModule(function(e){var t=e.parser,n=e.importer,r={added:function(e){for(var r,o,i,a,s=0,c=e.length;c>s&&(a=e[s]);s++)r||(r=a.ownerDocument,o=t.isParsed(r)),i=this.shouldLoadNode(a),i&&n.loadNode(a),this.shouldParseNode(a)&&o&&t.parseDynamic(a,i)},shouldLoadNode:function(e){return 1===e.nodeType&&o.call(e,n.loadSelectorsForNode(e))},shouldParseNode:function(e){return 1===e.nodeType&&o.call(e,t.parseSelectorsForNode(e))}};n.observer.addCallback=r.added.bind(r);var o=HTMLElement.prototype.matches||HTMLElement.prototype.matchesSelector||HTMLElement.prototype.webkitMatchesSelector||HTMLElement.prototype.mozMatchesSelector||HTMLElement.prototype.msMatchesSelector}),function(e){function t(){window.HTMLImports.importer.bootDocument(r)}var n=e.initializeModules;e.isIE;if(!e.useNative){n();var r=e.rootDocument;"complete"===document.readyState||"interactive"===document.readyState&&!window.attachEvent?t():document.addEventListener("DOMContentLoaded",t)}}(window.HTMLImports),window.CustomElements=window.CustomElements||{flags:{}},function(e){var t=e.flags,n=[],r=function(e){n.push(e)},o=function(){n.forEach(function(t){t(e)})};e.addModule=r,e.initializeModules=o,e.hasNative=Boolean(document.registerElement),e.isIE=/Trident/.test(navigator.userAgent),e.useNative=!t.register&&e.hasNative&&!window.ShadowDOMPolyfill&&(!window.HTMLImports||window.HTMLImports.useNative)}(window.CustomElements),window.CustomElements.addModule(function(e){function t(e,t){n(e,function(e){return t(e)?!0:void r(e,t)}),r(e,t)}function n(e,t,r){var o=e.firstElementChild;if(!o)for(o=e.firstChild;o&&o.nodeType!==Node.ELEMENT_NODE;)o=o.nextSibling;for(;o;)t(o,r)!==!0&&n(o,t,r),o=o.nextElementSibling;return null}function r(e,n){for(var r=e.shadowRoot;r;)t(r,n),r=r.olderShadowRoot}function o(e,t){i(e,t,[])}function i(e,t,n){if(e=window.wrap(e),!(n.indexOf(e)>=0)){n.push(e);for(var r,o=e.querySelectorAll("link[rel="+a+"]"),s=0,c=o.length;c>s&&(r=o[s]);s++)r["import"]&&i(r["import"],t,n);t(e)}}var a=window.HTMLImports?window.HTMLImports.IMPORT_LINK_TYPE:"none";e.forDocumentTree=o,e.forSubtree=t}),window.CustomElements.addModule(function(e){function t(e,t){return n(e,t)||r(e,t)}function n(t,n){return e.upgrade(t,n)?!0:void(n&&a(t))}function r(e,t){g(e,function(e){return n(e,t)?!0:void 0})}function o(e){L.push(e),E||(E=!0,setTimeout(i))}function i(){E=!1;for(var e,t=L,n=0,r=t.length;r>n&&(e=t[n]);n++)e();L=[]}function a(e){y?o(function(){s(e)}):s(e)}function s(e){e.__upgraded__&&!e.__attached&&(e.__attached=!0,e.attachedCallback&&e.attachedCallback())}function c(e){d(e),g(e,function(e){d(e)})}function d(e){y?o(function(){u(e)}):u(e)}function u(e){e.__upgraded__&&e.__attached&&(e.__attached=!1,e.detachedCallback&&e.detachedCallback())}function l(e){for(var t=e,n=window.wrap(document);t;){if(t==n)return!0;t=t.parentNode||t.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&t.host}}function h(e){if(e.shadowRoot&&!e.shadowRoot.__watched){_.dom&&console.log("watching shadow-root for: ",e.localName);for(var t=e.shadowRoot;t;)m(t),t=t.olderShadowRoot}}function p(e,n){if(_.dom){var r=n[0];if(r&&"childList"===r.type&&r.addedNodes&&r.addedNodes){for(var o=r.addedNodes[0];o&&o!==document&&!o.host;)o=o.parentNode;var i=o&&(o.URL||o._URL||o.host&&o.host.localName)||"";i=i.split("/?").shift().split("/").pop()}console.group("mutations (%d) [%s]",n.length,i||"")}var a=l(e);n.forEach(function(e){"childList"===e.type&&(T(e.addedNodes,function(e){e.localName&&t(e,a)}),T(e.removedNodes,function(e){e.localName&&c(e)}))}),_.dom&&console.groupEnd()}function f(e){for(e=window.wrap(e),e||(e=window.wrap(document));e.parentNode;)e=e.parentNode;var t=e.__observer;t&&(p(e,t.takeRecords()),i())}function m(e){if(!e.__observer){var t=new MutationObserver(p.bind(this,e));t.observe(e,{childList:!0,subtree:!0}),e.__observer=t}}function v(e){e=window.wrap(e),_.dom&&console.group("upgradeDocument: ",e.baseURI.split("/").pop()); +var n=e===window.wrap(document);t(e,n),m(e),_.dom&&console.groupEnd()}function w(e){b(e,v)}var _=e.flags,g=e.forSubtree,b=e.forDocumentTree,y=window.MutationObserver._isPolyfilled&&_["throttle-attached"];e.hasPolyfillMutations=y,e.hasThrottledAttached=y;var E=!1,L=[],T=Array.prototype.forEach.call.bind(Array.prototype.forEach),M=Element.prototype.createShadowRoot;M&&(Element.prototype.createShadowRoot=function(){var e=M.call(this);return window.CustomElements.watchShadow(this),e}),e.watchShadow=h,e.upgradeDocumentTree=w,e.upgradeDocument=v,e.upgradeSubtree=r,e.upgradeAll=t,e.attached=a,e.takeRecords=f}),window.CustomElements.addModule(function(e){function t(t,r){if("template"===t.localName&&window.HTMLTemplateElement&&HTMLTemplateElement.decorate&&HTMLTemplateElement.decorate(t),!t.__upgraded__&&t.nodeType===Node.ELEMENT_NODE){var o=t.getAttribute("is"),i=e.getRegisteredDefinition(t.localName)||e.getRegisteredDefinition(o);if(i&&(o&&i.tag==t.localName||!o&&!i["extends"]))return n(t,i,r)}}function n(t,n,o){return a.upgrade&&console.group("upgrade:",t.localName),n.is&&t.setAttribute("is",n.is),r(t,n),t.__upgraded__=!0,i(t),o&&e.attached(t),e.upgradeSubtree(t,o),a.upgrade&&console.groupEnd(),t}function r(e,t){Object.__proto__?e.__proto__=t.prototype:(o(e,t.prototype,t["native"]),e.__proto__=t.prototype)}function o(e,t,n){for(var r={},o=t;o!==n&&o!==HTMLElement.prototype;){for(var i,a=Object.getOwnPropertyNames(o),s=0;i=a[s];s++)r[i]||(Object.defineProperty(e,i,Object.getOwnPropertyDescriptor(o,i)),r[i]=1);o=Object.getPrototypeOf(o)}}function i(e){e.createdCallback&&e.createdCallback()}var a=e.flags;e.upgrade=t,e.upgradeWithDefinition=n,e.implementPrototype=r}),window.CustomElements.addModule(function(e){function t(t,r){var c=r||{};if(!t)throw new Error("document.registerElement: first argument `name` must not be empty");if(t.indexOf("-")<0)throw new Error("document.registerElement: first argument ('name') must contain a dash ('-'). Argument provided was '"+String(t)+"'.");if(o(t))throw new Error("Failed to execute 'registerElement' on 'Document': Registration failed for type '"+String(t)+"'. The type name is invalid.");if(d(t))throw new Error("DuplicateDefinitionError: a type with name '"+String(t)+"' is already registered");return c.prototype||(c.prototype=Object.create(HTMLElement.prototype)),c.__name=t.toLowerCase(),c.lifecycle=c.lifecycle||{},c.ancestry=i(c["extends"]),a(c),s(c),n(c.prototype),u(c.__name,c),c.ctor=l(c),c.ctor.prototype=c.prototype,c.prototype.constructor=c.ctor,e.ready&&w(document),c.ctor}function n(e){if(!e.setAttribute._polyfilled){var t=e.setAttribute;e.setAttribute=function(e,n){r.call(this,e,n,t)};var n=e.removeAttribute;e.removeAttribute=function(e){r.call(this,e,null,n)},e.setAttribute._polyfilled=!0}}function r(e,t,n){e=e.toLowerCase();var r=this.getAttribute(e);n.apply(this,arguments);var o=this.getAttribute(e);this.attributeChangedCallback&&o!==r&&this.attributeChangedCallback(e,r,o)}function o(e){for(var t=0;t=0&&b(r,HTMLElement),r)}function f(e,t){var n=e[t];e[t]=function(){var e=n.apply(this,arguments);return _(e),e}}var m,v=e.isIE,w=e.upgradeDocumentTree,_=e.upgradeAll,g=e.upgradeWithDefinition,b=e.implementPrototype,y=e.useNative,E=["annotation-xml","color-profile","font-face","font-face-src","font-face-uri","font-face-format","font-face-name","missing-glyph"],L={},T="http://www.w3.org/1999/xhtml",M=document.createElement.bind(document),N=document.createElementNS.bind(document);m=Object.__proto__||y?function(e,t){return e instanceof t}:function(e,t){if(e instanceof t)return!0;for(var n=e;n;){if(n===t.prototype)return!0;n=n.__proto__}return!1},f(Node.prototype,"cloneNode"),f(document,"importNode"),v&&!function(){var e=document.importNode;document.importNode=function(){var t=e.apply(document,arguments);if(t.nodeType==t.DOCUMENT_FRAGMENT_NODE){var n=document.createDocumentFragment();return n.appendChild(t),n}return t}}(),document.registerElement=t,document.createElement=p,document.createElementNS=h,e.registry=L,e["instanceof"]=m,e.reservedTagList=E,e.getRegisteredDefinition=d,document.register=document.registerElement}),function(e){function t(){i(window.wrap(document)),window.CustomElements.ready=!0;var e=window.requestAnimationFrame||function(e){setTimeout(e,16)};e(function(){setTimeout(function(){window.CustomElements.readyTime=Date.now(),window.HTMLImports&&(window.CustomElements.elapsed=window.CustomElements.readyTime-window.HTMLImports.readyTime),document.dispatchEvent(new CustomEvent("WebComponentsReady",{bubbles:!0}))})})}var n=e.useNative,r=e.initializeModules;e.isIE;if(n){var o=function(){};e.watchShadow=o,e.upgrade=o,e.upgradeAll=o,e.upgradeDocumentTree=o,e.upgradeSubtree=o,e.takeRecords=o,e["instanceof"]=function(e,t){return e instanceof t}}else r();var i=e.upgradeDocumentTree,a=e.upgradeDocument;if(window.wrap||(window.ShadowDOMPolyfill?(window.wrap=window.ShadowDOMPolyfill.wrapIfNeeded,window.unwrap=window.ShadowDOMPolyfill.unwrapIfNeeded):window.wrap=window.unwrap=function(e){return e}),window.HTMLImports&&(window.HTMLImports.__importsParsingHook=function(e){e["import"]&&a(wrap(e["import"]))}),"complete"===document.readyState||e.flags.eager)t();else if("interactive"!==document.readyState||window.attachEvent||window.HTMLImports&&!window.HTMLImports.ready){var s=window.HTMLImports&&!window.HTMLImports.ready?"HTMLImportsLoaded":"DOMContentLoaded";window.addEventListener(s,t)}else t()}(window.CustomElements),function(e){var t=document.createElement("style");t.textContent="body {transition: opacity ease-in 0.2s; } \nbody[unresolved] {opacity: 0; display: block; overflow: hidden; position: relative; } \n";var n=document.querySelector("head");n.insertBefore(t,n.firstChild)}(window.WebComponents); \ No newline at end of file diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 96fe2a67143..52ffe824e42 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -1,10 +1,11 @@ """ homeassistant.components.group ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides functionality to group devices that can be turned on or off. -""" +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/group/ +""" import homeassistant.core as ha from homeassistant.helpers import generate_entity_id from homeassistant.helpers.event import track_state_change @@ -16,7 +17,6 @@ from homeassistant.const import ( STATE_UNKNOWN) DOMAIN = "group" -DEPENDENCIES = [] ENTITY_ID_FORMAT = DOMAIN + ".{}" diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index a723f9cbd71..b71f2de7398 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -1,8 +1,10 @@ """ homeassistant.components.history ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provide pre-made queries on top of the recorder component. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/history/ """ import re from datetime import timedelta diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index bae720db8dc..b7f57b0157e 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -1,94 +1,27 @@ """ -homeassistant.components.httpinterface -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - +homeassistant.components.http +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This module provides an API and a HTTP interface for debug purposes. -By default it will run on port 8123. - -All API calls have to be accompanied by an 'api_password' parameter and will -return JSON. If successful calls will return status code 200 or 201. - -Other status codes that can occur are: - - 400 (Bad Request) - - 401 (Unauthorized) - - 404 (Not Found) - - 405 (Method not allowed) - -The api supports the following actions: - -/api - GET -Returns message if API is up and running. -Example result: -{ - "message": "API running." -} - -/api/states - GET -Returns a list of entities for which a state is available -Example result: -[ - { .. state object .. }, - { .. state object .. } -] - -/api/states/ - GET -Returns the current state from an entity -Example result: -{ - "attributes": { - "next_rising": "07:04:15 29-10-2013", - "next_setting": "18:00:31 29-10-2013" - }, - "entity_id": "weather.sun", - "last_changed": "23:24:33 28-10-2013", - "state": "below_horizon" -} - -/api/states/ - POST -Updates the current state of an entity. Returns status code 201 if successful -with location header of updated resource and as body the new state. -parameter: new_state - string -optional parameter: attributes - JSON encoded object -Example result: -{ - "attributes": { - "next_rising": "07:04:15 29-10-2013", - "next_setting": "18:00:31 29-10-2013" - }, - "entity_id": "weather.sun", - "last_changed": "23:24:33 28-10-2013", - "state": "below_horizon" -} - -/api/events/ - POST -Fires an event with event_type -optional parameter: event_data - JSON encoded object -Example result: -{ - "message": "Event download_file fired." -} - +For more details about the RESTful API, please refer to the documentation at +https://home-assistant.io/developers/api/ """ - -import json -import threading -import logging -import time -import gzip -import os -import random -import string from datetime import timedelta -from homeassistant.util import Throttle -from http.server import SimpleHTTPRequestHandler, HTTPServer +import gzip from http import cookies +from http.server import SimpleHTTPRequestHandler, HTTPServer +import json +import logging +import os from socketserver import ThreadingMixIn +import ssl +import threading +import time from urllib.parse import urlparse, parse_qs import homeassistant.core as ha from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, + SERVER_PORT, CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN, HTTP_HEADER_HA_AUTH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_ACCEPT_ENCODING, HTTP_HEADER_CONTENT_ENCODING, HTTP_HEADER_VARY, HTTP_HEADER_CONTENT_LENGTH, HTTP_HEADER_CACHE_CONTROL, HTTP_HEADER_EXPIRES, HTTP_OK, HTTP_UNAUTHORIZED, @@ -99,51 +32,43 @@ import homeassistant.util.dt as date_util import homeassistant.bootstrap as bootstrap DOMAIN = "http" -DEPENDENCIES = [] CONF_API_PASSWORD = "api_password" CONF_SERVER_HOST = "server_host" CONF_SERVER_PORT = "server_port" CONF_DEVELOPMENT = "development" -CONF_SESSIONS_ENABLED = "sessions_enabled" +CONF_SSL_CERTIFICATE = 'ssl_certificate' +CONF_SSL_KEY = 'ssl_key' DATA_API_PASSWORD = 'api_password' # Throttling time in seconds for expired sessions check -MIN_SEC_SESSION_CLEARING = timedelta(seconds=20) +SESSION_CLEAR_INTERVAL = timedelta(seconds=20) SESSION_TIMEOUT_SECONDS = 1800 SESSION_KEY = 'sessionId' _LOGGER = logging.getLogger(__name__) -def setup(hass, config=None): +def setup(hass, config): """ Sets up the HTTP API and debug interface. """ - if config is None or DOMAIN not in config: - config = {DOMAIN: {}} + conf = config.get(DOMAIN, {}) - api_password = util.convert(config[DOMAIN].get(CONF_API_PASSWORD), str) - - no_password_set = api_password is None - - if no_password_set: - api_password = util.get_random_string() + api_password = util.convert(conf.get(CONF_API_PASSWORD), str) # If no server host is given, accept all incoming requests - server_host = config[DOMAIN].get(CONF_SERVER_HOST, '0.0.0.0') - - server_port = config[DOMAIN].get(CONF_SERVER_PORT, SERVER_PORT) - - development = str(config[DOMAIN].get(CONF_DEVELOPMENT, "")) == "1" - - sessions_enabled = config[DOMAIN].get(CONF_SESSIONS_ENABLED, True) + server_host = conf.get(CONF_SERVER_HOST, '0.0.0.0') + server_port = conf.get(CONF_SERVER_PORT, SERVER_PORT) + development = str(conf.get(CONF_DEVELOPMENT, "")) == "1" + ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) + ssl_key = conf.get(CONF_SSL_KEY) try: server = HomeAssistantHTTPServer( (server_host, server_port), RequestHandler, hass, api_password, - development, no_password_set, sessions_enabled) + development, ssl_certificate, ssl_key) except OSError: - # Happens if address already in use + # If address already in use _LOGGER.exception("Error setting up HTTP server") return False @@ -153,7 +78,8 @@ def setup(hass, config=None): threading.Thread(target=server.start, daemon=True).start()) hass.http = server - hass.config.api = rem.API(util.get_local_ip(), api_password, server_port) + hass.config.api = rem.API(util.get_local_ip(), api_password, server_port, + ssl_certificate is not None) return True @@ -168,17 +94,16 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): # pylint: disable=too-many-arguments def __init__(self, server_address, request_handler_class, - hass, api_password, development, no_password_set, - sessions_enabled): + hass, api_password, development, ssl_certificate, ssl_key): super().__init__(server_address, request_handler_class) self.server_address = server_address self.hass = hass self.api_password = api_password self.development = development - self.no_password_set = no_password_set self.paths = [] - self.sessions = SessionStore(sessions_enabled) + self.sessions = SessionStore() + self.use_ssl = ssl_certificate is not None # We will lazy init this one if needed self.event_forwarder = None @@ -186,6 +111,12 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): if development: _LOGGER.info("running http in development mode") + if ssl_certificate is not None: + wrap_kwargs = {'certfile': ssl_certificate} + if ssl_key is not None: + wrap_kwargs['keyfile'] = ssl_key + self.socket = ssl.wrap_socket(self.socket, **wrap_kwargs) + def start(self): """ Starts the HTTP server. """ def stop_http(event): @@ -194,8 +125,11 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): self.hass.bus.listen_once(ha.EVENT_HOMEASSISTANT_STOP, stop_http) + protocol = 'https' if self.use_ssl else 'http' + _LOGGER.info( - "Starting web interface at http://%s:%d", *self.server_address) + "Starting web interface at %s://%s:%d", + protocol, self.server_address[0], self.server_address[1]) # 31-1-2015: Refactored frontend/api components out of this component # To prevent stuff from breaking, load the two extracted components @@ -227,12 +161,13 @@ class RequestHandler(SimpleHTTPRequestHandler): def __init__(self, req, client_addr, server): """ Contructor, call the base constructor and set up session """ - self._session = None + # Track if this was an authenticated request + self.authenticated = False SimpleHTTPRequestHandler.__init__(self, req, client_addr, server) def log_message(self, fmt, *arguments): """ Redirect built-in log to HA logging """ - if self.server.no_password_set: + if self.server.api_password is None: _LOGGER.info(fmt, *arguments) else: _LOGGER.info( @@ -243,12 +178,8 @@ class RequestHandler(SimpleHTTPRequestHandler): """ Does some common checks and calls appropriate method. """ url = urlparse(self.path) - # Read query input - data = parse_qs(url.query) - - # parse_qs gives a list for each value, take the latest element - for key in data: - data[key] = data[key][-1] + # Read query input. parse_qs gives a list for each value, we want last + data = {key: data[-1] for key, data in parse_qs(url.query).items()} # Did we get post input ? content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0)) @@ -267,18 +198,12 @@ class RequestHandler(SimpleHTTPRequestHandler): "Error parsing JSON", HTTP_UNPROCESSABLE_ENTITY) return - self._session = self.get_session() - if self.server.no_password_set: - api_password = self.server.api_password - else: - api_password = self.headers.get(HTTP_HEADER_HA_AUTH) - - if not api_password and DATA_API_PASSWORD in data: - api_password = data[DATA_API_PASSWORD] - - if not api_password and self._session is not None: - api_password = self._session.cookie_values.get( - CONF_API_PASSWORD) + self.authenticated = (self.server.api_password is None + or self.headers.get(HTTP_HEADER_HA_AUTH) == + self.server.api_password + or data.get(DATA_API_PASSWORD) == + self.server.api_password + or self.verify_session()) if '_METHOD' in data: method = data.pop('_METHOD') @@ -311,18 +236,13 @@ class RequestHandler(SimpleHTTPRequestHandler): # Did we find a handler for the incoming request? if handle_request_method: - # For some calls we need a valid password - if require_auth and api_password != self.server.api_password: + if require_auth and not self.authenticated: self.write_json_message( "API password missing or incorrect.", HTTP_UNAUTHORIZED) + return - else: - if self._session is None and require_auth: - self._session = self.server.sessions.create( - api_password) - - handle_request_method(self, path_match, data) + handle_request_method(self, path_match, data) elif path_matched_but_not_method: self.send_response(HTTP_METHOD_NOT_ALLOWED) @@ -373,18 +293,30 @@ class RequestHandler(SimpleHTTPRequestHandler): json.dumps(data, indent=4, sort_keys=True, cls=rem.JSONEncoder).encode("UTF-8")) - def write_file(self, path): + def write_text(self, message, status_code=HTTP_OK): + """ Helper method to return a text message to the caller. """ + self.send_response(status_code) + self.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN) + + self.set_session_cookie_header() + + self.end_headers() + + self.wfile.write(message.encode("UTF-8")) + + def write_file(self, path, cache_headers=True): """ Returns a file to the user. """ try: with open(path, 'rb') as inp: - self.write_file_pointer(self.guess_type(path), inp) + self.write_file_pointer(self.guess_type(path), inp, + cache_headers) except IOError: self.send_response(HTTP_NOT_FOUND) self.end_headers() _LOGGER.exception("Unable to serve %s", path) - def write_file_pointer(self, content_type, inp): + def write_file_pointer(self, content_type, inp, cache_headers=True): """ Helper function to write a file pointer to the user. Does not do error handling. @@ -394,7 +326,8 @@ class RequestHandler(SimpleHTTPRequestHandler): self.send_response(HTTP_OK) self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type) - self.set_cache_header() + if cache_headers: + self.set_cache_header() self.set_session_cookie_header() if do_gzip: @@ -421,123 +354,126 @@ class RequestHandler(SimpleHTTPRequestHandler): def set_cache_header(self): """ Add cache headers if not in development """ - if not self.server.development: - # 1 year in seconds - cache_time = 365 * 86400 + if self.server.development: + return - self.send_header( - HTTP_HEADER_CACHE_CONTROL, - "public, max-age={}".format(cache_time)) - self.send_header( - HTTP_HEADER_EXPIRES, - self.date_time_string(time.time()+cache_time)) + # 1 year in seconds + cache_time = 365 * 86400 + + self.send_header( + HTTP_HEADER_CACHE_CONTROL, + "public, max-age={}".format(cache_time)) + self.send_header( + HTTP_HEADER_EXPIRES, + self.date_time_string(time.time()+cache_time)) def set_session_cookie_header(self): - """ Add the header for the session cookie """ - if self.server.sessions.enabled and self._session is not None: - existing_sess_id = self.get_current_session_id() - - if existing_sess_id != self._session.session_id: - self.send_header( - 'Set-Cookie', - SESSION_KEY+'='+self._session.session_id) - - def get_session(self): - """ Get the requested session object from cookie value """ - if self.server.sessions.enabled is not True: + """ Add the header for the session cookie and return session id. """ + if not self.authenticated: return None - session_id = self.get_current_session_id() + session_id = self.get_cookie_session_id() + if session_id is not None: - session = self.server.sessions.get(session_id) - if session is not None: - session.reset_expiry() - return session + self.server.sessions.extend_validation(session_id) + return session_id - return None + self.send_header( + 'Set-Cookie', + '{}={}'.format(SESSION_KEY, self.server.sessions.create()) + ) - def get_current_session_id(self): + return session_id + + def verify_session(self): + """ Verify that we are in a valid session. """ + return self.get_cookie_session_id() is not None + + def get_cookie_session_id(self): """ Extracts the current session id from the - cookie or returns None if not set + cookie or returns None if not set or invalid """ + if 'Cookie' not in self.headers: + return None + cookie = cookies.SimpleCookie() + try: + cookie.load(self.headers["Cookie"]) + except cookies.CookieError: + return None - if self.headers.get('Cookie', None) is not None: - cookie.load(self.headers.get("Cookie")) + morsel = cookie.get(SESSION_KEY) - if cookie.get(SESSION_KEY, False): - return cookie[SESSION_KEY].value + if morsel is None: + return None + + session_id = cookie[SESSION_KEY].value + + if self.server.sessions.is_valid(session_id): + return session_id return None + def destroy_session(self): + """ Destroys session. """ + session_id = self.get_cookie_session_id() -class ServerSession: - """ A very simple session class """ - def __init__(self, session_id): - """ Set up the expiry time on creation """ - self._expiry = 0 - self.reset_expiry() - self.cookie_values = {} - self.session_id = session_id + if session_id is None: + return - def reset_expiry(self): - """ Resets the expiry based on current time """ - self._expiry = date_util.utcnow() + timedelta( - seconds=SESSION_TIMEOUT_SECONDS) + self.send_header('Set-Cookie', '') + self.server.sessions.destroy(session_id) - @property - def is_expired(self): - """ Return true if the session is expired based on the expiry time """ - return self._expiry < date_util.utcnow() + +def session_valid_time(): + """ Time till when a session will be valid. """ + return date_util.utcnow() + timedelta(seconds=SESSION_TIMEOUT_SECONDS) class SessionStore(object): """ Responsible for storing and retrieving http sessions """ - def __init__(self, enabled=True): + def __init__(self): """ Set up the session store """ self._sessions = {} - self.enabled = enabled - self.session_lock = threading.RLock() + self._lock = threading.RLock() - @Throttle(MIN_SEC_SESSION_CLEARING) - def remove_expired(self): + @util.Throttle(SESSION_CLEAR_INTERVAL) + def _remove_expired(self): """ Remove any expired sessions. """ - if self.session_lock.acquire(False): - try: - keys = [] - for key in self._sessions.keys(): - keys.append(key) + now = date_util.utcnow() + for key in [key for key, valid_time in self._sessions.items() + if valid_time < now]: + self._sessions.pop(key) - for key in keys: - if self._sessions[key].is_expired: - del self._sessions[key] - _LOGGER.info("Cleared expired session %s", key) - finally: - self.session_lock.release() + def is_valid(self, key): + """ Return True if a valid session is given. """ + with self._lock: + self._remove_expired() - def add(self, key, session): - """ Add a new session to the list of tracked sessions """ - self.remove_expired() - with self.session_lock: - self._sessions[key] = session + return (key in self._sessions and + self._sessions[key] > date_util.utcnow()) - def get(self, key): - """ get a session by key """ - self.remove_expired() - session = self._sessions.get(key, None) - if session is not None and session.is_expired: - return None - return session + def extend_validation(self, key): + """ Extend a session validation time. """ + with self._lock: + if key not in self._sessions: + return + self._sessions[key] = session_valid_time() - def create(self, api_password): - """ Creates a new session and adds it to the sessions """ - if self.enabled is not True: - return None + def destroy(self, key): + """ Destroy a session by key. """ + with self._lock: + self._sessions.pop(key, None) - chars = string.ascii_letters + string.digits - session_id = ''.join([random.choice(chars) for i in range(20)]) - session = ServerSession(session_id) - session.cookie_values[CONF_API_PASSWORD] = api_password - self.add(session_id, session) - return session + def create(self): + """ Creates a new session. """ + with self._lock: + session_id = util.get_random_string(20) + + while session_id in self._sessions: + session_id = util.get_random_string(20) + + self._sessions[session_id] = session_valid_time() + + return session_id diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py index 1eacd61bcee..6f406b24311 100644 --- a/homeassistant/components/ifttt.py +++ b/homeassistant/components/ifttt.py @@ -1,23 +1,10 @@ """ homeassistant.components.ifttt -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This component enable you to trigger Maker IFTTT recipes. -Check https://ifttt.com/maker for details. - -Configuration: - -To use Maker IFTTT you will need to add something like the following to your -config/configuration.yaml. - -ifttt: - key: xxxxx-x-xxxxxxxxxxxxx - -Variables: - -key -*Required -Your api key +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ifttt/ """ import logging import requests @@ -35,13 +22,11 @@ ATTR_VALUE1 = 'value1' ATTR_VALUE2 = 'value2' ATTR_VALUE3 = 'value3' -DEPENDENCIES = [] - REQUIREMENTS = ['pyfttt==0.3'] def trigger(hass, event, value1=None, value2=None, value3=None): - """ Trigger a Maker IFTTT recipe """ + """ Trigger a Maker IFTTT recipe. """ data = { ATTR_EVENT: event, ATTR_VALUE1: value1, @@ -52,7 +37,7 @@ def trigger(hass, event, value1=None, value2=None, value3=None): def setup(hass, config): - """ Setup the ifttt service component """ + """ Setup the ifttt service component. """ if not validate_config(config, {DOMAIN: ['key']}, _LOGGER): return False diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py new file mode 100644 index 00000000000..2286dd2d659 --- /dev/null +++ b/homeassistant/components/influxdb.py @@ -0,0 +1,104 @@ +""" +homeassistant.components.influxdb +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +InfluxDB component which allows you to send data to an Influx database. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/influxdb/ +""" +import logging + +import homeassistant.util as util +from homeassistant.helpers import validate_config +from homeassistant.const import (EVENT_STATE_CHANGED, STATE_ON, STATE_OFF, + STATE_UNLOCKED, STATE_LOCKED, STATE_UNKNOWN) +from homeassistant.components.sun import (STATE_ABOVE_HORIZON, + STATE_BELOW_HORIZON) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "influxdb" +DEPENDENCIES = [] + +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 8086 +DEFAULT_DATABASE = 'home_assistant' + +REQUIREMENTS = ['influxdb==2.10.0'] + +CONF_HOST = 'host' +CONF_PORT = 'port' +CONF_DB_NAME = 'database' +CONF_USERNAME = 'username' +CONF_PASSWORD = 'password' + + +def setup(hass, config): + """ Setup the InfluxDB component. """ + + from influxdb import InfluxDBClient, exceptions + + if not validate_config(config, {DOMAIN: ['host']}, _LOGGER): + return False + + conf = config[DOMAIN] + + host = conf[CONF_HOST] + port = util.convert(conf.get(CONF_PORT), int, DEFAULT_PORT) + database = util.convert(conf.get(CONF_DB_NAME), str, DEFAULT_DATABASE) + username = util.convert(conf.get(CONF_USERNAME), str) + password = util.convert(conf.get(CONF_PASSWORD), str) + + try: + influx = InfluxDBClient(host=host, port=port, username=username, + password=password, database=database) + databases = [i['name'] for i in influx.get_list_database()] + except exceptions.InfluxDBClientError: + _LOGGER.error("Database host is not accessible. " + "Please check your entries in the configuration file.") + return False + + if database not in databases: + _LOGGER.error("Database %s doesn't exist", database) + return False + + def influx_event_listener(event): + """ Listen for new messages on the bus and sends them to Influx. """ + + state = event.data.get('new_state') + + if state is None: + return + + if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON): + _state = 1 + elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, + STATE_BELOW_HORIZON): + _state = 0 + else: + _state = state.state + + measurement = state.attributes.get('unit_of_measurement', state.domain) + + json_body = [ + { + 'measurement': measurement, + 'tags': { + 'domain': state.domain, + 'entity_id': state.object_id, + }, + 'time': event.time_fired, + 'fields': { + 'value': _state, + } + } + ] + + try: + influx.write_points(json_body) + except exceptions.InfluxDBClientError: + _LOGGER.exception('Error saving event to InfluxDB') + + hass.bus.listen(EVENT_STATE_CHANGED, influx_event_listener) + + return True diff --git a/homeassistant/components/introduction.py b/homeassistant/components/introduction.py index 3a1af572a30..540d928f7f5 100644 --- a/homeassistant/components/introduction.py +++ b/homeassistant/components/introduction.py @@ -1,13 +1,14 @@ """ homeassistant.components.introduction ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Component that will help guide the user taking its first steps. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/introduction/ """ import logging DOMAIN = 'introduction' -DEPENDENCIES = [] def setup(hass, config=None): @@ -24,13 +25,13 @@ def setup(hass, config=None): Here are some resources to get started: - Configuring Home Assistant: - https://home-assistant.io/getting-started/configuration.html + https://home-assistant.io/getting-started/configuration/ - Available components: https://home-assistant.io/components/ - Troubleshooting your configuration: - https://home-assistant.io/getting-started/troubleshooting-configuration.html + https://home-assistant.io/getting-started/troubleshooting-configuration/ - Getting help: https://home-assistant.io/help/ diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 63c7b6c4af6..0f8d24520aa 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -5,7 +5,7 @@ Connects to an ISY-994 controller and loads relevant components to control its devices. Also contains the base classes for ISY Sensors, Lights, and Switches. For configuration details please visit the documentation for this component at -https://home-assistant.io/components/isy994.html +https://home-assistant.io/components/isy994/ """ import logging from urllib.parse import urlparse @@ -20,7 +20,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME) DOMAIN = "isy994" -DEPENDENCIES = [] REQUIREMENTS = ['PyISY==1.0.5'] DISCOVER_LIGHTS = "isy994.lights" DISCOVER_SWITCHES = "isy994.switches" @@ -117,7 +116,6 @@ class ISYDeviceABC(ToggleEntity): def __init__(self, node): # setup properties self.node = node - self.hidden = HIDDEN_STRING in self.raw_name # track changes self._change_handler = self.node.status. \ @@ -182,6 +180,11 @@ class ISYDeviceABC(ToggleEntity): return self.raw_name.replace(HIDDEN_STRING, '').strip() \ .replace('_', ' ') + @property + def hidden(self): + """ Suggestion if the entity should be hidden from UIs. """ + return HIDDEN_STRING in self.raw_name + def update(self): """ Update state of the sensor. """ # ISY objects are automatically updated by the ISY's event stream diff --git a/homeassistant/components/keyboard.py b/homeassistant/components/keyboard.py index 3629fce31bf..c772d1c6e74 100644 --- a/homeassistant/components/keyboard.py +++ b/homeassistant/components/keyboard.py @@ -1,8 +1,10 @@ """ homeassistant.components.keyboard ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides functionality to emulate keyboard presses on host machine. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/keyboard/ """ import logging @@ -13,7 +15,6 @@ from homeassistant.const import ( DOMAIN = "keyboard" -DEPENDENCIES = [] REQUIREMENTS = ['pyuserinput==0.1.9'] diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index c1b1579b4b5..93321b5fd10 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1,58 +1,16 @@ """ homeassistant.components.light ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides functionality to interact with lights. -It offers the following services: - -TURN_OFF - Turns one or multiple lights off. - -Supports following parameters: - - transition - Integer that represents the time the light should take to transition to - the new state. - - entity_id - String or list of strings that point at entity_ids of lights. - -TURN_ON - Turns one or multiple lights on and change attributes. - -Supports following parameters: - - transition - Integer that represents the time the light should take to transition to - the new state. - - - entity_id - String or list of strings that point at entity_ids of lights. - - - profile - String with the name of one of the built-in profiles (relax, energize, - concentrate, reading) or one of the custom profiles defined in - light_profiles.csv in the current working directory. - - Light profiles define a xy color and a brightness. - - If a profile is given and a brightness or xy color then the profile values - will be overwritten. - - - xy_color - A list containing two floats representing the xy color you want the light - to be. - - - rgb_color - A list containing three integers representing the xy color you want the - light to be. - - - brightness - Integer between 0 and 255 representing how bright you want the light to be. - +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/light/ """ - import logging import os import csv -from homeassistant.components import group, discovery, wink, isy994 +from homeassistant.components import group, discovery, wink, isy994, zwave from homeassistant.config import load_yaml_config_file from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID) @@ -63,7 +21,6 @@ import homeassistant.util.color as color_util DOMAIN = "light" -DEPENDENCIES = [] SCAN_INTERVAL = 30 GROUP_NAME_ALL_LIGHTS = 'all lights' @@ -77,6 +34,7 @@ ATTR_TRANSITION = "transition" # lists holding color values ATTR_RGB_COLOR = "rgb_color" ATTR_XY_COLOR = "xy_color" +ATTR_COLOR_TEMP = "color_temp" # int with value 0 .. 255 representing brightness of the light ATTR_BRIGHTNESS = "brightness" @@ -92,6 +50,8 @@ FLASH_LONG = "long" # Apply an effect to the light, can be EFFECT_COLORLOOP ATTR_EFFECT = "effect" EFFECT_COLORLOOP = "colorloop" +EFFECT_RANDOM = "random" +EFFECT_WHITE = "white" LIGHT_PROFILES_FILE = "light_profiles.csv" @@ -100,11 +60,14 @@ DISCOVERY_PLATFORMS = { wink.DISCOVER_LIGHTS: 'wink', isy994.DISCOVER_LIGHTS: 'isy994', discovery.SERVICE_HUE: 'hue', + zwave.DISCOVER_LIGHTS: 'zwave', } PROP_TO_ATTR = { 'brightness': ATTR_BRIGHTNESS, - 'color_xy': ATTR_XY_COLOR, + 'color_temp': ATTR_COLOR_TEMP, + 'rgb_color': ATTR_RGB_COLOR, + 'xy_color': ATTR_XY_COLOR, } _LOGGER = logging.getLogger(__name__) @@ -119,8 +82,8 @@ def is_on(hass, entity_id=None): # pylint: disable=too-many-arguments def turn_on(hass, entity_id=None, transition=None, brightness=None, - rgb_color=None, xy_color=None, profile=None, flash=None, - effect=None): + rgb_color=None, xy_color=None, color_temp=None, profile=None, + flash=None, effect=None): """ Turns all or specified light on. """ data = { key: value for key, value in [ @@ -130,6 +93,7 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None, (ATTR_BRIGHTNESS, brightness), (ATTR_RGB_COLOR, rgb_color), (ATTR_XY_COLOR, xy_color), + (ATTR_COLOR_TEMP, color_temp), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), ] if value is not None @@ -150,7 +114,7 @@ def turn_off(hass, entity_id=None, transition=None): hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) -# pylint: disable=too-many-branches, too-many-locals +# pylint: disable=too-many-branches, too-many-locals, too-many-statements def setup(hass, config): """ Exposes light control via statemachine and services. """ @@ -240,6 +204,15 @@ def setup(hass, config): # ValueError if value could not be converted to float pass + if ATTR_COLOR_TEMP in dat: + # color_temp should be an int of mirads value + colortemp = dat.get(ATTR_COLOR_TEMP) + + # Without this check, a ctcolor with value '99' would work + # These values are based on Philips Hue, may need ajustment later + if isinstance(colortemp, int) and 154 <= colortemp <= 500: + params[ATTR_COLOR_TEMP] = colortemp + if ATTR_RGB_COLOR in dat: try: # rgb_color should be a list containing 3 ints @@ -247,26 +220,18 @@ def setup(hass, config): if len(rgb_color) == 3: params[ATTR_RGB_COLOR] = [int(val) for val in rgb_color] - params[ATTR_XY_COLOR] = \ - color_util.color_RGB_to_xy(int(rgb_color[0]), - int(rgb_color[1]), - int(rgb_color[2])) except (TypeError, ValueError): # TypeError if rgb_color is not iterable # ValueError if not all values can be converted to int pass - if ATTR_FLASH in dat: - if dat[ATTR_FLASH] == FLASH_SHORT: - params[ATTR_FLASH] = FLASH_SHORT + if dat.get(ATTR_FLASH) in (FLASH_SHORT, FLASH_LONG): + params[ATTR_FLASH] = dat[ATTR_FLASH] - elif dat[ATTR_FLASH] == FLASH_LONG: - params[ATTR_FLASH] = FLASH_LONG - - if ATTR_EFFECT in dat: - if dat[ATTR_EFFECT] == EFFECT_COLORLOOP: - params[ATTR_EFFECT] = EFFECT_COLORLOOP + if dat.get(ATTR_EFFECT) in (EFFECT_COLORLOOP, EFFECT_WHITE, + EFFECT_RANDOM): + params[ATTR_EFFECT] = dat[ATTR_EFFECT] for light in target_lights: light.turn_on(**params) @@ -297,10 +262,20 @@ class Light(ToggleEntity): return None @property - def color_xy(self): + def xy_color(self): """ XY color value [float, float]. """ return None + @property + def rgb_color(self): + """ RGB color value [int, int, int] """ + return None + + @property + def color_temp(self): + """ CT color value in mirads. """ + return None + @property def device_state_attributes(self): """ Returns device specific state attributes. """ @@ -317,6 +292,12 @@ class Light(ToggleEntity): if value: data[attr] = value + if ATTR_RGB_COLOR not in data and ATTR_XY_COLOR in data and \ + ATTR_BRIGHTNESS in data: + data[ATTR_RGB_COLOR] = color_util.color_xy_brightness_to_RGB( + data[ATTR_XY_COLOR][0], data[ATTR_XY_COLOR][1], + data[ATTR_BRIGHTNESS]) + device_attr = self.device_state_attributes if device_attr is not None: diff --git a/homeassistant/components/light/blinksticklight.py b/homeassistant/components/light/blinksticklight.py index 3e8ba8b505d..fae9890c93d 100644 --- a/homeassistant/components/light/blinksticklight.py +++ b/homeassistant/components/light/blinksticklight.py @@ -4,24 +4,23 @@ homeassistant.components.light.blinksticklight Support for Blinkstick lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.blinksticklight.html +https://home-assistant.io/components/light.blinksticklight/ """ import logging -from blinkstick import blinkstick - -from homeassistant.components.light import (Light, ATTR_RGB_COLOR) +from homeassistant.components.light import Light, ATTR_RGB_COLOR _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["blinkstick==1.1.7"] -DEPENDENCIES = [] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Add device specified by serial number. """ + from blinkstick import blinkstick + stick = blinkstick.find_by_serial(config['serial']) add_devices_callback([BlinkStickLight(stick, config['name'])]) diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index 40a8cc023c5..73da0c51a09 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -1,37 +1,40 @@ """ homeassistant.components.light.demo ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Demo platform that implements lights. """ import random from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR) + Light, ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_COLOR_TEMP) LIGHT_COLORS = [ - [0.368, 0.180], - [0.460, 0.470], + [237, 224, 33], + [255, 63, 111], ] +LIGHT_TEMPS = [240, 380] + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return demo lights. """ add_devices_callback([ DemoLight("Bed Light", False), - DemoLight("Ceiling Lights", True, LIGHT_COLORS[0]), - DemoLight("Kitchen Lights", True, LIGHT_COLORS[1]) + DemoLight("Ceiling Lights", True, LIGHT_COLORS[0], LIGHT_TEMPS[1]), + DemoLight("Kitchen Lights", True, LIGHT_COLORS[1], LIGHT_TEMPS[0]) ]) class DemoLight(Light): """ Provides a demo switch. """ - def __init__(self, name, state, xy=None, brightness=180): + # pylint: disable=too-many-arguments + def __init__(self, name, state, rgb=None, ct=None, brightness=180): self._name = name self._state = state - self._xy = xy or random.choice(LIGHT_COLORS) + self._rgb = rgb or random.choice(LIGHT_COLORS) + self._ct = ct or random.choice(LIGHT_TEMPS) self._brightness = brightness @property @@ -50,9 +53,14 @@ class DemoLight(Light): return self._brightness @property - def color_xy(self): - """ XY color value. """ - return self._xy + def rgb_color(self): + """ rgb color value. """ + return self._rgb + + @property + def color_temp(self): + """ CT color temperature. """ + return self._ct @property def is_on(self): @@ -63,8 +71,11 @@ class DemoLight(Light): """ Turn the device on. """ self._state = True - if ATTR_XY_COLOR in kwargs: - self._xy = kwargs[ATTR_XY_COLOR] + if ATTR_RGB_COLOR in kwargs: + self._rgb = kwargs[ATTR_RGB_COLOR] + + if ATTR_COLOR_TEMP in kwargs: + self._ct = kwargs[ATTR_COLOR_TEMP] if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index b438d7b92b1..77672c9aaf5 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -2,19 +2,26 @@ homeassistant.components.light.hue ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Support for Hue lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.hue/ """ +import json import logging +import os import socket +import random from datetime import timedelta from urllib.parse import urlparse from homeassistant.loader import get_component import homeassistant.util as util +import homeassistant.util.color as color_util from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR, ATTR_TRANSITION, - ATTR_FLASH, FLASH_LONG, FLASH_SHORT, ATTR_EFFECT, - EFFECT_COLORLOOP) + Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR, ATTR_COLOR_TEMP, + ATTR_TRANSITION, ATTR_FLASH, FLASH_LONG, FLASH_SHORT, + ATTR_EFFECT, EFFECT_COLORLOOP, EFFECT_RANDOM, ATTR_RGB_COLOR) REQUIREMENTS = ['phue==0.8'] MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) @@ -28,21 +35,37 @@ _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) +def _find_host_from_config(hass): + """ Attempt to detect host based on existing configuration. """ + path = hass.config.path(PHUE_CONFIG_FILE) + + if not os.path.isfile(path): + return None + + try: + with open(path) as inp: + return next(json.loads(''.join(inp)).keys().__iter__()) + except (ValueError, AttributeError, StopIteration): + # ValueError if can't parse as JSON + # AttributeError if JSON value is not a dict + # StopIteration if no keys + return None + + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Gets the Hue lights. """ - try: - # pylint: disable=unused-variable - import phue # noqa - except ImportError: - _LOGGER.exception("Error while importing dependency phue.") - - return - if discovery_info is not None: host = urlparse(discovery_info[1]).hostname else: host = config.get(CONF_HOST, None) + if host is None: + host = _find_host_from_config(hass) + + if host is None: + _LOGGER.error('No host found in configuration') + return False + # Only act if we are not already configuring this host if host in _CONFIGURING: return @@ -123,6 +146,7 @@ def request_configuration(host, hass, add_devices_callback): return + # pylint: disable=unused-argument def hue_configuration_callback(data): """ Actions to do when our configuration callback is called. """ setup_bridge(host, hass, add_devices_callback) @@ -162,10 +186,15 @@ class HueLight(Light): return self.info['state']['bri'] @property - def color_xy(self): + def xy_color(self): """ XY color value. """ return self.info['state'].get('xy') + @property + def color_temp(self): + """ CT color value. """ + return self.info['state'].get('ct') + @property def is_on(self): """ True if device is on. """ @@ -178,15 +207,19 @@ class HueLight(Light): command = {'on': True} if ATTR_TRANSITION in kwargs: - # Transition time is in 1/10th seconds and cannot exceed - # 900 seconds. - command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10) + command['transitiontime'] = kwargs[ATTR_TRANSITION] * 10 if ATTR_BRIGHTNESS in kwargs: command['bri'] = kwargs[ATTR_BRIGHTNESS] if ATTR_XY_COLOR in kwargs: command['xy'] = kwargs[ATTR_XY_COLOR] + elif ATTR_RGB_COLOR in kwargs: + command['xy'] = color_util.color_RGB_to_xy( + *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + + if ATTR_COLOR_TEMP in kwargs: + command['ct'] = kwargs[ATTR_COLOR_TEMP] flash = kwargs.get(ATTR_FLASH) @@ -201,6 +234,9 @@ class HueLight(Light): if effect == EFFECT_COLORLOOP: command['effect'] = 'colorloop' + elif effect == EFFECT_RANDOM: + command['hue'] = random.randrange(0, 65535) + command['sat'] = random.randrange(150, 254) else: command['effect'] = 'none' diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py new file mode 100644 index 00000000000..fe7d504ed4f --- /dev/null +++ b/homeassistant/components/light/hyperion.py @@ -0,0 +1,126 @@ +""" +homeassistant.components.light.hyperion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Hyperion remotes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.hyperion/ +""" +import logging +import socket +import json + +from homeassistant.const import CONF_HOST +from homeassistant.components.light import (Light, ATTR_RGB_COLOR) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = [] + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Sets up a Hyperion server remote """ + host = config.get(CONF_HOST, None) + port = config.get("port", 19444) + device = Hyperion(host, port) + if device.setup(): + add_devices_callback([device]) + return True + else: + return False + + +class Hyperion(Light): + """ Represents a Hyperion remote """ + + def __init__(self, host, port): + self._host = host + self._port = port + self._name = host + self._is_available = True + self._rgb_color = [255, 255, 255] + + @property + def name(self): + """ Return the hostname of the server. """ + return self._name + + @property + def rgb_color(self): + """ Last RGB color value set. """ + return self._rgb_color + + @property + def is_on(self): + """ True if the device is online. """ + return self._is_available + + def turn_on(self, **kwargs): + """ Turn the lights on. """ + if self._is_available: + if ATTR_RGB_COLOR in kwargs: + self._rgb_color = kwargs[ATTR_RGB_COLOR] + + self.json_request({"command": "color", "priority": 128, + "color": self._rgb_color}) + + def turn_off(self, **kwargs): + """ Disconnect the remote. """ + self.json_request({"command": "clearall"}) + + def update(self): + """ Ping the remote. """ + # just see if the remote port is open + self._is_available = self.json_request() + + def setup(self): + """ Get the hostname of the remote. """ + response = self.json_request({"command": "serverinfo"}) + if response: + self._name = response["info"]["hostname"] + return True + + return False + + def json_request(self, request=None, wait_for_response=False): + """ Communicate with the json server. """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + + try: + sock.connect((self._host, self._port)) + except OSError: + sock.close() + return False + + if not request: + # no communication needed, simple presence detection returns True + sock.close() + return True + + sock.send(bytearray(json.dumps(request) + "\n", "utf-8")) + try: + buf = sock.recv(4096) + except socket.timeout: + # something is wrong, assume it's offline + sock.close() + return False + + # read until a newline or timeout + buffering = True + while buffering: + if "\n" in str(buf, "utf-8"): + response = str(buf, "utf-8").split("\n")[0] + buffering = False + else: + try: + more = sock.recv(4096) + except socket.timeout: + more = None + if not more: + buffering = False + response = str(buf, "utf-8") + else: + buf += more + + sock.close() + return json.loads(response) diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index 5b62120ee98..6aa604e1b84 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -2,6 +2,9 @@ homeassistant.components.light.isy994 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Support for ISY994 lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/isy994/ """ import logging diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index ba8b8235260..e072c6ce962 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -1,194 +1,284 @@ """ homeassistant.components.light.limitlessled ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for LimitlessLED bulbs. -Support for LimitlessLED bulbs, also known as... - -- EasyBulb -- AppLight -- AppLamp -- MiLight -- LEDme -- dekolight -- iLight - -Configuration: - -To use limitlessled you will need to add the following to your -configuration.yaml file. - -light: - platform: limitlessled - bridges: - - host: 192.168.1.10 - group_1_name: Living Room - group_2_name: Bedroom - group_3_name: Office - group_3_type: white - group_4_name: Kitchen - - host: 192.168.1.11 - group_2_name: Basement +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.limitlessled/ """ import logging -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.components.light import (Light, ATTR_BRIGHTNESS, - ATTR_XY_COLOR) -from homeassistant.util.color import color_RGB_to_xy + ATTR_RGB_COLOR, ATTR_EFFECT, + ATTR_COLOR_TEMP, ATTR_TRANSITION, + ATTR_FLASH, FLASH_LONG, + EFFECT_COLORLOOP, EFFECT_WHITE) + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['ledcontroller==1.1.0'] +REQUIREMENTS = ['limitlessled==1.0.0'] +RGB_BOUNDARY = 40 +DEFAULT_TRANSITION = 0 +DEFAULT_PORT = 8899 +DEFAULT_VERSION = 5 +DEFAULT_LED_TYPE = 'rgbw' +WHITE = [255, 255, 255] + + +def rewrite_legacy(config): + """ Rewrite legacy configuration to new format. """ + bridges = config.get('bridges', [config]) + new_bridges = [] + for bridge_conf in bridges: + groups = [] + if 'groups' in bridge_conf: + groups = bridge_conf['groups'] + else: + _LOGGER.warning("Legacy configuration format detected") + for i in range(1, 5): + name_key = 'group_%d_name' % i + if name_key in bridge_conf: + groups.append({ + 'number': i, + 'type': bridge_conf.get('group_%d_type' % i, + DEFAULT_LED_TYPE), + 'name': bridge_conf.get(name_key) + }) + new_bridges.append({ + 'host': bridge_conf.get('host'), + 'groups': groups + }) + return {'bridges': new_bridges} def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Gets the LimitlessLED lights. """ - import ledcontroller + from limitlessled.bridge import Bridge - # Handle old configuration format: - bridges = config.get('bridges', [config]) - - for bridge_id, bridge in enumerate(bridges): - bridge['id'] = bridge_id - - pool = ledcontroller.LedControllerPool([x['host'] for x in bridges]) + # Two legacy configuration formats are supported to + # maintain backwards compatibility. + config = rewrite_legacy(config) + # Use the expanded configuration format. lights = [] - for bridge in bridges: - for i in range(1, 5): - name_key = 'group_%d_name' % i - if name_key in bridge: - group_type = bridge.get('group_%d_type' % i, 'rgbw') - lights.append(LimitlessLED.factory(pool, bridge['id'], i, - bridge[name_key], - group_type)) - + for bridge_conf in config.get('bridges'): + bridge = Bridge(bridge_conf.get('host'), + port=bridge_conf.get('port', DEFAULT_PORT), + version=bridge_conf.get('version', DEFAULT_VERSION)) + for group_conf in bridge_conf.get('groups'): + group = bridge.add_group(group_conf.get('number'), + group_conf.get('name'), + group_conf.get('type', DEFAULT_LED_TYPE)) + lights.append(LimitlessLEDGroup.factory(group)) add_devices_callback(lights) -class LimitlessLED(Light): - """ Represents a LimitlessLED light """ +def state(new_state): + """ State decorator. + + Specify True (turn on) or False (turn off). + """ + def decorator(function): + """ Decorator function. """ + # pylint: disable=no-member,protected-access + def wrapper(self, **kwargs): + """ Wrap a group state change. """ + from limitlessled.pipeline import Pipeline + pipeline = Pipeline() + transition_time = DEFAULT_TRANSITION + # Stop any repeating pipeline. + if self.repeating: + self.repeating = False + self.group.stop() + # Not on and should be? Turn on. + if not self.is_on and new_state is True: + pipeline.on() + # Set transition time. + if ATTR_TRANSITION in kwargs: + transition_time = kwargs[ATTR_TRANSITION] + # Do group type-specific work. + function(self, transition_time, pipeline, **kwargs) + # Update state. + self._is_on = new_state + self.group.enqueue(pipeline) + self.update_ha_state() + return wrapper + return decorator + + +class LimitlessLEDGroup(Light): + """ LimitessLED group. """ + def __init__(self, group): + """ Initialize a group. """ + self.group = group + self.repeating = False + self._is_on = False + self._brightness = None @staticmethod - def factory(pool, controller_id, group, name, group_type): - ''' Construct a Limitless LED of the appropriate type ''' - if group_type == 'white': - return WhiteLimitlessLED(pool, controller_id, group, name) - elif group_type == 'rgbw': - return RGBWLimitlessLED(pool, controller_id, group, name) - - # pylint: disable=too-many-arguments - def __init__(self, pool, controller_id, group, name, group_type): - self.pool = pool - self.controller_id = controller_id - self.group = group - - self.pool.execute(self.controller_id, "set_group_type", self.group, - group_type) - - # LimitlessLEDs don't report state, we have track it ourselves. - self.pool.execute(self.controller_id, "off", self.group) - - self._name = name or DEVICE_DEFAULT_NAME - self._state = False + def factory(group): + """ Produce LimitlessLEDGroup objects. """ + from limitlessled.group.rgbw import RgbwGroup + from limitlessled.group.white import WhiteGroup + if isinstance(group, WhiteGroup): + return LimitlessLEDWhiteGroup(group) + elif isinstance(group, RgbwGroup): + return LimitlessLEDRGBWGroup(group) @property def should_poll(self): - """ No polling needed. """ + """ No polling needed. + + LimitlessLED state cannot be fetched. + """ return False @property def name(self): - """ Returns the name of the device if any. """ - return self._name + """ Returns the name of the group. """ + return self.group.name @property def is_on(self): """ True if device is on. """ - return self._state - - def turn_off(self, **kwargs): - """ Turn the device off. """ - self._state = False - self.pool.execute(self.controller_id, "off", self.group) - self.update_ha_state() - - -class RGBWLimitlessLED(LimitlessLED): - """ Represents a RGBW LimitlessLED light """ - - def __init__(self, pool, controller_id, group, name): - super().__init__(pool, controller_id, group, name, 'rgbw') - - self._brightness = 100 - self._xy_color = color_RGB_to_xy(255, 255, 255) - - # Build a color table that maps an RGB color to a color string - # recognized by LedController's set_color method - self._color_table = [(color_RGB_to_xy(*x[0]), x[1]) for x in [ - ((0xFF, 0xFF, 0xFF), 'white'), - ((0xEE, 0x82, 0xEE), 'violet'), - ((0x41, 0x69, 0xE1), 'royal_blue'), - ((0x87, 0xCE, 0xFA), 'baby_blue'), - ((0x00, 0xFF, 0xFF), 'aqua'), - ((0x7F, 0xFF, 0xD4), 'royal_mint'), - ((0x2E, 0x8B, 0x57), 'seafoam_green'), - ((0x00, 0x80, 0x00), 'green'), - ((0x32, 0xCD, 0x32), 'lime_green'), - ((0xFF, 0xFF, 0x00), 'yellow'), - ((0xDA, 0xA5, 0x20), 'yellow_orange'), - ((0xFF, 0xA5, 0x00), 'orange'), - ((0xFF, 0x00, 0x00), 'red'), - ((0xFF, 0xC0, 0xCB), 'pink'), - ((0xFF, 0x00, 0xFF), 'fusia'), - ((0xDA, 0x70, 0xD6), 'lilac'), - ((0xE6, 0xE6, 0xFA), 'lavendar'), - ]] + return self._is_on @property def brightness(self): + """ Brightness property. """ return self._brightness + @state(False) + def turn_off(self, transition_time, pipeline, **kwargs): + """ Turn off a group. """ + if self.is_on: + pipeline.transition(transition_time, brightness=0.0).off() + + +class LimitlessLEDWhiteGroup(LimitlessLEDGroup): + """ LimitlessLED White group. """ + def __init__(self, group): + """ Initialize White group. """ + super().__init__(group) + # Initialize group with known values. + self.group.on = True + self.group.temperature = 1.0 + self.group.brightness = 0.0 + self._brightness = _to_hass_brightness(1.0) + self._temperature = _to_hass_temperature(self.group.temperature) + self.group.on = False + @property - def color_xy(self): - return self._xy_color - - def _xy_to_led_color(self, xy_color): - """ Convert an XY color to the closest LedController color string. """ - def abs_dist_squared(p_0, p_1): - """ Returns the absolute value of the squared distance """ - return abs((p_0[0] - p_1[0])**2 + (p_0[1] - p_1[1])**2) - - candidates = [(abs_dist_squared(xy_color, x[0]), x[1]) for x in - self._color_table] - - # First candidate in the sorted list is closest to desired color: - return sorted(candidates)[0][1] - - def turn_on(self, **kwargs): - """ Turn the device on. """ - self._state = True + def color_temp(self): + """ Temperature property. """ + return self._temperature + @state(True) + def turn_on(self, transition_time, pipeline, **kwargs): + """ Turn on (or adjust property of) a group. """ + # Check arguments. if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - - if ATTR_XY_COLOR in kwargs: - self._xy_color = kwargs[ATTR_XY_COLOR] - - self.pool.execute(self.controller_id, "set_color", - self._xy_to_led_color(self._xy_color), self.group) - self.pool.execute(self.controller_id, "set_brightness", - self._brightness / 255.0, self.group) - self.update_ha_state() + if ATTR_COLOR_TEMP in kwargs: + self._temperature = kwargs[ATTR_COLOR_TEMP] + # Set up transition. + pipeline.transition(transition_time, + brightness=_from_hass_brightness( + self._brightness), + temperature=_from_hass_temperature( + self._temperature)) -class WhiteLimitlessLED(LimitlessLED): - """ Represents a White LimitlessLED light """ +class LimitlessLEDRGBWGroup(LimitlessLEDGroup): + """ LimitlessLED RGBW group. """ + def __init__(self, group): + """ Initialize RGBW group. """ + super().__init__(group) + # Initialize group with known values. + self.group.on = True + self.group.white() + self._color = WHITE + self.group.brightness = 0.0 + self._brightness = _to_hass_brightness(1.0) + self.group.on = False - def __init__(self, pool, controller_id, group, name): - super().__init__(pool, controller_id, group, name, 'white') + @property + def rgb_color(self): + """ Color property. """ + return self._color - def turn_on(self, **kwargs): - """ Turn the device on. """ - self._state = True - self.pool.execute(self.controller_id, "on", self.group) - self.update_ha_state() + @state(True) + def turn_on(self, transition_time, pipeline, **kwargs): + """ Turn on (or adjust property of) a group. """ + from limitlessled.presets import COLORLOOP + # Check arguments. + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + if ATTR_RGB_COLOR in kwargs: + self._color = kwargs[ATTR_RGB_COLOR] + # White is a special case. + if min(self._color) > 256 - RGB_BOUNDARY: + pipeline.white() + self._color = WHITE + # Set up transition. + pipeline.transition(transition_time, + brightness=_from_hass_brightness( + self._brightness), + color=_from_hass_color(self._color)) + # Flash. + if ATTR_FLASH in kwargs: + duration = 0 + if kwargs[ATTR_FLASH] == FLASH_LONG: + duration = 1 + pipeline.flash(duration=duration) + # Add effects. + if ATTR_EFFECT in kwargs: + if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: + self.repeating = True + pipeline.append(COLORLOOP) + if kwargs[ATTR_EFFECT] == EFFECT_WHITE: + pipeline.white() + self._color = WHITE + + +def _from_hass_temperature(temperature): + """ Convert Home Assistant color temperature + units to percentage. + """ + return (temperature - 154) / 346 + + +def _to_hass_temperature(temperature): + """ Convert percentage to Home Assistant + color temperature units. + """ + return int(temperature * 346) + 154 + + +def _from_hass_brightness(brightness): + """ Convert Home Assistant brightness units + to percentage. + """ + return brightness / 255 + + +def _to_hass_brightness(brightness): + """ Convert percentage to Home Assistant + brightness units. + """ + return int(brightness * 255) + + +def _from_hass_color(color): + """ Convert Home Assistant RGB list + to Color tuple. + """ + from limitlessled import Color + return Color(*tuple(color)) + + +def _to_hass_color(color): + """ Convert from Color tuple to + Home Assistant RGB list. + """ + return list([int(c) for c in color]) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py new file mode 100644 index 00000000000..3493c04db1b --- /dev/null +++ b/homeassistant/components/light/mqtt.py @@ -0,0 +1,179 @@ +""" +homeassistant.components.light.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to configure a MQTT light. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.mqtt/ +""" +from functools import partial +import logging + +import homeassistant.components.mqtt as mqtt +from homeassistant.components.light import (Light, + ATTR_BRIGHTNESS, ATTR_RGB_COLOR) +from homeassistant.util.template import render_with_possible_json_value + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'MQTT Light' +DEFAULT_QOS = 0 +DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_OFF = 'OFF' +DEFAULT_OPTIMISTIC = False + +DEPENDENCIES = ['mqtt'] + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Add MQTT Light. """ + + if config.get('command_topic') is None: + _LOGGER.error("Missing required variable: command_topic") + return False + + add_devices_callback([MqttLight( + hass, + config.get('name', DEFAULT_NAME), + {key: config.get(key) for key in + (typ + topic + for typ in ('', 'brightness_', 'rgb_') + for topic in ('state_topic', 'command_topic'))}, + {key: config.get(key + '_value_template') + for key in ('state', 'brightness', 'rgb')}, + config.get('qos', DEFAULT_QOS), + { + 'on': config.get('payload_on', DEFAULT_PAYLOAD_ON), + 'off': config.get('payload_off', DEFAULT_PAYLOAD_OFF) + }, + config.get('optimistic', DEFAULT_OPTIMISTIC))]) + + +class MqttLight(Light): + """ Provides a MQTT light. """ + + # pylint: disable=too-many-arguments,too-many-instance-attributes + def __init__(self, hass, name, topic, templates, qos, payload, optimistic): + + self._hass = hass + self._name = name + self._topic = topic + self._qos = qos + self._payload = payload + self._optimistic = optimistic or topic["state_topic"] is None + self._optimistic_rgb = optimistic or topic["rgb_state_topic"] is None + self._optimistic_brightness = (optimistic or + topic["brightness_state_topic"] is None) + self._state = False + + templates = {key: ((lambda value: value) if tpl is None else + partial(render_with_possible_json_value, hass, tpl)) + for key, tpl in templates.items()} + + def state_received(topic, payload, qos): + """ A new MQTT message has been received. """ + payload = templates['state'](payload) + if payload == self._payload["on"]: + self._state = True + elif payload == self._payload["off"]: + self._state = False + + self.update_ha_state() + + if self._topic["state_topic"] is not None: + mqtt.subscribe(self._hass, self._topic["state_topic"], + state_received, self._qos) + + def brightness_received(topic, payload, qos): + """ A new MQTT message for the brightness has been received. """ + self._brightness = int(templates['brightness'](payload)) + self.update_ha_state() + + if self._topic["brightness_state_topic"] is not None: + mqtt.subscribe(self._hass, self._topic["brightness_state_topic"], + brightness_received, self._qos) + self._brightness = 255 + else: + self._brightness = None + + def rgb_received(topic, payload, qos): + """ A new MQTT message has been received. """ + self._rgb = [int(val) for val in + templates['rgb'](payload).split(',')] + self.update_ha_state() + + if self._topic["rgb_state_topic"] is not None: + mqtt.subscribe(self._hass, self._topic["rgb_state_topic"], + rgb_received, self._qos) + self._rgb = [255, 255, 255] + else: + self._rgb = None + + @property + def brightness(self): + """ Brightness of this light between 0..255. """ + return self._brightness + + @property + def rgb_color(self): + """ RGB color value. """ + return self._rgb + + @property + def should_poll(self): + """ No polling needed for a MQTT light. """ + return False + + @property + def name(self): + """ Returns the name of the device if any. """ + return self._name + + @property + def is_on(self): + """ True if device is on. """ + return self._state + + def turn_on(self, **kwargs): + """ Turn the device on. """ + should_update = False + + if ATTR_RGB_COLOR in kwargs and \ + self._topic["rgb_command_topic"] is not None: + mqtt.publish(self._hass, self._topic["rgb_command_topic"], + "{},{},{}".format(*kwargs[ATTR_RGB_COLOR]), self._qos) + + if self._optimistic_rgb: + self._rgb = kwargs[ATTR_RGB_COLOR] + should_update = True + + if ATTR_BRIGHTNESS in kwargs and \ + self._topic["brightness_command_topic"] is not None: + + mqtt.publish(self._hass, self._topic["brightness_command_topic"], + kwargs[ATTR_BRIGHTNESS], self._qos) + + if self._optimistic_brightness: + self._brightness = kwargs[ATTR_BRIGHTNESS] + should_update = True + + mqtt.publish(self._hass, self._topic["command_topic"], + self._payload["on"], self._qos) + + if self._optimistic: + # optimistically assume that switch has changed state + self._state = True + should_update = True + + if should_update: + self.update_ha_state() + + def turn_off(self, **kwargs): + """ Turn the device off. """ + mqtt.publish(self._hass, self._topic["command_topic"], + self._payload["off"], self._qos) + + if self._optimistic: + # optimistically assume that switch has changed state + self._state = False + self.update_ha_state() diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index 5d6f41fe509..6132c10a99c 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -4,15 +4,19 @@ homeassistant.components.light.rfxtrx Support for RFXtrx lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.rfxtrx.html +https://home-assistant.io/components/light.rfxtrx/ """ import logging import homeassistant.components.rfxtrx as rfxtrx -import RFXtrx as rfxtrxmod from homeassistant.components.light import Light from homeassistant.util import slugify +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.rfxtrx import ATTR_STATE, ATTR_FIREEVENT, ATTR_PACKETID, \ + ATTR_NAME, EVENT_BUTTON_PRESSED + + DEPENDENCIES = ['rfxtrx'] _LOGGER = logging.getLogger(__name__) @@ -20,14 +24,24 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Setup the RFXtrx platform. """ + import RFXtrx as rfxtrxmod + lights = [] devices = config.get('devices', None) + if devices: for entity_id, entity_info in devices.items(): if entity_id not in rfxtrx.RFX_DEVICES: - _LOGGER.info("Add %s rfxtrx.light", entity_info['name']) - rfxobject = rfxtrx.get_rfx_object(entity_info['packetid']) - new_light = RfxtrxLight(entity_info['name'], rfxobject, False) + _LOGGER.info("Add %s rfxtrx.light", entity_info[ATTR_NAME]) + + # Check if i must fire event + fire_event = entity_info.get(ATTR_FIREEVENT, False) + datas = {ATTR_STATE: False, ATTR_FIREEVENT: fire_event} + + rfxobject = rfxtrx.get_rfx_object(entity_info[ATTR_PACKETID]) + new_light = RfxtrxLight( + entity_info[ATTR_NAME], rfxobject, datas + ) rfxtrx.RFX_DEVICES[entity_id] = new_light lights.append(new_light) @@ -53,18 +67,37 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): ) pkt_id = "".join("{0:02x}".format(x) for x in event.data) entity_name = "%s : %s" % (entity_id, pkt_id) - new_light = RfxtrxLight(entity_name, event, False) + datas = {ATTR_STATE: False, ATTR_FIREEVENT: False} + new_light = RfxtrxLight(entity_name, event, datas) rfxtrx.RFX_DEVICES[entity_id] = new_light add_devices_callback([new_light]) # Check if entity exists or previously added automatically - if entity_id in rfxtrx.RFX_DEVICES: + if entity_id in rfxtrx.RFX_DEVICES \ + and isinstance(rfxtrx.RFX_DEVICES[entity_id], RfxtrxLight): + _LOGGER.debug( + "EntityID: %s light_update. Command: %s", + entity_id, + event.values['Command'] + ) if event.values['Command'] == 'On'\ or event.values['Command'] == 'Off': - if event.values['Command'] == 'On': - rfxtrx.RFX_DEVICES[entity_id].turn_on() - else: - rfxtrx.RFX_DEVICES[entity_id].turn_off() + + # Update the rfxtrx device state + is_on = event.values['Command'] == 'On' + # pylint: disable=protected-access + rfxtrx.RFX_DEVICES[entity_id]._state = is_on + rfxtrx.RFX_DEVICES[entity_id].update_ha_state() + + # Fire event + if rfxtrx.RFX_DEVICES[entity_id].should_fire_event: + rfxtrx.RFX_DEVICES[entity_id].hass.bus.fire( + EVENT_BUTTON_PRESSED, { + ATTR_ENTITY_ID: + rfxtrx.RFX_DEVICES[entity_id].entity_id, + ATTR_STATE: event.values['Command'].lower() + } + ) # Subscribe to main rfxtrx events if light_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: @@ -73,10 +106,11 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class RfxtrxLight(Light): """ Provides a RFXtrx light. """ - def __init__(self, name, event, state): + def __init__(self, name, event, datas): self._name = name self._event = event - self._state = state + self._state = datas[ATTR_STATE] + self._should_fire_event = datas[ATTR_FIREEVENT] @property def should_poll(self): @@ -88,6 +122,11 @@ class RfxtrxLight(Light): """ Returns the name of the light if any. """ return self._name + @property + def should_fire_event(self): + """ Returns is the device must fire event""" + return self._should_fire_event + @property def is_on(self): """ True if light is on. """ diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index ed8b4b663ea..9908737b7b1 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -20,6 +20,10 @@ turn_on: description: Color for the light in XY-format example: '[0.52, 0.43]' + color_temp: + description: Color temperature for the light in mireds (154-500) + example: '250' + brightness: description: Number between 0..255 indicating brightness example: 120 @@ -38,6 +42,7 @@ turn_on: description: Light effect values: - colorloop + - random turn_off: description: Turn a light off diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 819dce499e9..48a5a3ed814 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -2,36 +2,34 @@ homeassistant.components.light.tellstick ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Support for Tellstick lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.tellstick/ """ -import logging -# pylint: disable=no-name-in-module, import-error from homeassistant.components.light import Light, ATTR_BRIGHTNESS from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, ATTR_FRIENDLY_NAME) -import tellcore.constants as tellcore_constants -from tellcore.library import DirectCallbackDispatcher REQUIREMENTS = ['tellcore-py==1.1.2'] +SIGNAL_REPETITIONS = 1 # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return Tellstick lights. """ - try: - import tellcore.telldus as telldus - except ImportError: - logging.getLogger(__name__).exception( - "Failed to import tellcore") - return [] + import tellcore.telldus as telldus + from tellcore.library import DirectCallbackDispatcher + import tellcore.constants as tellcore_constants core = telldus.TelldusCore(callback_dispatcher=DirectCallbackDispatcher()) + signal_repetitions = config.get('signal_repetitions', SIGNAL_REPETITIONS) switches_and_lights = core.devices() lights = [] for switch in switches_and_lights: if switch.methods(tellcore_constants.TELLSTICK_DIM): - lights.append(TellstickLight(switch)) + lights.append(TellstickLight(switch, signal_repetitions)) def _device_event_callback(id_, method, data, cid): """ Called from the TelldusCore library to update one device """ @@ -55,17 +53,22 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class TellstickLight(Light): """ Represents a Tellstick light. """ - last_sent_command_mask = (tellcore_constants.TELLSTICK_TURNON | - tellcore_constants.TELLSTICK_TURNOFF | - tellcore_constants.TELLSTICK_DIM | - tellcore_constants.TELLSTICK_UP | - tellcore_constants.TELLSTICK_DOWN) - def __init__(self, tellstick_device): + def __init__(self, tellstick_device, signal_repetitions): + import tellcore.constants as tellcore_constants + self.tellstick_device = tellstick_device self.state_attr = {ATTR_FRIENDLY_NAME: tellstick_device.name} + self.signal_repetitions = signal_repetitions self._brightness = 0 + self.last_sent_command_mask = (tellcore_constants.TELLSTICK_TURNON | + tellcore_constants.TELLSTICK_TURNOFF | + tellcore_constants.TELLSTICK_DIM | + tellcore_constants.TELLSTICK_UP | + tellcore_constants.TELLSTICK_DOWN) + self.update() + @property def name(self): """ Returns the name of the switch if any. """ @@ -83,7 +86,8 @@ class TellstickLight(Light): def turn_off(self, **kwargs): """ Turns the switch off. """ - self.tellstick_device.turn_off() + for _ in range(self.signal_repetitions): + self.tellstick_device.turn_off() self._brightness = 0 self.update_ha_state() @@ -96,11 +100,14 @@ class TellstickLight(Light): else: self._brightness = brightness - self.tellstick_device.dim(self._brightness) + for _ in range(self.signal_repetitions): + self.tellstick_device.dim(self._brightness) self.update_ha_state() def update(self): """ Update state of the light. """ + import tellcore.constants as tellcore_constants + last_command = self.tellstick_device.last_sent_command( self.last_sent_command_mask) diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index f41bfb56685..829d3cfccdb 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -1,60 +1,22 @@ """ homeassistant.components.light.vera ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Support for Vera lights. This component is useful if you wish for switches -connected to your Vera controller to appear as lights in Home Assistant. -All switches will be added as a light unless you exclude them in the config. - -Configuration: - -To use the Vera lights you will need to add something like the following to -your configuration.yaml file. - -light: - platform: vera - vera_controller_url: http://YOUR_VERA_IP:3480/ - device_data: - 12: - name: My awesome switch - exclude: true - 13: - name: Another switch - -Variables: - -vera_controller_url -*Required -This is the base URL of your vera controller including the port number if not -running on 80. Example: http://192.168.1.21:3480/ - -device_data -*Optional -This contains an array additional device info for your Vera devices. It is not -required and if not specified all lights configured in your Vera controller -will be added with default values. You should use the id of your vera device -as the key for the device within device_data. - -These are the variables for the device_data array: - -name -*Optional -This parameter allows you to override the name of your Vera device in the HA -interface, if not specified the value configured for the device in your Vera -will be used. - -exclude -*Optional -This parameter allows you to exclude the specified device from Home Assistant, -it should be set to "true" if you want this device excluded. +Support for Vera lights. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.vera/ """ import logging +import time + from requests.exceptions import RequestException from homeassistant.components.switch.vera import VeraSwitch -REQUIREMENTS = ['https://github.com/balloob/home-assistant-vera-api/archive/' - 'a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip' - '#python-vera==0.1'] +from homeassistant.components.light import ATTR_BRIGHTNESS + +REQUIREMENTS = ['https://github.com/pavoni/home-assistant-vera-api/archive/' + 'efdba4e63d58a30bc9b36d9e01e69858af9130b8.zip' + '#python-vera==0.1.1'] _LOGGER = logging.getLogger(__name__) @@ -77,7 +39,10 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): controller = veraApi.VeraController(base_url) devices = [] try: - devices = controller.get_devices(['Switch', 'On/Off Switch']) + devices = controller.get_devices([ + 'Switch', + 'On/Off Switch', + 'Dimmable Switch']) except RequestException: # There was a network related error connecting to the vera controller _LOGGER.exception("Error communicating with Vera API") @@ -89,6 +54,28 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): exclude = extra_data.get('exclude', False) if exclude is not True: - lights.append(VeraSwitch(device, extra_data)) + lights.append(VeraLight(device, extra_data)) add_devices_callback(lights) + + +class VeraLight(VeraSwitch): + """ Represents a Vera Light, including dimmable. """ + + @property + def state_attributes(self): + attr = super().state_attributes or {} + + if self.vera_device.is_dimmable: + attr[ATTR_BRIGHTNESS] = self.vera_device.get_brightness() + + return attr + + def turn_on(self, **kwargs): + if ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable: + self.vera_device.set_brightness(kwargs[ATTR_BRIGHTNESS]) + else: + self.vera_device.switch_on() + + self.last_command_send = time.time() + self.is_on_status = True diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 98988c20688..7b99b1882ae 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -2,6 +2,9 @@ homeassistant.components.light.wink ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Support for Wink lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.wink/ """ import logging @@ -9,9 +12,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.wink import WinkToggleDevice from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['https://github.com/balloob/python-wink/archive/' - 'c2b700e8ca866159566ecf5e644d9c297f69f257.zip' - '#python-wink==0.1'] +REQUIREMENTS = ['python-wink==0.3.1'] def setup_platform(hass, config, add_devices_callback, discovery_info=None): @@ -42,10 +43,10 @@ class WinkLight(WinkToggleDevice): brightness = kwargs.get(ATTR_BRIGHTNESS) if brightness is not None: - self.wink.setState(True, brightness / 255) + self.wink.set_state(True, brightness=brightness / 255) else: - self.wink.setState(True) + self.wink.set_state(True) @property def state_attributes(self): diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py new file mode 100644 index 00000000000..02664ed896c --- /dev/null +++ b/homeassistant/components/light/zwave.py @@ -0,0 +1,127 @@ +""" +homeassistant.components.light.zwave +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Z-Wave lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.zwave/ +""" +# Because we do not compile openzwave on CI +# pylint: disable=import-error +from threading import Timer + +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.light import (Light, ATTR_BRIGHTNESS) +import homeassistant.components.zwave as zwave + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Find and add Z-Wave lights. """ + if discovery_info is None: + return + + node = zwave.NETWORK.nodes[discovery_info[zwave.ATTR_NODE_ID]] + value = node.values[discovery_info[zwave.ATTR_VALUE_ID]] + + if value.command_class != zwave.COMMAND_CLASS_SWITCH_MULTILEVEL: + return + if value.type != zwave.TYPE_BYTE: + return + if value.genre != zwave.GENRE_USER: + return + + value.set_change_verified(False) + add_devices([ZwaveDimmer(value)]) + + +def brightness_state(value): + """ + Returns the brightness and state according to the current data of given + value. + """ + if value.data > 0: + return (value.data / 99) * 255, STATE_ON + else: + return 255, STATE_OFF + + +class ZwaveDimmer(Light): + """ Provides a Z-Wave dimmer. """ + # pylint: disable=too-many-arguments + def __init__(self, value): + from openzwave.network import ZWaveNetwork + from pydispatch import dispatcher + + self._value = value + self._node = value.node + + self._brightness, self._state = brightness_state(value) + + # Used for value change event handling + self._refreshing = False + self._timer = None + + dispatcher.connect( + self._value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + + def _value_changed(self, value): + """ Called when a value has changed on the network. """ + if self._value.value_id != value.value_id: + return + + if self._refreshing: + self._refreshing = False + self._brightness, self._state = brightness_state(value) + else: + def _refresh_value(): + """Used timer callback for delayed value refresh.""" + self._refreshing = True + self._value.refresh() + + if self._timer is not None and self._timer.isAlive(): + self._timer.cancel() + + self._timer = Timer(2, _refresh_value) + self._timer.start() + + self.update_ha_state() + + @property + def should_poll(self): + """ No polling needed for a light. """ + return False + + @property + def name(self): + """ Returns the name of the device if any. """ + name = self._node.name or "{}".format(self._node.product_name) + + return "{}".format(name or self._value.label) + + @property + def brightness(self): + """ Brightness of this light between 0..255. """ + return self._brightness + + @property + def is_on(self): + """ True if device is on. """ + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """ Turn the device on. """ + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + # Zwave multilevel switches use a range of [0, 99] to control + # brightness. + brightness = (self._brightness / 255) * 99 + + if self._node.set_dimmer(self._value.value_id, brightness): + self._state = STATE_ON + + def turn_off(self, **kwargs): + """ Turn the device off. """ + if self._node.set_dimmer(self._value.value_id, 0): + self._state = STATE_OFF diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py new file mode 100644 index 00000000000..0d67679e82c --- /dev/null +++ b/homeassistant/components/lock/__init__.py @@ -0,0 +1,112 @@ +""" +homeassistant.components.lock +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Component to interface with various locks that can be controlled remotely. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/lock/ +""" +from datetime import timedelta +import logging +import os + +from homeassistant.config import load_yaml_config_file +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity + +from homeassistant.const import ( + STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN, SERVICE_LOCK, SERVICE_UNLOCK, + ATTR_ENTITY_ID) +from homeassistant.components import (group, wink) + +DOMAIN = 'lock' +SCAN_INTERVAL = 30 + +GROUP_NAME_ALL_LOCKS = 'all locks' +ENTITY_ID_ALL_LOCKS = group.ENTITY_ID_FORMAT.format('all_locks') + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +ATTR_LOCKED = "locked" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +# Maps discovered services to their platforms +DISCOVERY_PLATFORMS = { + wink.DISCOVER_LOCKS: 'wink' +} + +_LOGGER = logging.getLogger(__name__) + + +def is_locked(hass, entity_id=None): + """ Returns if the lock is locked based on the statemachine. """ + entity_id = entity_id or ENTITY_ID_ALL_LOCKS + return hass.states.is_state(entity_id, STATE_LOCKED) + + +def lock(hass, entity_id=None): + """ Locks all or specified locks. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_LOCK, data) + + +def unlock(hass, entity_id=None): + """ Unlocks all or specified locks. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_UNLOCK, data) + + +def setup(hass, config): + """ Track states and offer events for locks. """ + component = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS, + GROUP_NAME_ALL_LOCKS) + component.setup(config) + + def handle_lock_service(service): + """ Handles calls to the lock services. """ + target_locks = component.extract_from_service(service) + + for item in target_locks: + if service.service == SERVICE_LOCK: + item.lock() + else: + item.unlock() + + if item.should_poll: + item.update_ha_state(True) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.services.register(DOMAIN, SERVICE_UNLOCK, handle_lock_service, + descriptions.get(SERVICE_UNLOCK)) + hass.services.register(DOMAIN, SERVICE_LOCK, handle_lock_service, + descriptions.get(SERVICE_LOCK)) + + return True + + +class LockDevice(Entity): + """ Represents a lock within Home Assistant. """ + # pylint: disable=no-self-use + + @property + def is_locked(self): + """ Is the lock locked or unlocked. """ + return None + + def lock(self): + """ Locks the lock. """ + raise NotImplementedError() + + def unlock(self): + """ Unlocks the lock. """ + raise NotImplementedError() + + @property + def state(self): + locked = self.is_locked + if locked is None: + return STATE_UNKNOWN + return STATE_LOCKED if locked else STATE_UNLOCKED diff --git a/homeassistant/components/lock/demo.py b/homeassistant/components/lock/demo.py new file mode 100644 index 00000000000..472b17f46bf --- /dev/null +++ b/homeassistant/components/lock/demo.py @@ -0,0 +1,49 @@ +""" +homeassistant.components.lock.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Demo platform that has two fake locks. +""" +from homeassistant.components.lock import LockDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Find and return demo locks. """ + add_devices_callback([ + DemoLock('Front Door', STATE_LOCKED), + DemoLock('Kitchen Door', STATE_UNLOCKED) + ]) + + +class DemoLock(LockDevice): + """ Provides a demo lock. """ + def __init__(self, name, state): + self._name = name + self._state = state + + @property + def should_poll(self): + """ No polling needed for a demo lock. """ + return False + + @property + def name(self): + """ Returns the name of the device if any. """ + return self._name + + @property + def is_locked(self): + """ True if device is locked. """ + return self._state == STATE_LOCKED + + def lock(self, **kwargs): + """ Lock the device. """ + self._state = STATE_LOCKED + self.update_ha_state() + + def unlock(self, **kwargs): + """ Unlock the device. """ + self._state = STATE_UNLOCKED + self.update_ha_state() diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py new file mode 100644 index 00000000000..bbf4ef04f64 --- /dev/null +++ b/homeassistant/components/lock/wink.py @@ -0,0 +1,66 @@ +""" +homeassistant.components.lock.wink +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Wink locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.wink/ +""" +import logging + +from homeassistant.components.lock import LockDevice +from homeassistant.const import CONF_ACCESS_TOKEN + +REQUIREMENTS = ['python-wink==0.3.1'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Wink platform. """ + import pywink + + if discovery_info is None: + token = config.get(CONF_ACCESS_TOKEN) + + if token is None: + logging.getLogger(__name__).error( + "Missing wink access_token. " + "Get one at https://winkbearertoken.appspot.com/") + return + + pywink.set_bearer_token(token) + + add_devices(WinkLockDevice(lock) for lock in pywink.get_locks()) + + +class WinkLockDevice(LockDevice): + """ Represents a Wink lock. """ + + def __init__(self, wink): + self.wink = wink + + @property + def unique_id(self): + """ Returns the id of this wink lock """ + return "{}.{}".format(self.__class__, self.wink.device_id()) + + @property + def name(self): + """ Returns the name of the lock if any. """ + return self.wink.name() + + def update(self): + """ Update the state of the lock. """ + self.wink.update_state() + + @property + def is_locked(self): + """ True if device is locked. """ + return self.wink.state() + + def lock(self): + """ Lock the device. """ + self.wink.set_state(True) + + def unlock(self): + """ Unlock the device. """ + self.wink.set_state(False) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 75a5cd83823..16159404dec 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -1,8 +1,10 @@ """ homeassistant.components.logbook ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Parses events and generates a human log. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/logbook/ """ from datetime import timedelta from itertools import groupby @@ -26,7 +28,7 @@ QUERY_EVENTS_BETWEEN = """ SELECT * FROM events WHERE time_fired > ? AND time_fired < ? """ -EVENT_LOGBOOK_ENTRY = 'LOGBOOK_ENTRY' +EVENT_LOGBOOK_ENTRY = 'logbook_entry' GROUP_BY_MINUTES = 15 @@ -202,7 +204,7 @@ def humanify(events): event.time_fired, "Home Assistant", action, domain=HA_DOMAIN) - elif event.event_type == EVENT_LOGBOOK_ENTRY: + elif event.event_type.lower() == EVENT_LOGBOOK_ENTRY: domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) if domain is None and entity_id is not None: diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py new file mode 100644 index 00000000000..a0d769e3d82 --- /dev/null +++ b/homeassistant/components/logger.py @@ -0,0 +1,87 @@ +""" +homeassistant.components.logger +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Component that will help set the level of logging for components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/logger/ +""" +import logging +from collections import OrderedDict + +DOMAIN = 'logger' + +LOGSEVERITY = { + 'CRITICAL': 50, + 'FATAL': 50, + 'ERROR': 40, + 'WARNING': 30, + 'WARN': 30, + 'INFO': 20, + 'DEBUG': 10, + 'NOTSET': 0 +} + +LOGGER_DEFAULT = 'default' +LOGGER_LOGS = 'logs' + + +class HomeAssistantLogFilter(logging.Filter): + """ A log filter. """ + # pylint: disable=no-init,too-few-public-methods + + def __init__(self, logfilter): + super().__init__() + + self.logfilter = logfilter + + def filter(self, record): + + # Log with filtered severity + if LOGGER_LOGS in self.logfilter: + for filtername in self.logfilter[LOGGER_LOGS]: + logseverity = self.logfilter[LOGGER_LOGS][filtername] + if record.name.startswith(filtername): + return record.levelno >= logseverity + + # Log with default severity + default = self.logfilter[LOGGER_DEFAULT] + return record.levelno >= default + + +def setup(hass, config=None): + """ Setup the logger component. """ + + logfilter = dict() + + # Set default log severity + logfilter[LOGGER_DEFAULT] = LOGSEVERITY['DEBUG'] + if LOGGER_DEFAULT in config.get(DOMAIN): + logfilter[LOGGER_DEFAULT] = LOGSEVERITY[ + config.get(DOMAIN)[LOGGER_DEFAULT].upper() + ] + + # Compute log severity for components + if LOGGER_LOGS in config.get(DOMAIN): + for key, value in config.get(DOMAIN)[LOGGER_LOGS].items(): + config.get(DOMAIN)[LOGGER_LOGS][key] = LOGSEVERITY[value.upper()] + + logs = OrderedDict( + sorted( + config.get(DOMAIN)[LOGGER_LOGS].items(), + key=lambda t: len(t[0]), + reverse=True + ) + ) + + logfilter[LOGGER_LOGS] = logs + + logger = logging.getLogger('') + logger.setLevel(logging.NOTSET) + + # Set log filter for all log handler + for handler in logging.root.handlers: + handler.setLevel(logging.NOTSET) + handler.addFilter(HomeAssistantLogFilter(logfilter)) + + return True diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 294fccbb1f5..8204052b4a9 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1,8 +1,10 @@ """ homeassistant.components.media_player ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Component to interface with various media players. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/media_player/ """ import logging import os @@ -20,7 +22,6 @@ from homeassistant.const import ( SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK) DOMAIN = 'media_player' -DEPENDENCIES = [] SCAN_INTERVAL = 10 ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -28,6 +29,7 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' DISCOVERY_PLATFORMS = { discovery.SERVICE_CAST: 'cast', discovery.SERVICE_SONOS: 'sonos', + discovery.SERVICE_PLEX: 'plex', } SERVICE_YOUTUBE_VIDEO = 'play_youtube_video' diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 6f622c9e0cc..87117cfd367 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -3,23 +3,10 @@ homeassistant.components.media_player.chromecast ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to interact with Cast devices on the network. -WARNING: This platform is currently not working due to a changed Cast API. - -Configuration: - -To use the chromecast integration you will need to add something like the -following to your configuration.yaml file. - -media_player: - platform: chromecast - host: 192.168.1.9 - -Variables: - -host -*Optional -Use only if you don't want to scan for devices. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.cast/ """ +# pylint: disable=import-error import logging from homeassistant.const import ( @@ -29,16 +16,16 @@ from homeassistant.const import ( from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, - SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) -REQUIREMENTS = ['pychromecast==0.6.12'] +REQUIREMENTS = ['pychromecast==0.6.13'] CONF_IGNORE_CEC = 'ignore_cec' CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_YOUTUBE + SUPPORT_NEXT_TRACK | SUPPORT_YOUTUBE | SUPPORT_PLAY_MEDIA KNOWN_HOSTS = [] # pylint: disable=invalid-name @@ -275,6 +262,10 @@ class CastDevice(MediaPlayerDevice): """ Seek the media to a specific location. """ self.cast.media_controller.seek(position) + def play_media(self, media_type, media_id): + """ Plays media from a URL """ + self.cast.media_controller.play_media(media_id, media_type) + def play_youtube(self, media_id): """ Plays a YouTube media. """ self.youtube.play_video(media_id) diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index 19286906f49..e8301bc2509 100644 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -2,42 +2,9 @@ homeassistant.components.media_player.denon ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides an interface to Denon Network Receivers. -Developed for a Denon DRA-N5, see -http://www.denon.co.uk/chg/product/compactsystems/networkmusicsystems/ceolpiccolo -A few notes: - - As long as this module is active and connected, the receiver does - not seem to accept additional telnet connections. - - - Be careful with the volume. 50% or even 100% are very loud. - - - To be able to wake up the receiver, activate the "remote" setting - in the receiver's settings. - - - Play and pause are supported, toggling is not possible. - - - Seeking cannot be implemented as the UI sends absolute positions. - Only seeking via simulated button presses is possible. - -Configuration: - -To use your Denon you will need to add something like the following to -your config/configuration.yaml: - -media_player: - platform: denon - name: Music station - host: 192.168.0.123 - -Variables: - -host -*Required -The ip of the player. Example: 192.168.0.123 - -name -*Optional -The name of the device. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.denon/ """ import telnetlib import logging @@ -67,13 +34,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_HOST) return False - add_devices([ - DenonDevice( - config.get('name', 'Music station'), - config.get('host')) - ]) - - return True + denon = DenonDevice( + config.get("name", "Music station"), + config.get("host") + ) + if denon.update(): + add_devices([denon]) + return True + else: + return False class DenonDevice(MediaPlayerDevice): @@ -84,28 +53,41 @@ class DenonDevice(MediaPlayerDevice): def __init__(self, name, host): self._name = name self._host = host - self._telnet = telnetlib.Telnet(self._host) + self._pwstate = "PWSTANDBY" + self._volume = 0 + self._muted = False + self._mediasource = "" - def query(self, message): - """ Send request and await response from server """ + @classmethod + def telnet_request(cls, telnet, command): + """ Executes `command` and returns the response. """ + telnet.write(command.encode("ASCII") + b"\r") + return telnet.read_until(b"\r", timeout=0.2).decode("ASCII").strip() + + def telnet_command(self, command): + """ Establishes a telnet connection and sends `command`. """ + telnet = telnetlib.Telnet(self._host) + telnet.write(command.encode("ASCII") + b"\r") + telnet.read_very_eager() # skip response + telnet.close() + + def update(self): try: - # unspecified command, should be ignored - self._telnet.write("?".encode('UTF-8') + b'\r') - except (EOFError, BrokenPipeError, ConnectionResetError): - self._telnet.open(self._host) + telnet = telnetlib.Telnet(self._host) + except ConnectionRefusedError: + return False - self._telnet.read_very_eager() # skip what is not requested + self._pwstate = self.telnet_request(telnet, "PW?") + # PW? sends also SISTATUS, which is not interesting + telnet.read_until(b"\r", timeout=0.2) - self._telnet.write(message.encode('ASCII') + b'\r') - # timeout 200ms, defined by protocol - resp = self._telnet.read_until(b'\r', timeout=0.2)\ - .decode('UTF-8').strip() + volume_str = self.telnet_request(telnet, "MV?")[len("MV"):] + self._volume = int(volume_str) / 60 + self._muted = (self.telnet_request(telnet, "MU?") == "MUON") + self._mediasource = self.telnet_request(telnet, "SI?")[len("SI"):] - if message == "PW?": - # workaround; PW? sends also SISTATUS - self._telnet.read_until(b'\r', timeout=0.2) - - return resp + telnet.close() + return True @property def name(self): @@ -115,10 +97,9 @@ class DenonDevice(MediaPlayerDevice): @property def state(self): """ Returns the state of the device. """ - pwstate = self.query('PW?') - if pwstate == "PWSTANDBY": + if self._pwstate == "PWSTANDBY": return STATE_OFF - if pwstate == "PWON": + if self._pwstate == "PWON": return STATE_ON return STATE_UNKNOWN @@ -126,17 +107,17 @@ class DenonDevice(MediaPlayerDevice): @property def volume_level(self): """ Volume level of the media player (0..1). """ - return int(self.query('MV?')[len('MV'):]) / 60 + return self._volume @property def is_volume_muted(self): """ Boolean if volume is currently muted. """ - return self.query('MU?') == "MUON" + return self._muted @property def media_title(self): """ Current media source. """ - return self.query('SI?')[len('SI'):] + return self._mediasource @property def supported_media_commands(self): @@ -145,24 +126,24 @@ class DenonDevice(MediaPlayerDevice): def turn_off(self): """ turn_off media player. """ - self.query('PWSTANDBY') + self.telnet_command("PWSTANDBY") def volume_up(self): """ volume_up media player. """ - self.query('MVUP') + self.telnet_command("MVUP") def volume_down(self): """ volume_down media player. """ - self.query('MVDOWN') + self.telnet_command("MVDOWN") def set_volume_level(self, volume): """ set volume level, range 0..1. """ # 60dB max - self.query('MV' + str(round(volume * 60)).zfill(2)) + self.telnet_command("MV" + str(round(volume * 60)).zfill(2)) def mute_volume(self, mute): """ mute (true) or unmute (false) media player. """ - self.query('MU' + ('ON' if mute else 'OFF')) + self.telnet_command("MU" + ("ON" if mute else "OFF")) def media_play_pause(self): """ media_play_pause media player. """ @@ -170,22 +151,22 @@ class DenonDevice(MediaPlayerDevice): def media_play(self): """ media_play media player. """ - self.query('NS9A') + self.telnet_command("NS9A") def media_pause(self): """ media_pause media player. """ - self.query('NS9B') + self.telnet_command("NS9B") def media_next_track(self): """ Send next track command. """ - self.query('NS9D') + self.telnet_command("NS9D") def media_previous_track(self): - self.query('NS9E') + self.telnet_command("NS9E") def media_seek(self, position): raise NotImplementedError() def turn_on(self): """ turn the media player on. """ - self.query('PWON') + self.telnet_command("PWON") diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 9db17416bda..e5f9885f86e 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -1,47 +1,11 @@ """ homeassistant.components.media_player.firetv ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Provides functionality to interact with FireTV devices. -Provides control over an Amazon Fire TV (/stick) via -python-firetv, a Python 2.x module with a helper script -that exposes a HTTP server to fetch state and perform -actions. - -Steps to configure your Amazon Fire TV stick with Home Assistant: - -1. Turn on ADB Debugging on your Amazon Fire TV: - a. From the main (Launcher) screen, select Settings. - b. Select System > Developer Options. - c. Select ADB Debugging. -2. Find Amazon Fire TV device IP: - a. From the main (Launcher) screen, select Settings. - b. Select System > About > Network. -3. `pip install firetv[firetv-server]` into a Python 2.x environment -4. `firetv-server -d :5555`, background the process -5. Configure Home Assistant as follows: - -media_player: - platform: firetv - # optional: where firetv-server is running (default is 'localhost:5556') - host: localhost:5556 - # optional: device id (default is 'default') - device: livingroom-firetv - # optional: friendly name (default is 'Amazon Fire TV') - name: My Amazon Fire TV - -Note that python-firetv has support for multiple Amazon Fire TV devices. -If you have more than one configured, be sure to specify the device id used. -Run `firetv-server -h` and/or view the source for complete capabilities. - -Possible states are: - - off (TV screen is dark) - - standby (standard UI is active - not apps) - - idle (screen saver is active) - - play (video is playing) - - pause (video is paused) - - disconnected (can't communicate with device) +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.firetv/ """ - import logging import requests @@ -69,7 +33,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """ Sets up the firetv platform. """ + """ Sets up the FireTV platform. """ host = config.get('host', 'localhost:5556') device_id = config.get('device', 'default') try: @@ -85,7 +49,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.info( 'Device %s accessible and ready for control', device_id) else: - _LOGGER.warn( + _LOGGER.warning( 'Device %s is not registered with firetv-server', device_id) except requests.exceptions.RequestException: _LOGGER.error('Could not connect to firetv-server at %s', host) @@ -94,12 +58,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class FireTV(object): """ firetv-server client. - Should a native Python 3 ADB module become available, - python-firetv can support Python 3, it can be added - as a dependency, and this class can be dispensed of. + Should a native Python 3 ADB module become available, python-firetv can + support Python 3, it can be added as a dependency, and this class can be + dispensed of. - For now, it acts as a client to the firetv-server - HTTP server (which must be running via Python 2). + For now, it acts as a client to the firetv-server HTTP server (which must + be running via Python 2). """ def __init__(self, host, device_id): @@ -108,10 +72,7 @@ class FireTV(object): @property def state(self): - """ Get the device state. - - An exception means UNKNOWN state. - """ + """ Get the device state. An exception means UNKNOWN state. """ try: response = requests.get( DEVICE_STATE_URL.format( @@ -126,11 +87,7 @@ class FireTV(object): return STATE_UNKNOWN def action(self, action_id): - """ Perform an action on the device. - - There is no action acknowledgment, so exceptions - result in a pass. - """ + """ Perform an action on the device. """ try: requests.get( DEVICE_ACTION_URL.format( @@ -193,7 +150,7 @@ class FireTVDevice(MediaPlayerDevice): self._firetv.action('turn_off') def media_play(self): - """ Send play commmand. """ + """ Send play command. """ self._firetv.action('media_play') def media_pause(self): diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py index 70def719146..5d08a7e95d4 100644 --- a/homeassistant/components/media_player/itunes.py +++ b/homeassistant/components/media_player/itunes.py @@ -1,39 +1,15 @@ """ homeassistant.components.media_player.itunes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Provides an interface to iTunes-API (https://github.com/maddox/itunes-api) - -The iTunes media player will allow you to control your iTunes instance. You -can play/pause/next/previous/mute, adjust volume, etc. - -In addition to controlling iTunes, your available AirPlay endpoints will be -added as media players as well. You can then individually address them append -turn them on, turn them off, or adjust their volume. - -Configuration: - -To use iTunes you will need to add something like the following to -your configuration.yaml file. - -media_player: - platform: itunes - name: iTunes - host: http://192.168.1.16 - port: 8181 - -Variables: - -name -*Optional -The name of the device. - -url -*Required -URL of your running version of iTunes-API. Example: http://192.168.1.50:8181 +Provides an interface to iTunes API. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.itunes/ """ import logging +import requests + from homeassistant.components.media_player import ( MediaPlayerDevice, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, @@ -43,8 +19,6 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_ON) -import requests - _LOGGER = logging.getLogger(__name__) SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ @@ -157,11 +131,9 @@ class Itunes(object): path = '/airplay_devices/' + device_id + '/volume' return self._request('PUT', path, {'level': level}) -# pylint: disable=unused-argument -# pylint: disable=abstract-method + +# pylint: disable=unused-argument, abstract-method # pylint: disable=too-many-instance-attributes - - def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the itunes platform. """ @@ -179,7 +151,6 @@ class ItunesDevice(MediaPlayerDevice): """ Represents a iTunes-API instance. """ # pylint: disable=too-many-public-methods - def __init__(self, name, host, port, add_devices): self._name = name self._host = host diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 2fe42e2e707..6fe6be554c6 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -3,35 +3,8 @@ homeassistant.components.media_player.kodi ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides an interface to the XBMC/Kodi JSON-RPC API -Configuration: - -To use the Kodi you will need to add something like the following to -your configuration.yaml file. - -media_player: - platform: kodi - name: Kodi - url: http://192.168.0.123/jsonrpc - user: kodi - password: my_secure_password - -Variables: - -name -*Optional -The name of the device. - -url -*Required -The URL of the XBMC/Kodi JSON-RPC API. Example: http://192.168.0.123/jsonrpc - -user -*Optional -The XBMC/Kodi HTTP username. - -password -*Optional -The XBMC/Kodi HTTP password. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.kodi/ """ import urllib import logging @@ -42,11 +15,6 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF) -try: - import jsonrpc_requests -except ImportError: - jsonrpc_requests = None - _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['jsonrpc-requests==0.1'] @@ -58,11 +26,6 @@ SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the kodi platform. """ - global jsonrpc_requests # pylint: disable=invalid-name - if jsonrpc_requests is None: - import jsonrpc_requests as jsonrpc_requests_ - jsonrpc_requests = jsonrpc_requests_ - add_devices([ KodiDevice( config.get('name', 'Kodi'), @@ -87,6 +50,7 @@ class KodiDevice(MediaPlayerDevice): # pylint: disable=too-many-public-methods def __init__(self, name, url, auth=None): + import jsonrpc_requests self._name = name self._url = url self._server = jsonrpc_requests.Server(url, auth=auth) @@ -104,6 +68,7 @@ class KodiDevice(MediaPlayerDevice): def _get_players(self): """ Returns the active player objects or None """ + import jsonrpc_requests try: return self._server.Player.GetActivePlayers() except jsonrpc_requests.jsonrpc.TransportError: diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 8cc22f9b982..27b5aa3863c 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -3,35 +3,8 @@ homeassistant.components.media_player.mpd ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to interact with a Music Player Daemon. -Configuration: - -To use MPD you will need to add something like the following to your -configuration.yaml file. - -media_player: - platform: mpd - server: 127.0.0.1 - port: 6600 - location: bedroom - password: superSecretPassword123 - -Variables: - -server -*Required -IP address of the Music Player Daemon. Example: 192.168.1.32 - -port -*Optional -Port of the Music Player Daemon, defaults to 6600. Example: 6600 - -location -*Optional -Location of your Music Player Daemon. - -password -*Optional -Password for your Music Player Daemon. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.mpd/ """ import logging import socket @@ -48,14 +21,14 @@ from homeassistant.const import ( from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_TURN_OFF, - SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_MUSIC) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['python-mpd2==0.5.4'] SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK # pylint: disable=unused-argument @@ -168,7 +141,17 @@ class MpdDevice(MediaPlayerDevice): @property def media_title(self): """ Title of current playing media. """ - return self.currentsong['title'] + name = self.currentsong.get('name', None) + title = self.currentsong.get('title', None) + + if name is None and title is None: + return "None" + elif name is None: + return title + elif title is None: + return name + else: + return '{}: {}'.format(name, title) @property def media_artist(self): @@ -190,9 +173,13 @@ class MpdDevice(MediaPlayerDevice): return SUPPORT_MPD def turn_off(self): - """ Service to exit the running MPD. """ + """ Service to send the MPD the command to stop playing. """ self.client.stop() + def turn_on(self): + """ Service to send the MPD the command to start playing. """ + self.client.play() + def set_volume_level(self, volume): """ Sets volume """ self.client.setvol(int(volume * 100)) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 5fac9ecb0f0..6925f942be4 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -4,40 +4,116 @@ homeassistant.components.media_player.plex Provides an interface to the Plex API. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.plex.html +https://home-assistant.io/components/media_player.plex/ """ +import os +import json import logging from datetime import timedelta +from urllib.parse import urlparse +from homeassistant.loader import get_component +import homeassistant.util as util from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) from homeassistant.const import ( - STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_UNKNOWN) -import homeassistant.util as util + DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_PLAYING, + STATE_PAUSED, STATE_OFF, STATE_UNKNOWN) -REQUIREMENTS = ['https://github.com/adrienbrault/python-plexapi/archive/' - 'df2d0847e801d6d5cda920326d693cf75f304f1a.zip' - '#python-plexapi==1.0.2'] +REQUIREMENTS = ['plexapi==1.1.0'] MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +PLEX_CONFIG_FILE = 'plex.conf' + +# Map ip to request id for configuring +_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK -# pylint: disable=abstract-method, unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """ Sets up the plex platform. """ - from plexapi.myplex import MyPlexUser - from plexapi.exceptions import BadRequest +def config_from_file(filename, config=None): + ''' Small configuration file management function''' + if config: + # We're writing configuration + try: + with open(filename, 'w') as fdesc: + fdesc.write(json.dumps(config)) + except IOError as error: + _LOGGER.error('Saving config file failed: %s', error) + return False + return True + else: + # We're reading config + if os.path.isfile(filename): + try: + with open(filename, 'r') as fdesc: + return json.loads(fdesc.read()) + except IOError as error: + _LOGGER.error('Reading config file failed: %s', error) + # This won't work yet + return False + else: + return {} + + +# pylint: disable=abstract-method, unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Sets up the plex platform. """ + + config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) + if len(config): + # Setup a configured PlexServer + host, token = config.popitem() + token = token['token'] + # Via discovery + elif discovery_info is not None: + # Parse discovery data + host = urlparse(discovery_info[1]).netloc + _LOGGER.info('Discovered PLEX server: %s', host) + + if host in _CONFIGURING: + return + token = None + else: + return + + setup_plexserver(host, token, hass, add_devices_callback) + + +# pylint: disable=too-many-branches +def setup_plexserver(host, token, hass, add_devices_callback): + ''' Setup a plexserver based on host parameter''' + import plexapi.server + import plexapi.exceptions + + try: + plexserver = plexapi.server.PlexServer('http://%s' % host, token) + except (plexapi.exceptions.BadRequest, + plexapi.exceptions.Unauthorized, + plexapi.exceptions.NotFound) as error: + _LOGGER.info(error) + # No token or wrong token + request_configuration(host, hass, add_devices_callback) + return + + # If we came here and configuring this host, mark as done + if host in _CONFIGURING: + request_id = _CONFIGURING.pop(host) + configurator = get_component('configurator') + configurator.request_done(request_id) + _LOGGER.info('Discovery configuration done!') + + # Save config + if not config_from_file( + hass.config.path(PLEX_CONFIG_FILE), + {host: {'token': token}}): + _LOGGER.error('failed to save config file') + + _LOGGER.info('Connected to: htts://%s', host) - name = config.get('name', '') - user = config.get('user', '') - password = config.get('password', '') - plexuser = MyPlexUser.signin(user, password) - plexserver = plexuser.getResource(name).connect() plex_clients = {} plex_sessions = {} @@ -45,34 +121,34 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def update_devices(): """ Updates the devices objects. """ try: - devices = plexuser.devices() - except BadRequest: + devices = plexserver.clients() + except plexapi.exceptions.BadRequest: _LOGGER.exception("Error listing plex devices") return new_plex_clients = [] for device in devices: - if (all(x not in ['client', 'player'] for x in device.provides) - or 'PlexAPI' == device.product): + # For now, let's allow all deviceClass types + if device.deviceClass in ['badClient']: continue - if device.clientIdentifier not in plex_clients: + if device.machineIdentifier not in plex_clients: new_client = PlexClient(device, plex_sessions, update_devices, update_sessions) - plex_clients[device.clientIdentifier] = new_client + plex_clients[device.machineIdentifier] = new_client new_plex_clients.append(new_client) else: - plex_clients[device.clientIdentifier].set_device(device) + plex_clients[device.machineIdentifier].set_device(device) if new_plex_clients: - add_devices(new_plex_clients) + add_devices_callback(new_plex_clients) @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update_sessions(): """ Updates the sessions objects. """ try: sessions = plexserver.sessions() - except BadRequest: + except plexapi.exceptions.BadRequest: _LOGGER.exception("Error listing plex sessions") return @@ -84,10 +160,34 @@ def setup_platform(hass, config, add_devices, discovery_info=None): update_sessions() +def request_configuration(host, hass, add_devices_callback): + """ Request configuration steps from the user. """ + configurator = get_component('configurator') + + # We got an error if this method is called while we are configuring + if host in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING[host], "Failed to register, please try again.") + + return + + def plex_configuration_callback(data): + """ Actions to do when our configuration callback is called. """ + setup_plexserver(host, data.get('token'), hass, add_devices_callback) + + _CONFIGURING[host] = configurator.request_config( + hass, "Plex Media Server", plex_configuration_callback, + description=('Enter the X-Plex-Token'), + description_image="/static/images/config_plex_mediaserver.png", + submit_caption="Confirm", + fields=[{'id': 'token', 'name': 'X-Plex-Token', 'type': ''}] + ) + + class PlexClient(MediaPlayerDevice): """ Represents a Plex device. """ - # pylint: disable=too-many-public-methods + # pylint: disable=too-many-public-methods, attribute-defined-outside-init def __init__(self, device, plex_sessions, update_devices, update_sessions): self.plex_sessions = plex_sessions self.update_devices = update_devices @@ -99,17 +199,23 @@ class PlexClient(MediaPlayerDevice): self.device = device @property - def session(self): - """ Returns the session, if any. """ - if self.device.clientIdentifier not in self.plex_sessions: - return None - - return self.plex_sessions[self.device.clientIdentifier] + def unique_id(self): + """ Returns the id of this plex client """ + return "{}.{}".format( + self.__class__, self.device.machineIdentifier or self.device.name) @property def name(self): """ Returns the name of the device. """ - return self.device.name or self.device.product or self.device.device + return self.device.name or DEVICE_DEFAULT_NAME + + @property + def session(self): + """ Returns the session, if any. """ + if self.device.machineIdentifier not in self.plex_sessions: + return None + + return self.plex_sessions[self.device.machineIdentifier] @property def state(self): @@ -120,7 +226,8 @@ class PlexClient(MediaPlayerDevice): return STATE_PLAYING elif state == 'paused': return STATE_PAUSED - elif self.device.isReachable: + # This is nasty. Need to find a way to determine alive + elif self.device: return STATE_IDLE else: return STATE_OFF @@ -196,16 +303,16 @@ class PlexClient(MediaPlayerDevice): def media_play(self): """ media_play media player. """ - self.device.play({'type': 'video'}) + self.device.play() def media_pause(self): """ media_pause media player. """ - self.device.pause({'type': 'video'}) + self.device.pause() def media_next_track(self): """ Send next track command. """ - self.device.skipNext({'type': 'video'}) + self.device.skipNext() def media_previous_track(self): """ Send previous track command. """ - self.device.skipPrevious({'type': 'video'}) + self.device.skipPrevious() diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index faf4f6aa983..71c0c2aeb75 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -1,17 +1,11 @@ """ homeassistant.components.media_player.sonos -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides an interface to Sonos players (via SoCo) -Configuration: - -To use SoCo, add something like this to your configuration: - -media_player: - platform: sonos +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.sonos/ """ - import logging import datetime @@ -45,9 +39,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the Sonos platform. """ import soco + if discovery_info: + add_devices([SonosDevice(hass, soco.SoCo(discovery_info))]) + return True + players = soco.discover() + if not players: - _LOGGER.warning('No Sonos speakers found. Disabling: %s', __name__) + _LOGGER.warning('No Sonos speakers found.') return False add_devices(SonosDevice(hass, p) for p in players) @@ -56,8 +55,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return True -# pylint: disable=too-many-instance-attributes -# pylint: disable=too-many-public-methods +# pylint: disable=too-many-instance-attributes, too-many-public-methods # pylint: disable=abstract-method class SonosDevice(MediaPlayerDevice): """ Represents a Sonos device. """ @@ -74,7 +72,7 @@ class SonosDevice(MediaPlayerDevice): return True def update_sonos(self, now): - """ Updates state, called by track_utc_time_change """ + """ Updates state, called by track_utc_time_change. """ self.update_ha_state(True) @property @@ -162,31 +160,31 @@ class SonosDevice(MediaPlayerDevice): return SUPPORT_SONOS def turn_off(self): - """ turn_off media player. """ + """ Turn off media player. """ self._player.pause() def volume_up(self): - """ volume_up media player. """ + """ Volume up media player. """ self._player.volume += 1 def volume_down(self): - """ volume_down media player. """ + """ Volume down media player. """ self._player.volume -= 1 def set_volume_level(self, volume): - """ set volume level, range 0..1. """ + """ Set volume level, range 0..1. """ self._player.volume = str(int(volume * 100)) def mute_volume(self, mute): - """ mute (true) or unmute (false) media player. """ + """ Mute (true) or unmute (false) media player. """ self._player.mute = mute def media_play(self): - """ media_play media player. """ + """ Send paly command. """ self._player.play() def media_pause(self): - """ media_pause media player. """ + """ Send pause command. """ self._player.pause() def media_next_track(self): @@ -202,5 +200,5 @@ class SonosDevice(MediaPlayerDevice): self._player.seek(str(datetime.timedelta(seconds=int(position)))) def turn_on(self): - """ turn the media player on. """ + """ Turn the media player on. """ self._player.play() diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 940aa890f3a..d3139d52c01 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -1,39 +1,11 @@ """ homeassistant.components.media_player.squeezebox -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides an interface to the Logitech SqueezeBox API -Configuration: - -To use SqueezeBox add something something like the following to your -configuration.yaml file. - -media_player: - platform: squeezebox - host: 192.168.1.21 - port: 9090 - username: user - password: password - -Variables: - -host -*Required -The host name or address of the Logitech Media Server. - -port -*Optional -Telnet port to Logitech Media Server, default 9090. - -usermame -*Optional -Username, if password protection is enabled. - -password -*Optional -Password, if password protection is enabled. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.squeezebox/ """ - import logging import telnetlib import urllib.parse @@ -201,7 +173,7 @@ class SqueezeBoxDevice(MediaPlayerDevice): def volume_level(self): """ Volume level of the media player (0..1). """ if 'mixer volume' in self._status: - return int(self._status['mixer volume']) / 100.0 + return int(float(self._status['mixer volume'])) / 100.0 @property def is_volume_muted(self): @@ -291,7 +263,7 @@ class SqueezeBoxDevice(MediaPlayerDevice): def media_pause(self): """ media_pause media player. """ - self._lms.query(self._id, 'pause', '0') + self._lms.query(self._id, 'pause', '1') self.update_ha_state() def media_next_track(self): diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 844e59ea189..6f53c89835a 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -3,27 +3,8 @@ homeassistant.components.modbus ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Modbus component, using pymodbus (python3 branch). -Configuration: - -To use the Modbus component you will need to add something like the following -to your configuration.yaml file. - -#Modbus TCP -modbus: - type: tcp - host: 127.0.0.1 - port: 2020 - -#Modbus RTU -modbus: - type: serial - method: rtu - port: /dev/ttyUSB0 - baudrate: 9600 - stopbits: 1 - bytesize: 8 - parity: N - +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/modbus/ """ import logging @@ -32,7 +13,6 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_START, DOMAIN = "modbus" -DEPENDENCIES = [] REQUIREMENTS = ['https://github.com/bashwork/pymodbus/archive/' 'd7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0'] diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 71ba0fe0c9c..b5ea258c5cc 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1,56 +1,16 @@ """ homeassistant.components.mqtt ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -MQTT component, using paho-mqtt. This component needs a MQTT broker like -Mosquitto or Mosca. The Eclipse Foundation is running a public MQTT server -at iot.eclipse.org. If you prefer to use that one, keep in mind to adjust -the topic/client ID and that your messages are public. +MQTT component, using paho-mqtt. -Configuration: - -To use MQTT you will need to add something like the following to your -config/configuration.yaml. - -mqtt: - broker: 127.0.0.1 - -Or, if you want more options: - -mqtt: - broker: 127.0.0.1 - port: 1883 - client_id: home-assistant-1 - keepalive: 60 - username: your_username - password: your_secret_password - certificate: /home/paulus/dev/addtrustexternalcaroot.crt - -Variables: - -broker -*Required -This is the IP address of your MQTT broker, e.g. 192.168.1.32. - -port -*Optional -The network port to connect to. Default is 1883. - -client_id -*Optional -Client ID that Home Assistant will use. Has to be unique on the server. -Default is a random generated one. - -keepalive -*Optional -The keep alive in seconds for this client. Default is 60. - -certificate -*Optional -Certificate to use for encrypting the connection to the broker. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt/ """ import logging import os import socket +import time + from homeassistant.exceptions import HomeAssistantError import homeassistant.util as util @@ -67,11 +27,11 @@ MQTT_CLIENT = None DEFAULT_PORT = 1883 DEFAULT_KEEPALIVE = 60 DEFAULT_QOS = 0 +DEFAULT_RETAIN = False SERVICE_PUBLISH = 'publish' -EVENT_MQTT_MESSAGE_RECEIVED = 'MQTT_MESSAGE_RECEIVED' +EVENT_MQTT_MESSAGE_RECEIVED = 'mqtt_message_received' -DEPENDENCIES = [] REQUIREMENTS = ['paho-mqtt==1.1'] CONF_BROKER = 'broker' @@ -85,9 +45,12 @@ CONF_CERTIFICATE = 'certificate' ATTR_TOPIC = 'topic' ATTR_PAYLOAD = 'payload' ATTR_QOS = 'qos' +ATTR_RETAIN = 'retain' + +MAX_RECONNECT_WAIT = 300 # seconds -def publish(hass, topic, payload, qos=None): +def publish(hass, topic, payload, qos=None, retain=None): """ Send an MQTT message. """ data = { ATTR_TOPIC: topic, @@ -95,6 +58,10 @@ def publish(hass, topic, payload, qos=None): } if qos is not None: data[ATTR_QOS] = qos + + if retain is not None: + data[ATTR_RETAIN] = retain + hass.services.call(DOMAIN, SERVICE_PUBLISH, data) @@ -107,9 +74,7 @@ def subscribe(hass, topic, callback, qos=DEFAULT_QOS): event.data[ATTR_QOS]) hass.bus.listen(EVENT_MQTT_MESSAGE_RECEIVED, mqtt_topic_subscriber) - - if topic not in MQTT_CLIENT.topics: - MQTT_CLIENT.subscribe(topic, qos) + MQTT_CLIENT.subscribe(topic, qos) def setup(hass, config): @@ -158,9 +123,10 @@ def setup(hass, config): msg_topic = call.data.get(ATTR_TOPIC) payload = call.data.get(ATTR_PAYLOAD) qos = call.data.get(ATTR_QOS, DEFAULT_QOS) + retain = call.data.get(ATTR_RETAIN, DEFAULT_RETAIN) if msg_topic is None or payload is None: return - MQTT_CLIENT.publish(msg_topic, payload, qos) + MQTT_CLIENT.publish(msg_topic, payload, qos, retain) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_mqtt) @@ -169,44 +135,42 @@ def setup(hass, config): return True -# This is based on one of the paho-mqtt examples: -# http://git.eclipse.org/c/paho/org.eclipse.paho.mqtt.python.git/tree/examples/sub-class.py # pylint: disable=too-many-arguments -class MQTT(object): # pragma: no cover +class MQTT(object): """ Implements messaging service for MQTT. """ def __init__(self, hass, broker, port, client_id, keepalive, username, password, certificate): import paho.mqtt.client as mqtt - self.hass = hass - self._progress = {} - self.topics = {} + self.userdata = { + 'hass': hass, + 'topics': {}, + 'progress': {}, + } if client_id is None: self._mqttc = mqtt.Client() else: self._mqttc = mqtt.Client(client_id) + self._mqttc.user_data_set(self.userdata) + if username is not None: self._mqttc.username_pw_set(username, password) if certificate is not None: self._mqttc.tls_set(certificate) - self._mqttc.on_subscribe = self._mqtt_on_subscribe - self._mqttc.on_unsubscribe = self._mqtt_on_unsubscribe - self._mqttc.on_connect = self._mqtt_on_connect - self._mqttc.on_message = self._mqtt_on_message + self._mqttc.on_subscribe = _mqtt_on_subscribe + self._mqttc.on_unsubscribe = _mqtt_on_unsubscribe + self._mqttc.on_connect = _mqtt_on_connect + self._mqttc.on_disconnect = _mqtt_on_disconnect + self._mqttc.on_message = _mqtt_on_message + self._mqttc.connect(broker, port, keepalive) - def publish(self, topic, payload, qos): + def publish(self, topic, payload, qos, retain): """ Publish a MQTT message. """ - self._mqttc.publish(topic, payload, qos) - - def unsubscribe(self, topic): - """ Unsubscribe from topic. """ - result, mid = self._mqttc.unsubscribe(topic) - _raise_on_error(result) - self._progress[mid] = topic + self._mqttc.publish(topic, payload, qos, retain) def start(self): """ Run the MQTT client. """ @@ -218,58 +182,96 @@ class MQTT(object): # pragma: no cover def subscribe(self, topic, qos): """ Subscribe to a topic. """ - if topic in self.topics: + if topic in self.userdata['topics']: return result, mid = self._mqttc.subscribe(topic, qos) _raise_on_error(result) - self._progress[mid] = topic - self.topics[topic] = None + self.userdata['progress'][mid] = topic + self.userdata['topics'][topic] = None - def _mqtt_on_connect(self, mqttc, obj, flags, result_code): - """ On connect, resubscribe to all topics we were subscribed to. """ - if result_code != 0: - _LOGGER.error('Unable to connect to the MQTT broker: %s', { - 1: 'Incorrect protocol version', - 2: 'Invalid client identifier', - 3: 'Server unavailable', - 4: 'Bad username or password', - 5: 'Not authorised' - }.get(result_code)) - self._mqttc.disconnect() - return - - old_topics = self.topics - self._progress = {} - self.topics = {} - for topic, qos in old_topics.items(): - # qos is None if we were in process of subscribing - if qos is not None: - self._mqttc.subscribe(topic, qos) - - def _mqtt_on_subscribe(self, mqttc, obj, mid, granted_qos): - """ Called when subscribe succesfull. """ - topic = self._progress.pop(mid, None) - if topic is None: - return - self.topics[topic] = granted_qos - - def _mqtt_on_unsubscribe(self, mqttc, obj, mid, granted_qos): - """ Called when subscribe succesfull. """ - topic = self._progress.pop(mid, None) - if topic is None: - return - self.topics.pop(topic, None) - - def _mqtt_on_message(self, mqttc, obj, msg): - """ Message callback """ - self.hass.bus.fire(EVENT_MQTT_MESSAGE_RECEIVED, { - ATTR_TOPIC: msg.topic, - ATTR_QOS: msg.qos, - ATTR_PAYLOAD: msg.payload.decode('utf-8'), - }) + def unsubscribe(self, topic): + """ Unsubscribe from topic. """ + result, mid = self._mqttc.unsubscribe(topic) + _raise_on_error(result) + self.userdata['progress'][mid] = topic -def _raise_on_error(result): # pragma: no cover +def _mqtt_on_message(mqttc, userdata, msg): + """ Message callback """ + userdata['hass'].bus.fire(EVENT_MQTT_MESSAGE_RECEIVED, { + ATTR_TOPIC: msg.topic, + ATTR_QOS: msg.qos, + ATTR_PAYLOAD: msg.payload.decode('utf-8'), + }) + + +def _mqtt_on_connect(mqttc, userdata, flags, result_code): + """ On connect, resubscribe to all topics we were subscribed to. """ + if result_code != 0: + _LOGGER.error('Unable to connect to the MQTT broker: %s', { + 1: 'Incorrect protocol version', + 2: 'Invalid client identifier', + 3: 'Server unavailable', + 4: 'Bad username or password', + 5: 'Not authorised' + }.get(result_code, 'Unknown reason')) + mqttc.disconnect() + return + + old_topics = userdata['topics'] + + userdata['topics'] = {} + userdata['progress'] = {} + + for topic, qos in old_topics.items(): + # qos is None if we were in process of subscribing + if qos is not None: + mqttc.subscribe(topic, qos) + + +def _mqtt_on_subscribe(mqttc, userdata, mid, granted_qos): + """ Called when subscribe successful. """ + topic = userdata['progress'].pop(mid, None) + if topic is None: + return + userdata['topics'][topic] = granted_qos + + +def _mqtt_on_unsubscribe(mqttc, userdata, mid, granted_qos): + """ Called when subscribe successful. """ + topic = userdata['progress'].pop(mid, None) + if topic is None: + return + userdata['topics'].pop(topic, None) + + +def _mqtt_on_disconnect(mqttc, userdata, result_code): + """ Called when being disconnected. """ + # When disconnected because of calling disconnect() + if result_code == 0: + return + + tries = 0 + wait_time = 0 + + while True: + try: + if mqttc.reconnect() == 0: + _LOGGER.info('Successfully reconnected to the MQTT server') + break + except socket.error: + pass + + wait_time = min(2**tries, MAX_RECONNECT_WAIT) + _LOGGER.warning( + 'Disconnected from MQTT (%s). Trying to reconnect in %ss', + result_code, wait_time) + # It is ok to sleep here as we are in the MQTT thread. + time.sleep(wait_time) + tries += 1 + + +def _raise_on_error(result): """ Raise error if error result. """ if result != 0: raise HomeAssistantError('Error talking to MQTT: {}'.format(result)) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index e6cdf372dc8..245b9c6fde3 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -1,26 +1,31 @@ """ homeassistant.components.notify ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Provides functionality to notify people. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/notify/ """ from functools import partial import logging import os +import homeassistant.bootstrap as bootstrap from homeassistant.config import load_yaml_config_file -from homeassistant.loader import get_component from homeassistant.helpers import config_per_platform +from homeassistant.util import template from homeassistant.const import CONF_NAME DOMAIN = "notify" -DEPENDENCIES = [] # Title of notification ATTR_TITLE = "title" ATTR_TITLE_DEFAULT = "Home Assistant" +# Target of the notification (user, device, etc) +ATTR_TARGET = 'target' + # Text to notify user of ATTR_MESSAGE = "message" @@ -29,9 +34,16 @@ SERVICE_NOTIFY = "notify" _LOGGER = logging.getLogger(__name__) -def send_message(hass, message): +def send_message(hass, message, title=None): """ Send a notification message. """ - hass.services.call(DOMAIN, SERVICE_NOTIFY, {ATTR_MESSAGE: message}) + data = { + ATTR_MESSAGE: message + } + + if title is not None: + data[ATTR_TITLE] = title + + hass.services.call(DOMAIN, SERVICE_NOTIFY, data) def setup(hass, config): @@ -43,16 +55,15 @@ def setup(hass, config): for platform, p_config in config_per_platform(config, DOMAIN, _LOGGER): # get platform - notify_implementation = get_component( - 'notify.{}'.format(platform)) + notify_implementation = bootstrap.prepare_setup_platform( + hass, config, DOMAIN, platform) if notify_implementation is None: _LOGGER.error("Unknown notification service specified.") continue # create platform service - notify_service = notify_implementation.get_service( - hass, {DOMAIN: p_config}) + notify_service = notify_implementation.get_service(hass, p_config) if notify_service is None: _LOGGER.error("Failed to initialize notification service %s", @@ -67,9 +78,12 @@ def setup(hass, config): if message is None: return - title = call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + title = template.render( + hass, call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)) + target = call.data.get(ATTR_TARGET) + message = template.render(hass, message) - notify_service.send_message(message, title=title) + notify_service.send_message(message, title=title, target=target) # register service service_call_handler = partial(notify_message, notify_service) diff --git a/homeassistant/components/notify/file.py b/homeassistant/components/notify/file.py index 3fe6d555524..1469ac4558f 100644 --- a/homeassistant/components/notify/file.py +++ b/homeassistant/components/notify/file.py @@ -4,7 +4,7 @@ homeassistant.components.notify.file File notification service. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.file.html +https://home-assistant.io/components/notify.file/ """ import logging import os @@ -20,14 +20,14 @@ _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """ Get the file notification service. """ - if not validate_config(config, + if not validate_config({DOMAIN: config}, {DOMAIN: ['filename', 'timestamp']}, _LOGGER): return None - filename = config[DOMAIN]['filename'] - timestamp = config[DOMAIN]['timestamp'] + filename = config['filename'] + timestamp = config['timestamp'] return FileNotificationService(hass, filename, timestamp) diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py index 839eac24a0d..fbc9e3cbbbd 100644 --- a/homeassistant/components/notify/instapush.py +++ b/homeassistant/components/notify/instapush.py @@ -4,11 +4,13 @@ homeassistant.components.notify.instapush Instapush notification service. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.instapush.html +https://home-assistant.io/components/notify.instapush/ """ import logging import json +import requests + from homeassistant.helpers import validate_config from homeassistant.components.notify import ( DOMAIN, ATTR_TITLE, BaseNotificationService) @@ -21,7 +23,7 @@ _RESOURCE = 'https://api.instapush.im/v1/' def get_service(hass, config): """ Get the instapush notification service. """ - if not validate_config(config, + if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_API_KEY, 'app_secret', 'event', @@ -29,52 +31,29 @@ def get_service(hass, config): _LOGGER): return None + headers = {'x-instapush-appid': config[CONF_API_KEY], + 'x-instapush-appsecret': config['app_secret']} + try: - import requests - - except ImportError: - _LOGGER.exception( - "Unable to import requests. " - "Did you maybe not install the 'Requests' package?") - + response = requests.get(_RESOURCE + 'events/list', + headers=headers).json() + except ValueError: + _LOGGER.error('Unexpected answer from Instapush API.') return None - # pylint: disable=unused-variable - try: - response = requests.get(_RESOURCE) + if 'error' in response: + _LOGGER.error(response['msg']) + return None - except requests.ConnectionError: + if len([app for app in response if app['title'] == config['event']]) == 0: _LOGGER.error( - "Connection error " - "Please check if https://instapush.im is available.") - + "No app match your given value. " + "Please create an app at https://instapush.im") return None - instapush = requests.Session() - headers = {'x-instapush-appid': config[DOMAIN][CONF_API_KEY], - 'x-instapush-appsecret': config[DOMAIN]['app_secret']} - response = instapush.get(_RESOURCE + 'events/list', - headers=headers) - - try: - if response.json()['error']: - _LOGGER.error(response.json()['msg']) - # pylint: disable=bare-except - except: - try: - next(events for events in response.json() - if events['title'] == config[DOMAIN]['event']) - except StopIteration: - _LOGGER.error( - "No event match your given value. " - "Please create an event at https://instapush.im") - else: - return InstapushNotificationService( - config[DOMAIN].get(CONF_API_KEY), - config[DOMAIN]['app_secret'], - config[DOMAIN]['event'], - config[DOMAIN]['tracker'] - ) + return InstapushNotificationService( + config[CONF_API_KEY], config['app_secret'], config['event'], + config['tracker']) # pylint: disable=too-few-public-methods @@ -82,9 +61,6 @@ class InstapushNotificationService(BaseNotificationService): """ Implements notification service for Instapush. """ def __init__(self, api_key, app_secret, event, tracker): - # pylint: disable=no-name-in-module, unused-variable - from requests import Session - self._api_key = api_key self._app_secret = app_secret self._event = event @@ -94,8 +70,6 @@ class InstapushNotificationService(BaseNotificationService): 'x-instapush-appsecret': self._app_secret, 'Content-Type': 'application/json'} - self.instapush = Session() - def send_message(self, message="", **kwargs): """ Send a message to a user. """ @@ -104,10 +78,8 @@ class InstapushNotificationService(BaseNotificationService): data = {"event": self._event, "trackers": {self._tracker: title + " : " + message}} - response = self.instapush.post( - _RESOURCE + 'post', - data=json.dumps(data), - headers=self._headers) + response = requests.post(_RESOURCE + 'post', data=json.dumps(data), + headers=self._headers) if response.json()['status'] == 401: _LOGGER.error( diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py index a9fa6559e71..74ed796804c 100644 --- a/homeassistant/components/notify/nma.py +++ b/homeassistant/components/notify/nma.py @@ -4,11 +4,13 @@ homeassistant.components.notify.nma NMA (Notify My Android) notification service. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.nma.html +https://home-assistant.io/components/notify.nma/ """ import logging import xml.etree.ElementTree as ET +import requests + from homeassistant.helpers import validate_config from homeassistant.components.notify import ( DOMAIN, ATTR_TITLE, BaseNotificationService) @@ -21,31 +23,20 @@ _RESOURCE = 'https://www.notifymyandroid.com/publicapi/' def get_service(hass, config): """ Get the NMA notification service. """ - if not validate_config(config, + if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_API_KEY]}, _LOGGER): return None - try: - # pylint: disable=unused-variable - from requests import Session - - except ImportError: - _LOGGER.exception( - "Unable to import requests. " - "Did you maybe not install the 'Requests' package?") - - return None - - nma = Session() - response = nma.get(_RESOURCE + 'verify', - params={"apikey": config[DOMAIN][CONF_API_KEY]}) + response = requests.get(_RESOURCE + 'verify', + params={"apikey": config[CONF_API_KEY]}) tree = ET.fromstring(response.content) if tree[0].tag == 'error': _LOGGER.error("Wrong API key supplied. %s", tree[0].text) - else: - return NmaNotificationService(config[DOMAIN][CONF_API_KEY]) + return None + + return NmaNotificationService(config[CONF_API_KEY]) # pylint: disable=too-few-public-methods @@ -53,26 +44,20 @@ class NmaNotificationService(BaseNotificationService): """ Implements notification service for NMA. """ def __init__(self, api_key): - # pylint: disable=no-name-in-module, unused-variable - from requests import Session - self._api_key = api_key - self._data = {"apikey": self._api_key} - - self.nma = Session() def send_message(self, message="", **kwargs): """ Send a message to a user. """ - title = kwargs.get(ATTR_TITLE) + data = { + "apikey": self._api_key, + "application": 'home-assistant', + "event": kwargs.get(ATTR_TITLE), + "description": message, + "priority": 0, + } - self._data['application'] = 'home-assistant' - self._data['event'] = title - self._data['description'] = message - self._data['priority'] = 0 - - response = self.nma.get(_RESOURCE + 'notify', - params=self._data) + response = requests.get(_RESOURCE + 'notify', params=data) tree = ET.fromstring(response.content) if tree[0].tag == 'error': diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 49aaccd3004..941a78ac709 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -4,59 +4,125 @@ homeassistant.components.notify.pushbullet PushBullet platform for notify component. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.pushbullet.html +https://home-assistant.io/components/notify.pushbullet/ """ import logging -from homeassistant.helpers import validate_config from homeassistant.components.notify import ( - DOMAIN, ATTR_TITLE, BaseNotificationService) + ATTR_TITLE, ATTR_TARGET, BaseNotificationService) from homeassistant.const import CONF_API_KEY _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pushbullet.py==0.7.1'] +REQUIREMENTS = ['pushbullet.py==0.9.0'] +# pylint: disable=unused-argument def get_service(hass, config): """ Get the PushBullet notification service. """ + from pushbullet import PushBullet + from pushbullet import InvalidKeyError - if not validate_config(config, - {DOMAIN: [CONF_API_KEY]}, - _LOGGER): + if CONF_API_KEY not in config: + _LOGGER.error("Unable to find config key '%s'", CONF_API_KEY) return None try: - # pylint: disable=unused-variable - from pushbullet import PushBullet, InvalidKeyError # noqa - - except ImportError: - _LOGGER.exception( - "Unable to import pushbullet. " - "Did you maybe not install the 'pushbullet.py' package?") - - return None - - try: - return PushBulletNotificationService(config[DOMAIN][CONF_API_KEY]) - + pushbullet = PushBullet(config[CONF_API_KEY]) except InvalidKeyError: _LOGGER.error( "Wrong API key supplied. " "Get it at https://www.pushbullet.com/account") + return None + + return PushBulletNotificationService(pushbullet) # pylint: disable=too-few-public-methods class PushBulletNotificationService(BaseNotificationService): """ Implements notification service for Pushbullet. """ - def __init__(self, api_key): - from pushbullet import PushBullet + def __init__(self, pb): + self.pushbullet = pb + self.pbtargets = {} + self.refresh() - self.pushbullet = PushBullet(api_key) + def refresh(self): + """ + Refresh devices, contacts, etc - def send_message(self, message="", **kwargs): - """ Send a message to a user. """ + pbtargets stores all targets available from this pushbullet instance + into a dict. These are PB objects!. It sacrifices a bit of memory + for faster processing at send_message. + As of sept 2015, contacts were replaced by chats. This is not + implemented in the module yet. + """ + self.pushbullet.refresh() + self.pbtargets = { + 'device': { + tgt.nickname.lower(): tgt for tgt in self.pushbullet.devices}, + 'channel': { + tgt.channel_tag.lower(): tgt for + tgt in self.pushbullet.channels}, + } + + def send_message(self, message=None, **kwargs): + """ + Send a message to a specified target. + If no target specified, a 'normal' push will be sent to all devices + linked to the PB account. + Email is special, these are assumed to always exist. We use a special + call which doesn't require a push object. + """ + targets = kwargs.get(ATTR_TARGET) title = kwargs.get(ATTR_TITLE) + refreshed = False - self.pushbullet.push_note(title, message) + if not targets: + # Backward compatebility, notify all devices in own account + self.pushbullet.push_note(title, message) + _LOGGER.info('Sent notification to self') + return + + # Make list if not so + if not isinstance(targets, list): + targets = [targets] + + # Main loop, Process all targets specified + for target in targets: + try: + ttype, tname = target.split('/', 1) + except ValueError: + _LOGGER.error('Invalid target syntax: %s', target) + continue + + # Target is email, send directly, don't use a target object + # This also seems works to send to all devices in own account + if ttype == 'email': + self.pushbullet.push_note(title, message, email=tname) + _LOGGER.info('Sent notification to email %s', tname) + continue + + # Refresh if name not found. While awaiting periodic refresh + # solution in component, poor mans refresh ;) + if ttype not in self.pbtargets: + _LOGGER.error('Invalid target syntax: %s', target) + continue + + tname = tname.lower() + + if tname not in self.pbtargets[ttype] and not refreshed: + self.refresh() + refreshed = True + + # Attempt push_note on a dict value. Keys are types & target + # name. Dict pbtargets has all *actual* targets. + try: + self.pbtargets[ttype][tname].push_note(title, message) + _LOGGER.info('Sent notification to %s/%s', ttype, tname) + except KeyError: + _LOGGER.error('No such target: %s/%s', ttype, tname) + continue + except self.pushbullet.errors.PushError: + _LOGGER.error('Notify failed to: %s/%s', ttype, tname) + continue diff --git a/homeassistant/components/notify/pushetta.py b/homeassistant/components/notify/pushetta.py new file mode 100644 index 00000000000..22bbd5175c6 --- /dev/null +++ b/homeassistant/components/notify/pushetta.py @@ -0,0 +1,63 @@ +""" +homeassistant.components.notify.pushetta +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Pushetta platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.pushetta/ +""" +import logging + +from homeassistant.helpers import validate_config +from homeassistant.components.notify import ( + DOMAIN, ATTR_TITLE, BaseNotificationService) +from homeassistant.const import CONF_API_KEY + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pushetta==1.0.15'] + + +def get_service(hass, config): + """ Get the Pushetta notification service. """ + + from pushetta import Pushetta, exceptions + + if not validate_config({DOMAIN: config}, + {DOMAIN: [CONF_API_KEY, 'channel_name']}, + _LOGGER): + return None + + try: + pushetta = Pushetta(config[CONF_API_KEY]) + pushetta.pushMessage(config['channel_name'], "Home Assistant started") + except exceptions.TokenValidationError: + _LOGGER.error("Please check your access token") + return None + except exceptions.ChannelNotFoundError: + _LOGGER.error("Channel '%s' not found", config['channel_name']) + return None + + return PushettaNotificationService(config[CONF_API_KEY], + config['channel_name']) + + +# pylint: disable=too-few-public-methods +class PushettaNotificationService(BaseNotificationService): + """ Implements notification service for Pushetta. """ + + def __init__(self, api_key, channel_name): + + from pushetta import Pushetta + + self._api_key = api_key + self._channel_name = channel_name + self.pushetta = Pushetta(self._api_key) + + def send_message(self, message="", **kwargs): + """ Send a message to a user. """ + + title = kwargs.get(ATTR_TITLE) + + self.pushetta.pushMessage(self._channel_name, + "{} {}".format(title, message)) diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index e2b4b4c4b40..7c776300cdb 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -4,7 +4,7 @@ homeassistant.components.notify.pushover Pushover platform for notify component. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.pushover.html +https://home-assistant.io/components/notify.pushover/ """ import logging @@ -21,32 +21,21 @@ _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """ Get the pushover notification service. """ - if not validate_config(config, + if not validate_config({DOMAIN: config}, {DOMAIN: ['user_key', CONF_API_KEY]}, _LOGGER): return None - try: - # pylint: disable=no-name-in-module, unused-variable - from pushover import InitError - - except ImportError: - _LOGGER.exception( - "Unable to import pushover. " - "Did you maybe not install the 'python-pushover.py' package?") - - return None + from pushover import InitError try: - api_token = config[DOMAIN].get(CONF_API_KEY) - return PushoverNotificationService( - config[DOMAIN]['user_key'], - api_token) - + return PushoverNotificationService(config['user_key'], + config[CONF_API_KEY]) except InitError: _LOGGER.error( "Wrong API key supplied. " "Get it at https://pushover.net") + return None # pylint: disable=too-few-public-methods @@ -54,7 +43,6 @@ class PushoverNotificationService(BaseNotificationService): """ Implements notification service for Pushover. """ def __init__(self, user_key, api_token): - # pylint: disable=no-name-in-module, unused-variable from pushover import Client self._user_key = user_key self._api_token = api_token @@ -63,11 +51,9 @@ class PushoverNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """ Send a message to a user. """ - - # pylint: disable=no-name-in-module from pushover import RequestError - title = kwargs.get(ATTR_TITLE) + try: - self.pushover.send_message(message, title=title) + self.pushover.send_message(message, title=kwargs.get(ATTR_TITLE)) except RequestError: _LOGGER.exception("Could not send pushover notification") diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index e69de29bb2d..3b2734e2674 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -0,0 +1,15 @@ +notify: + description: Send a notification + + fields: + message: + description: Message body of the notification. + example: The garage door has been open for 10 minutes. + + title: + description: Optional title for your notification. + example: 'Your Garage Door Friend' + + target: + description: Target of the notification. Optional depending on the platform + example: platform specific diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 0b168ed5075..7a386d8864f 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -4,13 +4,12 @@ homeassistant.components.notify.slack Slack platform for notify component. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.slack.html +https://home-assistant.io/components/notify.slack/ """ import logging from homeassistant.helpers import validate_config -from homeassistant.components.notify import ( - DOMAIN, BaseNotificationService) +from homeassistant.components.notify import DOMAIN, BaseNotificationService from homeassistant.const import CONF_API_KEY REQUIREMENTS = ['slacker==0.6.8'] @@ -20,34 +19,22 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-variable def get_service(hass, config): """ Get the slack notification service. """ + import slacker - if not validate_config(config, + if not validate_config({DOMAIN: config}, {DOMAIN: ['default_channel', CONF_API_KEY]}, _LOGGER): return None try: - # pylint: disable=no-name-in-module, unused-variable - from slacker import Error as SlackError - - except ImportError: - _LOGGER.exception( - "Unable to import slacker. " - "Did you maybe not install the 'slacker.py' package?") - - return None - - try: - api_token = config[DOMAIN].get(CONF_API_KEY) - return SlackNotificationService( - config[DOMAIN]['default_channel'], - api_token) + config['default_channel'], + config[CONF_API_KEY]) - except SlackError as ex: - _LOGGER.error( + except slacker.Error: + _LOGGER.exception( "Slack authentication failed") - _LOGGER.exception(ex) + return None # pylint: disable=too-few-public-methods @@ -56,6 +43,7 @@ class SlackNotificationService(BaseNotificationService): def __init__(self, default_channel, api_token): from slacker import Slacker + self._default_channel = default_channel self._api_token = api_token self.slack = Slacker(self._api_token) @@ -63,11 +51,11 @@ class SlackNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """ Send a message to a user. """ + import slacker - from slacker import Error as SlackError channel = kwargs.get('channel', self._default_channel) + try: self.slack.chat.post_message(channel, message) - except SlackError as ex: + except slacker.Error: _LOGGER.exception("Could not send slack notification") - _LOGGER.exception(ex) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 758e7839e50..5e848634bb9 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -4,7 +4,7 @@ homeassistant.components.notify.smtp Mail (SMTP) notification service. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.smtp.html +https://home-assistant.io/components/notify.smtp/ """ import logging import smtplib @@ -20,35 +20,31 @@ _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """ Get the mail notification service. """ - if not validate_config(config, - {DOMAIN: ['server', - 'port', - 'sender', - 'username', - 'password', - 'recipient']}, + if not validate_config({DOMAIN: config}, + {DOMAIN: ['server', 'port', 'sender', 'username', + 'password', 'recipient']}, _LOGGER): return None - smtp_server = config[DOMAIN]['server'] - port = int(config[DOMAIN]['port']) - username = config[DOMAIN]['username'] - password = config[DOMAIN]['password'] + smtp_server = config['server'] + port = int(config['port']) + username = config['username'] + password = config['password'] + starttls = int(config['starttls']) server = None try: server = smtplib.SMTP(smtp_server, port) server.ehlo() - if int(config[DOMAIN]['starttls']) == 1: + if starttls == 1: server.starttls() server.ehlo() try: server.login(username, password) - except (smtplib.SMTPException, smtplib.SMTPSenderRefused) as error: - _LOGGER.exception(error, - "Please check your settings.") + except (smtplib.SMTPException, smtplib.SMTPSenderRefused): + _LOGGER.exception("Please check your settings.") return None @@ -66,18 +62,13 @@ def get_service(hass, config): return None - if server: - server.quit() + finally: + if server: + server.quit() return MailNotificationService( - config[DOMAIN]['server'], - config[DOMAIN]['port'], - config[DOMAIN]['sender'], - config[DOMAIN]['starttls'], - config[DOMAIN]['username'], - config[DOMAIN]['password'], - config[DOMAIN]['recipient'] - ) + smtp_server, port, config['sender'], starttls, username, password, + config['recipient']) # pylint: disable=too-few-public-methods, too-many-instance-attributes @@ -90,7 +81,7 @@ class MailNotificationService(BaseNotificationService): self._server = server self._port = port self._sender = sender - self.starttls = int(starttls) + self.starttls = starttls self.username = username self.password = password self.recipient = recipient diff --git a/homeassistant/components/notify/syslog.py b/homeassistant/components/notify/syslog.py index 7881e68476d..4ee9ead9152 100644 --- a/homeassistant/components/notify/syslog.py +++ b/homeassistant/components/notify/syslog.py @@ -4,7 +4,7 @@ homeassistant.components.notify.syslog Syslog notification service. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.syslog.html +https://home-assistant.io/components/notify.syslog/ """ import logging import syslog @@ -52,16 +52,14 @@ PRIORITIES = {5: syslog.LOG_EMERG, def get_service(hass, config): """ Get the mail notification service. """ - if not validate_config(config, - {DOMAIN: ['facility', - 'option', - 'priority']}, + if not validate_config({DOMAIN: config}, + {DOMAIN: ['facility', 'option', 'priority']}, _LOGGER): return None - _facility = FACILITIES.get(config[DOMAIN]['facility'], 40) - _option = OPTIONS.get(config[DOMAIN]['option'], 10) - _priority = PRIORITIES.get(config[DOMAIN]['priority'], -1) + _facility = FACILITIES.get(config['facility'], 40) + _option = OPTIONS.get(config['option'], 10) + _priority = PRIORITIES.get(config['priority'], -1) return SyslogNotificationService(_facility, _option, _priority) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 23b915baf1e..1d78c38f3c6 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -4,7 +4,7 @@ homeassistant.components.notify.telegram Telegram platform for notify component. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.telegram.html +https://home-assistant.io/components/notify.telegram/ """ import logging import urllib @@ -15,36 +15,28 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_API_KEY _LOGGER = logging.getLogger(__name__) + REQUIREMENTS = ['python-telegram-bot==2.8.7'] def get_service(hass, config): """ Get the Telegram notification service. """ + import telegram - if not validate_config(config, + if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_API_KEY, 'chat_id']}, _LOGGER): return None try: - import telegram - except ImportError: - _LOGGER.exception( - "Unable to import python-telegram-bot. " - "Did you maybe not install the 'python-telegram-bot' package?") - return None - - try: - bot = telegram.Bot(token=config[DOMAIN][CONF_API_KEY]) + bot = telegram.Bot(token=config[CONF_API_KEY]) username = bot.getMe()['username'] - _LOGGER.info("Telegram bot is' %s'", username) + _LOGGER.info("Telegram bot is '%s'.", username) except urllib.error.HTTPError: _LOGGER.error("Please check your access token.") return None - return TelegramNotificationService( - config[DOMAIN][CONF_API_KEY], - config[DOMAIN]['chat_id']) + return TelegramNotificationService(config[CONF_API_KEY], config['chat_id']) # pylint: disable=too-few-public-methods @@ -53,14 +45,19 @@ class TelegramNotificationService(BaseNotificationService): def __init__(self, api_key, chat_id): import telegram + self._api_key = api_key self._chat_id = chat_id self.bot = telegram.Bot(token=self._api_key) def send_message(self, message="", **kwargs): """ Send a message to a user. """ + import telegram title = kwargs.get(ATTR_TITLE) - self.bot.sendMessage(chat_id=self._chat_id, - text=title + " " + message) + try: + self.bot.sendMessage(chat_id=self._chat_id, + text=title + " " + message) + except telegram.error.TelegramError: + _LOGGER.exception("Error sending message.") diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index d4f5a7336a6..4b688fb7a79 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -4,52 +4,30 @@ homeassistant.components.notify.xmpp Jabber (XMPP) notification service. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.xmpp.html +https://home-assistant.io/components/notify.xmpp/ """ import logging -_LOGGER = logging.getLogger(__name__) - -try: - import sleekxmpp - -except ImportError: - _LOGGER.exception( - "Unable to import sleekxmpp. " - "Did you maybe not install the 'SleekXMPP' package?") - from homeassistant.helpers import validate_config from homeassistant.components.notify import ( DOMAIN, ATTR_TITLE, BaseNotificationService) REQUIREMENTS = ['sleekxmpp==1.3.1', 'dnspython3==1.12.0'] +_LOGGER = logging.getLogger(__name__) + def get_service(hass, config): """ Get the Jabber (XMPP) notification service. """ - if not validate_config(config, - {DOMAIN: ['sender', - 'password', - 'recipient']}, + if not validate_config({DOMAIN: config}, + {DOMAIN: ['sender', 'password', 'recipient']}, _LOGGER): return None - try: - SendNotificationBot(config[DOMAIN]['sender'] + '/home-assistant', - config[DOMAIN]['password'], - config[DOMAIN]['recipient'], - '') - except ImportError: - _LOGGER.exception( - "Unable to contact jabber server." - "Please check your credentials.") - - return None - - return XmppNotificationService(config[DOMAIN]['sender'], - config[DOMAIN]['password'], - config[DOMAIN]['recipient']) + return XmppNotificationService(config['sender'], + config['password'], + config['recipient']) # pylint: disable=too-few-public-methods @@ -65,40 +43,40 @@ class XmppNotificationService(BaseNotificationService): """ Send a message to a user. """ title = kwargs.get(ATTR_TITLE) - data = title + ": " + message + data = "{}: {}".format(title, message) if title else message - SendNotificationBot(self._sender + '/home-assistant', - self._password, - self._recipient, - data) + send_message(self._sender + '/home-assistant', self._password, + self._recipient, data) -class SendNotificationBot(sleekxmpp.ClientXMPP): - """ Service for sending Jabber (XMPP) messages. """ +def send_message(sender, password, recipient, message): + """ Send a message over XMPP. """ + import sleekxmpp - def __init__(self, jid, password, recipient, msg): + class SendNotificationBot(sleekxmpp.ClientXMPP): + """ Service for sending Jabber (XMPP) messages. """ - super(SendNotificationBot, self).__init__(jid, password) + def __init__(self): + super(SendNotificationBot, self).__init__(sender, password) - logging.basicConfig(level=logging.ERROR) + logging.basicConfig(level=logging.ERROR) - self.recipient = recipient - self.msg = msg + self.use_tls = True + self.use_ipv6 = False + self.add_event_handler('failed_auth', self.check_credentials) + self.add_event_handler('session_start', self.start) + self.connect() + self.process() - self.use_tls = True - self.use_ipv6 = False - self.add_event_handler('failed_auth', self.check_credentials) - self.add_event_handler('session_start', self.start) - self.connect() - self.process(block=False) + def start(self, event): + """ Starts the communication and sends the message. """ + self.send_presence() + self.get_roster() + self.send_message(mto=recipient, mbody=message, mtype='chat') + self.disconnect(wait=True) - def start(self, event): - """ Starts the communication and sends the message. """ - self.send_presence() - self.get_roster() - self.send_message(mto=self.recipient, mbody=self.msg, mtype='chat') - self.disconnect(wait=True) + def check_credentials(self, event): + """" Disconnect from the server if credentials are invalid. """ + self.disconnect() - def check_credentials(self, event): - """" Disconnect from the server if credentials are invalid. """ - self.disconnect() + SendNotificationBot() diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index 10f6576d23f..802634715e9 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -1,9 +1,11 @@ """ homeassistant.components.recorder ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Component that records all events and state changes. Allows other components +to query this database. -Component that records all events and state changes. -Allows other components to query this database. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/recorder/ """ import logging import threading @@ -14,14 +16,13 @@ import json import atexit from homeassistant.core import Event, EventOrigin, State -import homeassistant.util.dt as date_util +import homeassistant.util.dt as dt_util from homeassistant.remote import JSONEncoder from homeassistant.const import ( MATCH_ALL, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) DOMAIN = "recorder" -DEPENDENCIES = [] DB_FILE = 'home-assistant.db' @@ -57,12 +58,12 @@ def query_events(event_query, arguments=None): def row_to_state(row): - """ Convert a databsae row to a state. """ + """ Convert a database row to a state. """ try: return State( row[1], row[2], json.loads(row[3]), - date_util.utc_from_timestamp(row[4]), - date_util.utc_from_timestamp(row[5])) + dt_util.utc_from_timestamp(row[4]), + dt_util.utc_from_timestamp(row[5])) except ValueError: # When json.loads fails _LOGGER.exception("Error converting row to state: %s", row) @@ -72,8 +73,8 @@ def row_to_state(row): def row_to_event(row): """ Convert a databse row to an event. """ try: - return Event(row[1], json.loads(row[2]), EventOrigin[row[3].lower()], - date_util.utc_from_timestamp(row[5])) + return Event(row[1], json.loads(row[2]), EventOrigin(row[3]), + dt_util.utc_from_timestamp(row[5])) except ValueError: # When json.loads fails _LOGGER.exception("Error converting row to event: %s", row) @@ -81,8 +82,9 @@ def row_to_event(row): def run_information(point_in_time=None): - """ Returns information about current run or the run that - covers point_in_time. """ + """ + Returns information about current run or the run that covers point_in_time. + """ _verify_instance() if point_in_time is None or point_in_time > _INSTANCE.recording_start: @@ -114,10 +116,10 @@ class RecorderRun(object): self.start = _INSTANCE.recording_start self.closed_incorrect = False else: - self.start = date_util.utc_from_timestamp(row[1]) + self.start = dt_util.utc_from_timestamp(row[1]) if row[2] is not None: - self.end = date_util.utc_from_timestamp(row[2]) + self.end = dt_util.utc_from_timestamp(row[2]) self.closed_incorrect = bool(row[3]) @@ -140,8 +142,10 @@ class RecorderRun(object): @property def where_after_start_run(self): - """ Returns SQL WHERE clause to select rows - created after the start of the run. """ + """ + Returns SQL WHERE clause to select rows created after the start of the + run. + """ return "created >= {} ".format(_adapt_datetime(self.start)) @property @@ -156,9 +160,7 @@ class RecorderRun(object): class Recorder(threading.Thread): - """ - Threaded recorder - """ + """ Threaded recorder class """ def __init__(self, hass): threading.Thread.__init__(self) @@ -167,8 +169,8 @@ class Recorder(threading.Thread): self.queue = queue.Queue() self.quit_object = object() self.lock = threading.Lock() - self.recording_start = date_util.utcnow() - self.utc_offset = date_util.now().utcoffset().total_seconds() + self.recording_start = dt_util.utcnow() + self.utc_offset = dt_util.now().utcoffset().total_seconds() def start_recording(event): """ Start recording. """ @@ -206,17 +208,20 @@ class Recorder(threading.Thread): self.queue.task_done() def event_listener(self, event): - """ Listens for new events on the EventBus and puts them - in the process queue. """ + """ + Listens for new events on the EventBus and puts them in the process + queue. + """ self.queue.put(event) def shutdown(self, event): """ Tells the recorder to shut down. """ self.queue.put(self.quit_object) + self.block_till_done() def record_state(self, entity_id, state, event_id): """ Save a state to the database. """ - now = date_util.utcnow() + now = dt_util.utcnow() # State got deleted if state is None: @@ -243,7 +248,7 @@ class Recorder(threading.Thread): """ Save an event to the database. """ info = ( event.event_type, json.dumps(event.data, cls=JSONEncoder), - str(event.origin), date_util.utcnow(), event.time_fired, + str(event.origin), dt_util.utcnow(), event.time_fired, self.utc_offset ) @@ -303,7 +308,7 @@ class Recorder(threading.Thread): def save_migration(migration_id): """ Save and commit a migration to the database. """ cur.execute('INSERT INTO schema_version VALUES (?, ?)', - (migration_id, date_util.utcnow())) + (migration_id, dt_util.utcnow())) self.conn.commit() _LOGGER.info("Database migrated to version %d", migration_id) @@ -416,21 +421,21 @@ class Recorder(threading.Thread): self.query( """INSERT INTO recorder_runs (start, created, utc_offset) VALUES (?, ?, ?)""", - (self.recording_start, date_util.utcnow(), self.utc_offset)) + (self.recording_start, dt_util.utcnow(), self.utc_offset)) def _close_run(self): """ Save end time for current run. """ self.query( "UPDATE recorder_runs SET end=? WHERE start=?", - (date_util.utcnow(), self.recording_start)) + (dt_util.utcnow(), self.recording_start)) def _adapt_datetime(datetimestamp): """ Turn a datetime into an integer for in the DB. """ - return date_util.as_utc(datetimestamp.replace(microsecond=0)).timestamp() + return dt_util.as_utc(datetimestamp.replace(microsecond=0)).timestamp() def _verify_instance(): - """ throws error if recorder not initialized. """ + """ Throws error if recorder not initialized. """ if _INSTANCE is None: raise RuntimeError("Recorder not initialized.") diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 0788986c91d..aea3afe7f05 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -3,19 +3,26 @@ homeassistant.components.rfxtrx ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides support for RFXtrx components. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/rfxtrx.html +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rfxtrx/ """ import logging from homeassistant.util import slugify -DEPENDENCIES = [] REQUIREMENTS = ['https://github.com/Danielhiversen/pyRFXtrx/archive/0.2.zip' + '#RFXtrx==0.2'] DOMAIN = "rfxtrx" -CONF_DEVICE = 'device' -CONF_DEBUG = 'debug' + +ATTR_DEVICE = 'device' +ATTR_DEBUG = 'debug' +ATTR_STATE = 'state' +ATTR_NAME = 'name' +ATTR_PACKETID = 'packetid' +ATTR_FIREEVENT = 'fire_event' + +EVENT_BUTTON_PRESSED = 'button_pressed' + RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) @@ -50,15 +57,15 @@ def setup(hass, config): # Init the rfxtrx module global RFXOBJECT - if CONF_DEVICE not in config[DOMAIN]: + if ATTR_DEVICE not in config[DOMAIN]: _LOGGER.exception( "can found device parameter in %s YAML configuration section", DOMAIN ) return False - device = config[DOMAIN][CONF_DEVICE] - debug = config[DOMAIN].get(CONF_DEBUG, False) + device = config[DOMAIN][ATTR_DEVICE] + debug = config[DOMAIN].get(ATTR_DEBUG, False) RFXOBJECT = rfxtrxmod.Core(device, handle_receive, debug=debug) diff --git a/homeassistant/components/rollershutter/__init__.py b/homeassistant/components/rollershutter/__init__.py new file mode 100644 index 00000000000..517ebf97b25 --- /dev/null +++ b/homeassistant/components/rollershutter/__init__.py @@ -0,0 +1,144 @@ +""" +homeassistant.components.rollershutter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Rollershutter component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rollershutter/ +""" +import os +import logging + +from homeassistant.config import load_yaml_config_file +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.components import group +from homeassistant.const import ( + SERVICE_MOVE_UP, SERVICE_MOVE_DOWN, SERVICE_STOP, + STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN, ATTR_ENTITY_ID) + + +DOMAIN = 'rollershutter' +SCAN_INTERVAL = 15 + +GROUP_NAME_ALL_ROLLERSHUTTERS = 'all rollershutters' +ENTITY_ID_ALL_ROLLERSHUTTERS = group.ENTITY_ID_FORMAT.format( + 'all_rollershutters') + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +# Maps discovered services to their platforms +DISCOVERY_PLATFORMS = {} + +_LOGGER = logging.getLogger(__name__) + +ATTR_CURRENT_POSITION = 'current_position' + + +def is_open(hass, entity_id=None): + """ Returns if the rollershutter is open based on the statemachine. """ + entity_id = entity_id or ENTITY_ID_ALL_ROLLERSHUTTERS + return hass.states.is_state(entity_id, STATE_OPEN) + + +def move_up(hass, entity_id=None): + """ Move up all or specified rollershutter. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_MOVE_UP, data) + + +def move_down(hass, entity_id=None): + """ Move down all or specified rollershutter. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_MOVE_DOWN, data) + + +def stop(hass, entity_id=None): + """ Stops all or specified rollershutter. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_STOP, data) + + +def setup(hass, config): + """ Track states and offer events for rollershutters. """ + component = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS, + GROUP_NAME_ALL_ROLLERSHUTTERS) + component.setup(config) + + def handle_rollershutter_service(service): + """ Handles calls to the rollershutter services. """ + target_rollershutters = component.extract_from_service(service) + + for rollershutter in target_rollershutters: + if service.service == SERVICE_MOVE_UP: + rollershutter.move_up() + elif service.service == SERVICE_MOVE_DOWN: + rollershutter.move_down() + elif service.service == SERVICE_STOP: + rollershutter.stop() + + if rollershutter.should_poll: + rollershutter.update_ha_state(True) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + hass.services.register(DOMAIN, SERVICE_MOVE_UP, + handle_rollershutter_service, + descriptions.get(SERVICE_MOVE_UP)) + hass.services.register(DOMAIN, SERVICE_MOVE_DOWN, + handle_rollershutter_service, + descriptions.get(SERVICE_MOVE_DOWN)) + hass.services.register(DOMAIN, SERVICE_STOP, + handle_rollershutter_service, + descriptions.get(SERVICE_STOP)) + + return True + + +class RollershutterDevice(Entity): + """ Represents a rollershutter within Home Assistant. """ + # pylint: disable=no-self-use + + @property + def current_position(self): + """ + Return current position of rollershutter. + None is unknown, 0 is closed, 100 is fully open. + """ + raise NotImplementedError() + + @property + def state(self): + """ Returns the state of the rollershutter. """ + current = self.current_position + + if current is None: + return STATE_UNKNOWN + + return STATE_CLOSED if current == 0 else STATE_OPEN + + @property + def state_attributes(self): + """ Return the state attributes. """ + current = self.current_position + + if current is None: + return None + + return { + ATTR_CURRENT_POSITION: current + } + + def move_up(self, **kwargs): + """ Move the rollershutter down. """ + raise NotImplementedError() + + def move_down(self, **kwargs): + """ Move the rollershutter up. """ + raise NotImplementedError() + + def stop(self, **kwargs): + """ Stop the rollershutter. """ + raise NotImplementedError() diff --git a/homeassistant/components/rollershutter/demo.py b/homeassistant/components/rollershutter/demo.py new file mode 100644 index 00000000000..4ffeb6be6dd --- /dev/null +++ b/homeassistant/components/rollershutter/demo.py @@ -0,0 +1,83 @@ +""" +homeassistant.components.rollershutter.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Demo platform for the rollorshutter component. +""" +from homeassistant.const import EVENT_TIME_CHANGED +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.components.rollershutter import RollershutterDevice + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Demo rollershutters. """ + add_devices([ + DemoRollershutter(hass, 'Kitchen Window', 0), + DemoRollershutter(hass, 'Living Room Window', 100), + ]) + + +class DemoRollershutter(RollershutterDevice): + """ Represents a rollershutter.. """ + # pylint: disable=no-self-use + + def __init__(self, hass, name, position): + self.hass = hass + self._name = name + self._position = position + self._moving_up = True + self._listener = None + + @property + def name(self): + """ Returns the name of the rollershutter. """ + return self._name + + @property + def should_poll(self): + """ No polling needed for a demo rollershutter. """ + return False + + @property + def current_position(self): + """ Returns the current position of the rollershutter. """ + return self._position + + def move_up(self, **kwargs): + """ Move the rollershutter down. """ + if self._position == 0: + return + + self._listen() + self._moving_up = True + + def move_down(self, **kwargs): + """ Move the rollershutter up. """ + if self._position == 100: + return + + self._listen() + self._moving_up = False + + def stop(self, **kwargs): + """ Stop the rollershutter. """ + if self._listener is not None: + self.hass.bus.remove_listener(EVENT_TIME_CHANGED, self._listener) + self._listener = None + + def _listen(self): + """ Listens for changes. """ + if self._listener is None: + self._listener = track_utc_time_change(self.hass, + self._time_changed) + + def _time_changed(self, now): + """ Track time changes. """ + if self._moving_up: + self._position -= 10 + else: + self._position += 10 + + if self._position % 100 == 0: + self.stop() + + self.update_ha_state() diff --git a/homeassistant/components/rollershutter/mqtt.py b/homeassistant/components/rollershutter/mqtt.py new file mode 100644 index 00000000000..2951d772be0 --- /dev/null +++ b/homeassistant/components/rollershutter/mqtt.py @@ -0,0 +1,108 @@ +""" +homeassistant.components.rollershutter.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to configure a MQTT rollershutter. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rollershutter.mqtt/ +""" +import logging +import homeassistant.components.mqtt as mqtt +from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.components.rollershutter import RollershutterDevice +from homeassistant.util import template + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +DEFAULT_NAME = "MQTT Rollershutter" +DEFAULT_QOS = 0 +DEFAULT_PAYLOAD_UP = "UP" +DEFAULT_PAYLOAD_DOWN = "DOWN" +DEFAULT_PAYLOAD_STOP = "STOP" + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Add MQTT Rollershutter """ + + if config.get('command_topic') is None: + _LOGGER.error("Missing required variable: command_topic") + return False + + add_devices_callback([MqttRollershutter( + hass, + config.get('name', DEFAULT_NAME), + config.get('state_topic'), + config.get('command_topic'), + config.get('qos', DEFAULT_QOS), + config.get('payload_up', DEFAULT_PAYLOAD_UP), + config.get('payload_down', DEFAULT_PAYLOAD_DOWN), + config.get('payload_stop', DEFAULT_PAYLOAD_STOP), + config.get(CONF_VALUE_TEMPLATE))]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MqttRollershutter(RollershutterDevice): + """ Represents a rollershutter that can be controlled using MQTT. """ + def __init__(self, hass, name, state_topic, command_topic, qos, + payload_up, payload_down, payload_stop, value_template): + self._state = None + self._hass = hass + self._name = name + self._state_topic = state_topic + self._command_topic = command_topic + self._qos = qos + self._payload_up = payload_up + self._payload_down = payload_down + self._payload_stop = payload_stop + + if self._state_topic is None: + return + + def message_received(topic, payload, qos): + """ A new MQTT message has been received. """ + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) + if payload.isnumeric() and 0 <= int(payload) <= 100: + self._state = int(payload) + self.update_ha_state() + else: + _LOGGER.warning( + "Payload is expected to be an integer between 0 and 100") + + mqtt.subscribe(hass, self._state_topic, message_received, self._qos) + + @property + def should_poll(self): + """ No polling needed """ + return False + + @property + def name(self): + """ The name of the rollershutter. """ + return self._name + + @property + def current_position(self): + """ + Return current position of rollershutter. + None is unknown, 0 is closed, 100 is fully open. + """ + return self._state + + def move_up(self, **kwargs): + """ Move the rollershutter up. """ + mqtt.publish(self.hass, self._command_topic, self._payload_up, + self._qos) + + def move_down(self, **kwargs): + """ Move the rollershutter down. """ + mqtt.publish(self.hass, self._command_topic, self._payload_down, + self._qos) + + def stop(self, **kwargs): + """ Stop the device. """ + mqtt.publish(self.hass, self._command_topic, self._payload_stop, + self._qos) diff --git a/homeassistant/components/rollershutter/services.yaml b/homeassistant/components/rollershutter/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/scene.py b/homeassistant/components/scene.py index 66c15f8272f..ce1a3242542 100644 --- a/homeassistant/components/scene.py +++ b/homeassistant/components/scene.py @@ -1,11 +1,10 @@ """ homeassistant.components.scene ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows users to set and activate scenes. -Allows users to set and activate scenes within Home Assistant. - -A scene is a set of states that describe how you want certain entities to be. -For example, light A should be red with 100 brightness. Light B should be on. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/scene/ """ import logging from collections import namedtuple @@ -74,8 +73,9 @@ def _process_config(scene_config): for entity_id in c_entities: if isinstance(c_entities[entity_id], dict): - state = c_entities[entity_id].pop('state', None) - attributes = c_entities[entity_id] + entity_attrs = c_entities[entity_id].copy() + state = entity_attrs.pop('state', None) + attributes = entity_attrs else: state = c_entities[entity_id] attributes = {} diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index c4f70b6d6d3..3e13db66699 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -1,161 +1,218 @@ """ homeassistant.components.script ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -entity_id Scripts are a sequence of actions that can be triggered manually by the user or automatically based upon automation events, etc. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/script/ """ import logging from datetime import timedelta -import homeassistant.util.dt as date_util +from itertools import islice import threading -from homeassistant.helpers.event import track_point_in_time -from homeassistant.util import split_entity_id +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.util import slugify, split_entity_id +import homeassistant.util.dt as date_util from homeassistant.const import ( - STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, EVENT_TIME_CHANGED) + ATTR_ENTITY_ID, EVENT_TIME_CHANGED, STATE_ON, SERVICE_TURN_ON, + SERVICE_TURN_OFF) DOMAIN = "script" +ENTITY_ID_FORMAT = DOMAIN + '.{}' DEPENDENCIES = ["group"] +STATE_NOT_RUNNING = 'Not Running' + CONF_ALIAS = "alias" -CONF_SERVICE = "execute_service" +CONF_SERVICE = "service" +CONF_SERVICE_OLD = "execute_service" CONF_SERVICE_DATA = "service_data" CONF_SEQUENCE = "sequence" CONF_EVENT = "event" CONF_EVENT_DATA = "event_data" CONF_DELAY = "delay" -ATTR_ENTITY_ID = "entity_id" + +ATTR_LAST_ACTION = 'last_action' +ATTR_CAN_CANCEL = 'can_cancel' _LOGGER = logging.getLogger(__name__) +def is_on(hass, entity_id): + """ Returns if the switch is on based on the statemachine. """ + return hass.states.is_state(entity_id, STATE_ON) + + +def turn_on(hass, entity_id): + """ Turn script on. """ + _, object_id = split_entity_id(entity_id) + + hass.services.call(DOMAIN, object_id) + + +def turn_off(hass, entity_id): + """ Turn script on. """ + hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) + + def setup(hass, config): """ Load the scripts from the configuration. """ - scripts = [] - for name, cfg in config[DOMAIN].items(): - if CONF_SEQUENCE not in cfg: - _LOGGER.warn("Missing key 'sequence' for script %s", name) + component = EntityComponent(_LOGGER, DOMAIN, hass) + + def service_handler(service): + """ Execute a service call to script.