diff --git a/.coveragerc b/.coveragerc index f41886aaa0f..9410611536d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,32 +2,84 @@ source = homeassistant omit = - homeassistant/external/* + homeassistant/__main__.py # omit pieces of code that rely on external devices being present + homeassistant/components/arduino.py + homeassistant/components/*/arduino.py + + homeassistant/components/isy994.py + homeassistant/components/*/isy994.py + + homeassistant/components/modbus.py + homeassistant/components/*/modbus.py + + homeassistant/components/*/tellstick.py + homeassistant/components/*/vera.py + + homeassistant/components/verisure.py + homeassistant/components/*/verisure.py + homeassistant/components/wink.py homeassistant/components/*/wink.py homeassistant/components/zwave.py homeassistant/components/*/zwave.py - homeassistant/components/*/tellstick.py - homeassistant/components/*/vera.py - - homeassistant/components/keyboard.py - homeassistant/components/switch/wemo.py - homeassistant/components/thermostat/nest.py - homeassistant/components/light/hue.py - homeassistant/components/sensor/systemmonitor.py - homeassistant/components/sensor/sabnzbd.py - homeassistant/components/notify/pushbullet.py - homeassistant/components/notify/pushover.py - homeassistant/components/media_player/cast.py + homeassistant/components/ifttt.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/luci.py - homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py - homeassistant/components/device_tracker/ddwrt.py + homeassistant/components/device_tracker/thomson.py + homeassistant/components/device_tracker/tomato.py + homeassistant/components/device_tracker/tplink.py + homeassistant/components/discovery.py + homeassistant/components/downloader.py + homeassistant/components/keyboard.py + homeassistant/components/light/hue.py + homeassistant/components/light/limitlessled.py + homeassistant/components/media_player/cast.py + homeassistant/components/media_player/denon.py + homeassistant/components/media_player/kodi.py + homeassistant/components/media_player/mpd.py + homeassistant/components/media_player/squeezebox.py + homeassistant/components/notify/file.py + homeassistant/components/notify/instapush.py + homeassistant/components/notify/nma.py + homeassistant/components/notify/pushbullet.py + homeassistant/components/notify/pushover.py + homeassistant/components/notify/slack.py + homeassistant/components/notify/smtp.py + homeassistant/components/notify/syslog.py + homeassistant/components/notify/xmpp.py + homeassistant/components/sensor/arest.py + homeassistant/components/sensor/bitcoin.py + homeassistant/components/sensor/dht.py + homeassistant/components/sensor/efergy.py + homeassistant/components/sensor/forecast.py + homeassistant/components/sensor/mysensors.py + homeassistant/components/sensor/openweathermap.py + homeassistant/components/sensor/rfxtrx.py + homeassistant/components/sensor/rpi_gpio.py + homeassistant/components/sensor/sabnzbd.py + homeassistant/components/sensor/swiss_public_transport.py + homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/temper.py + homeassistant/components/sensor/time_date.py + homeassistant/components/sensor/transmission.py + homeassistant/components/switch/command_switch.py + homeassistant/components/switch/edimax.py + homeassistant/components/switch/hikvisioncam.py + homeassistant/components/switch/rpi_gpio.py + homeassistant/components/switch/transmission.py + homeassistant/components/switch/wemo.py + homeassistant/components/thermostat/nest.py [report] diff --git a/.gitignore b/.gitignore index c7e72545bf6..6bda29ca6fc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ homeassistant/components/frontend/www_static/polymer/bower_components/* config/custom_components/* !config/custom_components/example.py !config/custom_components/hello_world.py +!config/custom_components/mqtt_example.py + +tests/config/home-assistant.log # Hide sublime text stuff *.sublime-project @@ -64,5 +67,12 @@ nosetests.xml .project .pydevproject -# Hide emacs backups +# emacs auto backups *~ +*# +*.orig +.python-version + +# venv stuff +pyvenv.cfg +pip-selfcheck.json diff --git a/.gitmodules b/.gitmodules index ae38be7c61b..ad28a4e2c8a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,21 +1,3 @@ -[submodule "homeassistant/external/pynetgear"] - path = homeassistant/external/pynetgear - url = https://github.com/balloob/pynetgear.git -[submodule "homeassistant/external/pywemo"] - path = homeassistant/external/pywemo - url = https://github.com/balloob/pywemo.git -[submodule "homeassistant/external/netdisco"] - path = homeassistant/external/netdisco - url = https://github.com/balloob/netdisco.git -[submodule "homeassistant/external/noop"] - path = homeassistant/external/noop - url = https://github.com/balloob/noop.git -[submodule "homeassistant/components/frontend/www_static/polymer/home-assistant-js"] - path = homeassistant/components/frontend/www_static/polymer/home-assistant-js - url = https://github.com/balloob/home-assistant-js.git -[submodule "homeassistant/external/vera"] - path = homeassistant/external/vera - url = https://github.com/jamespcole/home-assistant-vera-api.git -[submodule "homeassistant/external/nzbclients"] - path = homeassistant/external/nzbclients - url = https://github.com/jamespcole/home-assistant-nzb-clients.git +[submodule "homeassistant/components/frontend/www_static/home-assistant-polymer"] + path = homeassistant/components/frontend/www_static/home-assistant-polymer + url = https://github.com/balloob/home-assistant-polymer.git diff --git a/.travis.yml b/.travis.yml index d8632860c2a..339ed48d424 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,12 @@ +sudo: false language: python python: - "3.4" install: - - pip install -r requirements.txt + - pip install -r requirements_all.txt - pip install flake8 pylint coveralls script: - - flake8 homeassistant --exclude bower_components,external + - flake8 homeassistant - pylint homeassistant - coverage run -m unittest discover tests after_success: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f1c2c658bb..3f2fd110a1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,24 +1,41 @@ -# Adding support for a new device +# Contributing to Home Assistant -For help on building your component, please see the See the [developer documentation on home-assistant.io](https://home-assistant.io/developers/). +Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spent a couple of hours and help to integrate them? + +The process is straight-forward. + + - Fork the Home Assistant [git repository](https://github.com/balloob/home-assistant). + - Write the code for your device, notification service, sensor, or IoT thing. + - Check it with ``pylint`` and ``flake8``. + - Create a Pull Request against the [**dev**](https://github.com/balloob/home-assistant/tree/dev) branch of Home Assistant. + +Still interested? Then you should read the next sections and get more details. + +## Adding support for a new device + +For help on building your component, please see the [developer documentation](https://home-assistant.io/developers/) on [home-assistant.io](https://home-assistant.io/). After you finish adding support for your device: - - update the supported devices in README.md. - - add any new dependencies to requirements.txt. - - Make sure all your code passes Pylint, flake8 (PEP8 and some more) validation. To generate reports, run `pylint homeassistant > pylint.txt` and `flake8 homeassistant --exclude bower_components,external > flake8.txt`. + - Update the supported devices in the `README.md` file. + - Add any new dependencies to `requirements.txt`. + - 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`. + - 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: - - update the file [`domain-icon.html`](https://github.com/balloob/home-assistant/blob/master/homeassistant/components/http/www_static/polymer/domain-icon.html) with an icon for your domain ([pick from this list](https://www.polymer-project.org/components/core-icons/demo.html)) - - update the demo component with two states that it provides + - 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 -Since you've updated domain-icon.html, you've made changes to the frontend: +Since you've updated `home-assistant-icons.html`, you've made changes to the frontend: - - run `build_frontend`. This will build a new version of the frontend. Make sure you add the changed files `frontend.py` and `frontend.html` to the commit. + - Run `build_frontend`. This will build a new version of the frontend. Make sure you add the changed files `frontend.py` and `frontend.html` to the commit. -## Setting states +### Setting states It is the responsibility of the component to maintain the states of the devices in your domain. Each device should be a single state and, if possible, a group should be provided that tracks the combined state of the devices. @@ -27,15 +44,29 @@ A state can have several attributes that will help the frontend in displaying yo - `friendly_name`: this name will be used as the name of the device - `entity_picture`: this picture will be shown instead of the domain icon - `unit_of_measurement`: this will be appended to the state in the interface + - `hidden`: This is a suggestion to the frontend on if the state should be hidden These attributes are defined in [homeassistant.components](https://github.com/balloob/home-assistant/blob/master/homeassistant/components/__init__.py#L25). -## Working on the frontend +### Proper Visibility Handling -The frontend is composed of Polymer web-components and compiled into the file `frontend.html`. During development you do not want to work with the compiled version but with the seperate files. To have Home Assistant serve the seperate files, set `development=1` for the http-component in your config. +Generally, when creating a new entity for Home Assistant you will want it to be a class that inherits the [homeassistant.helpers.entity.Entity](https://github.com/balloob/home-assistant/blob/master/homeassistant/helpers/entity.py) class. If this is done, visibility will be handled for you. +You can set a suggestion for your entity's visibility by setting the hidden property by doing something similar to the following. + +```python +self.hidden = True +``` + +This will SUGGEST that the active frontend hides the entity. This requires that the active frontend support hidden cards (the default frontend does) and that the value of hidden be included in your attributes dictionary (see above). The Entity abstract class will take care of this for you. + +Remember: The suggestion set by your component's code will always be overwritten by user settings in the configuration.yaml file. This is why you may set hidden to be False, but the property may remain True (or vice-versa). + +### Working on the frontend + +The frontend is composed of [Polymer](https://www.polymer-project.org) web-components and compiled into the file `frontend.html`. During development you do not want to work with the compiled version but with the seperate files. To have Home Assistant serve the seperate files, set `development=1` for the *http-component* in your config. When you are done with development and ready to commit your changes, run `build_frontend`, set `development=0` in your config and validate that everything still works. -## Notes on PyLint and PEP8 validation +### Notes on PyLint and PEP8 validation In case a PyLint warning cannot be avoided, add a comment to disable the PyLint check for that line. This can be done using the format `# pylint: disable=YOUR-ERROR-NAME`. Example of an unavoidable PyLint warning is if you do not use the passed in datetime if you're listening for time change. diff --git a/Dockerfile b/Dockerfile index ff34dc79297..9554ec552d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,18 @@ MAINTAINER Paulus Schoutsen VOLUME /config +RUN pip3 install --no-cache-dir -r requirements_all.txt + +# For the nmap tracker 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 + 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 CMD [ "python", "-m", "homeassistant", "--config", "/config" ] diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000000..aae95799ac4 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-exclude tests * diff --git a/README.md b/README.md index 43c4d58a33d..26bb0b998f5 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,37 @@ -# 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) +# 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) -This is the source code for Home Assistant. For installation instructions, tutorials and the docs, please see [the website](https://home-assistant.io). For a functioning demo frontend of Home Assistant, [click here](https://home-assistant.io/demo/). +[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. -It offers the following functionality through built-in components: +To get started: +```bash +python3 -m pip install homeassistant +hass --open-ui +``` - * Track if devices are home by monitoring connected devices to a wireless router (supporting [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), [DD-WRT](http://www.dd-wrt.com/site/index)) - * Track and control [Philips Hue](http://meethue.com) lights - * Track and control [WeMo switches](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) - * Track and control [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast) - * Track running services by monitoring `ps` output - * Track and control [Tellstick devices and sensors](http://www.telldus.se/products/tellstick) +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/), and [ASUSWRT](http://event.asus.com/2013/nw/ASUSWRT/) + * [Philips Hue](http://meethue.com) lights, [WeMo](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) switches, [Edimax](http://www.edimax.com/) switches, [Efergy](https://efergy.com) energy monitoring, RFXtrx sensors, and [Tellstick](http://www.telldus.se/products/tellstick) devices and sensors + * [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), and [Kodi (XBMC)](http://kodi.tv/) + * Support for [ISY994](https://www.universal-devices.com/residential/isy994i-series/) (Insteon and X10 devices), [Z-Wave](http://www.z-wave.com/), [Nest Thermostats](https://nest.com/), [Arduino](https://www.arduino.cc/), [Raspberry Pi](https://www.raspberrypi.org/), and [Modbus](http://www.modbus.org/) + * Integrate data from the [Bitcoin](https://bitcoin.org) network, meteorological data from [OpenWeatherMap](http://openweathermap.org/) and [Forecast.io](https://forecast.io/), [Transmission](http://www.transmissionbt.com/), or [SABnzbd](http://sabnzbd.org). + * [See full list of supported devices](https://home-assistant.io/components/) + +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 light loss + * Turn on lights slowly during sun set to compensate for less light * Turn off all lights and devices when everybody leaves the house - * Offers web interface to monitor and control Home Assistant - * Offers a [REST API](https://home-assistant.io/developers/api.html) for easy integration with other projects - * [Ability to have multiple instances of Home Assistant work together](https://home-assistant.io/developers/architecture.html) - -Home Assistant also includes functionality for controlling HTPCs: - - * Simulate key presses for Play/Pause, Next track, Prev track, Volume up, Volume Down - * Download files - * Open URLs in the default browser - -[![screenshot-states](https://raw.github.com/balloob/home-assistant/master/docs/screenshots.png)](https://home-assistant.io/demo/) + * Offers a [REST API](https://home-assistant.io/developers/api.html) and can interface with MQTT for easy integration with other projects + * 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/), 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, reach out to the [Home Assistant developer community](https://groups.google.com/forum/#!forum/home-assistant-dev). - -## Installation instructions / Quick-start guide - -Running Home Assistant requires that python 3.4 and the package requests are installed. Run the following code to install and start Home Assistant: - -```python -git clone --recursive https://github.com/balloob/home-assistant.git -cd home-assistant -pip3 install -r requirements.txt -python3 -m homeassistant --open-ui -``` - -The last command will start the Home Assistant server and launch its webinterface. By default Home Assistant looks for the configuration file `config/home-assistant.conf`. A standard configuration file will be written if none exists. - -If you are still exploring if you want to use Home Assistant in the first place, you can enable the demo mode by adding the `--demo-mode` argument to the last command. - -Please see [the getting started guide](https://home-assistant.io/getting-started/) on how to further configure Home Asssitant. +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/config/configuration.yaml.example b/config/configuration.yaml.example index 125be6e2d05..5acca361a30 100644 --- a/config/configuration.yaml.example +++ b/config/configuration.yaml.example @@ -28,7 +28,8 @@ wink: access_token: 'YOUR_TOKEN' device_tracker: - # The following types are available: netgear, tomato, luci, nmap_tracker + # The following types are available: ddwrt, netgear, tomato, luci, + # and nmap_tracker platform: netgear host: 192.168.1.1 username: admin @@ -65,9 +66,9 @@ device_sun_light_trigger: # Optional: disable lights being turned off when everybody leaves the house # disable_turn_off: 1 -# A comma seperated list of states that have to be tracked as a single group +# A comma separated list of states that have to be tracked as a single group # Grouped states should share the same type of states (ON/OFF or HOME/NOT_HOME) -group: +group: living_room: - light.Bowl - light.Ceiling @@ -158,5 +159,5 @@ scene: light.tv_back_light: on light.ceiling: state: on - color: [0.33, 0.66] + xy_color: [0.33, 0.66] brightness: 200 diff --git a/config/custom_components/example.py b/config/custom_components/example.py index a972e3ab576..ee7f18f437a 100644 --- a/config/custom_components/example.py +++ b/config/custom_components/example.py @@ -8,6 +8,22 @@ Example component to target an entity_id to: - turn it off if all lights are turned off - turn it off if all people leave the house - offer a service to turn it on for 10 seconds + +Configuration: + +To use the Example custom component you will need to add the following to +your configuration.yaml file. + +example: + target: TARGET_ENTITY + +Variable: + +target +*Required +TARGET_ENTITY should be one of your devices that can be turned on and off, +ie a light or a switch. Example value could be light.Ceiling or switch.AC +(if you have these devices with those names). """ import time import logging @@ -22,7 +38,7 @@ DOMAIN = "example" # List of component names (string) your component depends upon # We depend on group because group will be loaded after all the components that -# initalize devices have been setup. +# initialize devices have been setup. DEPENDENCIES = ['group'] # Configuration key for the entity id we are targetting @@ -31,6 +47,7 @@ CONF_TARGET = 'target' # Name of the service that we expose SERVICE_FLASH = 'flash' +# Shortcut for the logger _LOGGER = logging.getLogger(__name__) @@ -115,5 +132,5 @@ def setup(hass, config): # Register our service with HASS. hass.services.register(DOMAIN, SERVICE_FLASH, flash_service) - # Tells the bootstrapper that the component was succesfully initialized + # Tells the bootstrapper that the component was successfully initialized return True diff --git a/config/custom_components/hello_world.py b/config/custom_components/hello_world.py index be1b935c8ad..a3d4ce762bb 100644 --- a/config/custom_components/hello_world.py +++ b/config/custom_components/hello_world.py @@ -1,8 +1,14 @@ """ custom_components.hello_world ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Implements the bare minimum that a component should implement. + +Configuration: + +To use the hello_word component you will need to add the following to your +configuration.yaml file. + +hello_world: """ # The domain of your component. Should be equal to the name of your component diff --git a/config/custom_components/mqtt_example.py b/config/custom_components/mqtt_example.py new file mode 100644 index 00000000000..98e16b6bfa9 --- /dev/null +++ b/config/custom_components/mqtt_example.py @@ -0,0 +1,59 @@ +""" +custom_components.mqtt_example +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Shows how to communicate with MQTT. Follows a topic on MQTT and updates the +state of an entity to the last message received on that topic. + +Also offers a service 'set_state' that will publish a message on the topic that +will be passed via MQTT to our message received listener. Call the service with +example payload {"new_state": "some new state"}. + +Configuration: + +To use the mqtt_example component you will need to add the following to your +configuration.yaml file. + +mqtt_example: + topic: home-assistant/mqtt_example + +""" +import homeassistant.loader as loader + +# The domain of your component. Should be equal to the name of your component +DOMAIN = "mqtt_example" + +# List of component names (string) your component depends upon +DEPENDENCIES = ['mqtt'] + + +CONF_TOPIC = 'topic' +DEFAULT_TOPIC = 'home-assistant/mqtt_example' + + +def setup(hass, config): + """ Setup our mqtt_example component. """ + mqtt = loader.get_component('mqtt') + topic = config[DOMAIN].get('topic', DEFAULT_TOPIC) + entity_id = 'mqtt_example.last_message' + + # Listen to a message on MQTT + + def message_received(topic, payload, qos): + """ A new MQTT message has been received. """ + hass.states.set(entity_id, payload) + + mqtt.subscribe(hass, topic, message_received) + + hass.states.set(entity_id, 'No messages') + + # Service to publish a message on MQTT + + def set_state_service(call): + """ Service to send a message. """ + mqtt.publish(hass, topic, call.data.get('new_state')) + + # Register our service with Home Assistant + hass.services.register(DOMAIN, 'set_state', set_state_service) + + # return boolean to indicate that initialization was successful + return True diff --git a/docs/architecture-remote.png b/docs/architecture-remote.png deleted file mode 100644 index 3109c921846..00000000000 Binary files a/docs/architecture-remote.png and /dev/null differ diff --git a/docs/architecture.png b/docs/architecture.png deleted file mode 100644 index 7fe62cf3144..00000000000 Binary files a/docs/architecture.png and /dev/null differ diff --git a/docs/screenshots.png b/docs/screenshots.png index 09dff77c894..a5e278b0394 100644 Binary files a/docs/screenshots.png and b/docs/screenshots.png differ diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index b93a8ee99be..e69de29bb2d 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -1,946 +0,0 @@ -""" -homeassistant -~~~~~~~~~~~~~ - -Home Assistant is a Home Automation framework for observing the state -of entities and react to changes. -""" - -import os -import time -import logging -import threading -import enum -import re -import datetime as dt -import functools as ft - -import requests - -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - SERVICE_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, - EVENT_CALL_SERVICE, ATTR_NOW, ATTR_DOMAIN, ATTR_SERVICE, MATCH_ALL, - EVENT_SERVICE_EXECUTED, ATTR_SERVICE_CALL_ID, EVENT_SERVICE_REGISTERED, - TEMP_CELCIUS, TEMP_FAHRENHEIT) -import homeassistant.util as util - -DOMAIN = "homeassistant" - -# How often time_changed event should fire -TIMER_INTERVAL = 1 # seconds - -# How long we wait for the result of a service call -SERVICE_CALL_LIMIT = 10 # seconds - -# Define number of MINIMUM worker threads. -# During bootstrap of HA (see bootstrap.from_config_dict()) worker threads -# will be added for each component that polls devices. -MIN_WORKER_THREAD = 2 - -# Pattern for validating entity IDs (format: .) -ENTITY_ID_PATTERN = re.compile(r"^(?P\w+)\.(?P\w+)$") - -_LOGGER = logging.getLogger(__name__) - - -class HomeAssistant(object): - """ Core class to route all communication to right components. """ - - def __init__(self): - self.pool = pool = create_worker_pool() - self.bus = EventBus(pool) - self.services = ServiceRegistry(self.bus, pool) - self.states = StateMachine(self.bus) - self.config = Config() - - @property - def components(self): - """ DEPRECATED 3/21/2015. Use hass.config.components """ - _LOGGER.warning( - 'hass.components is deprecated. Use hass.config.components') - return self.config.components - - @property - def local_api(self): - """ DEPRECATED 3/21/2015. Use hass.config.api """ - _LOGGER.warning( - 'hass.local_api is deprecated. Use hass.config.api') - return self.config.api - - @property - def config_dir(self): - """ DEPRECATED 3/18/2015. Use hass.config.config_dir """ - _LOGGER.warning( - 'hass.config_dir is deprecated. Use hass.config.config_dir') - return self.config.config_dir - - def get_config_path(self, path): - """ DEPRECATED 3/18/2015. Use hass.config.path """ - _LOGGER.warning( - 'hass.get_config_path is deprecated. Use hass.config.path') - return self.config.path(path) - - def start(self): - """ Start home assistant. """ - _LOGGER.info( - "Starting Home Assistant (%d threads)", self.pool.worker_count) - - Timer(self) - - self.bus.fire(EVENT_HOMEASSISTANT_START) - - def block_till_stopped(self): - """ Will register service homeassistant/stop and - will block until called. """ - request_shutdown = threading.Event() - - self.services.register(DOMAIN, SERVICE_HOMEASSISTANT_STOP, - lambda service: request_shutdown.set()) - - while not request_shutdown.isSet(): - try: - time.sleep(1) - - except KeyboardInterrupt: - break - - self.stop() - - def track_point_in_time(self, action, point_in_time): - """ - Adds a listener that fires once at or after a spefic point in time. - """ - - @ft.wraps(action) - def point_in_time_listener(event): - """ Listens for matching time_changed events. """ - now = event.data[ATTR_NOW] - - if now >= point_in_time and \ - not hasattr(point_in_time_listener, 'run'): - - # Set variable so that we will never run twice. - # Because the event bus might have to wait till a thread comes - # available to execute this listener it might occur that the - # listener gets lined up twice to be executed. This will make - # sure the second time it does nothing. - point_in_time_listener.run = True - - self.bus.remove_listener(EVENT_TIME_CHANGED, - point_in_time_listener) - - action(now) - - self.bus.listen(EVENT_TIME_CHANGED, point_in_time_listener) - return point_in_time_listener - - # pylint: disable=too-many-arguments - def track_time_change(self, action, - year=None, month=None, day=None, - hour=None, minute=None, second=None): - """ Adds a listener that will fire if time matches a pattern. """ - - # We do not have to wrap the function with time pattern matching logic - # if no pattern given - if any((val is not None for val in - (year, month, day, hour, minute, second))): - - pmp = _process_match_param - year, month, day = pmp(year), pmp(month), pmp(day) - hour, minute, second = pmp(hour), pmp(minute), pmp(second) - - @ft.wraps(action) - def time_listener(event): - """ Listens for matching time_changed events. """ - now = event.data[ATTR_NOW] - - mat = _matcher - - if mat(now.year, year) and \ - mat(now.month, month) and \ - mat(now.day, day) and \ - mat(now.hour, hour) and \ - mat(now.minute, minute) and \ - mat(now.second, second): - - action(now) - - else: - @ft.wraps(action) - def time_listener(event): - """ Fires every time event that comes in. """ - action(event.data[ATTR_NOW]) - - self.bus.listen(EVENT_TIME_CHANGED, time_listener) - return time_listener - - def stop(self): - """ Stops Home Assistant and shuts down all threads. """ - _LOGGER.info("Stopping") - - self.bus.fire(EVENT_HOMEASSISTANT_STOP) - - # Wait till all responses to homeassistant_stop are done - self.pool.block_till_done() - - self.pool.stop() - - def get_entity_ids(self, domain_filter=None): - """ - Returns known entity ids. - - THIS METHOD IS DEPRECATED. Use hass.states.entity_ids - """ - _LOGGER.warning( - "hass.get_entiy_ids is deprecated. Use hass.states.entity_ids") - - return self.states.entity_ids(domain_filter) - - def listen_once_event(self, event_type, listener): - """ Listen once for event of a specific type. - - To listen to all events specify the constant ``MATCH_ALL`` - as event_type. - - Note: at the moment it is impossible to remove a one time listener. - - THIS METHOD IS DEPRECATED. Please use hass.events.listen_once. - """ - _LOGGER.warning( - "hass.listen_once_event is deprecated. Use hass.bus.listen_once") - - self.bus.listen_once(event_type, listener) - - def track_state_change(self, entity_ids, action, - from_state=None, to_state=None): - """ - Track specific state changes. - entity_ids, from_state and to_state can be string or list. - Use list to match multiple. - - THIS METHOD IS DEPRECATED. Use hass.states.track_change - """ - _LOGGER.warning(( - "hass.track_state_change is deprecated. " - "Use hass.states.track_change")) - - self.states.track_change(entity_ids, action, from_state, to_state) - - def call_service(self, domain, service, service_data=None): - """ - Fires event to call specified service. - - THIS METHOD IS DEPRECATED. Use hass.services.call - """ - _LOGGER.warning(( - "hass.services.call is deprecated. " - "Use hass.services.call")) - - self.services.call(domain, service, service_data) - - -def _process_match_param(parameter): - """ Wraps parameter in a list if it is not one and returns it. """ - if parameter is None or parameter == MATCH_ALL: - return MATCH_ALL - elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): - return (parameter,) - else: - return tuple(parameter) - - -def _matcher(subject, pattern): - """ Returns True if subject matches the pattern. - - Pattern is either a list of allowed subjects or a `MATCH_ALL`. - """ - return MATCH_ALL == pattern or subject in pattern - - -class JobPriority(util.OrderedEnum): - """ Provides priorities for bus events. """ - # pylint: disable=no-init,too-few-public-methods - - EVENT_CALLBACK = 0 - EVENT_SERVICE = 1 - EVENT_STATE = 2 - EVENT_TIME = 3 - EVENT_DEFAULT = 4 - - @staticmethod - def from_event_type(event_type): - """ Returns a priority based on event type. """ - if event_type == EVENT_TIME_CHANGED: - return JobPriority.EVENT_TIME - elif event_type == EVENT_STATE_CHANGED: - return JobPriority.EVENT_STATE - elif event_type == EVENT_CALL_SERVICE: - return JobPriority.EVENT_SERVICE - elif event_type == EVENT_SERVICE_EXECUTED: - return JobPriority.EVENT_CALLBACK - else: - return JobPriority.EVENT_DEFAULT - - -def create_worker_pool(): - """ Creates a worker pool to be used. """ - - def job_handler(job): - """ Called whenever a job is available to do. """ - try: - func, arg = job - func(arg) - except Exception: # pylint: disable=broad-except - # Catch any exception our service/event_listener might throw - # We do not want to crash our ThreadPool - _LOGGER.exception("BusHandler:Exception doing job") - - def busy_callback(worker_count, current_jobs, pending_jobs_count): - """ Callback to be called when the pool queue gets too big. """ - - _LOGGER.warning( - "WorkerPool:All %d threads are busy and %d jobs pending", - worker_count, pending_jobs_count) - - for start, job in current_jobs: - _LOGGER.warning("WorkerPool:Current job from %s: %s", - util.datetime_to_str(start), job) - - return util.ThreadPool(job_handler, MIN_WORKER_THREAD, busy_callback) - - -class EventOrigin(enum.Enum): - """ Distinguish between origin of event. """ - # pylint: disable=no-init,too-few-public-methods - - local = "LOCAL" - remote = "REMOTE" - - def __str__(self): - return self.value - - -# pylint: disable=too-few-public-methods -class Event(object): - """ Represents an event within the Bus. """ - - __slots__ = ['event_type', 'data', 'origin'] - - def __init__(self, event_type, data=None, origin=EventOrigin.local): - self.event_type = event_type - self.data = data or {} - self.origin = origin - - def as_dict(self): - """ Returns a dict representation of this Event. """ - return { - 'event_type': self.event_type, - 'data': dict(self.data), - 'origin': str(self.origin) - } - - def __repr__(self): - # pylint: disable=maybe-no-member - if self.data: - return "".format( - self.event_type, str(self.origin)[0], - util.repr_helper(self.data)) - else: - return "".format(self.event_type, - str(self.origin)[0]) - - -class EventBus(object): - """ Class that allows different components to communicate via services - and events. - """ - - def __init__(self, pool=None): - self._listeners = {} - self._lock = threading.Lock() - self._pool = pool or create_worker_pool() - - @property - def listeners(self): - """ Dict with events that is being listened for and the number - of listeners. - """ - with self._lock: - return {key: len(self._listeners[key]) - for key in self._listeners} - - def fire(self, event_type, event_data=None, origin=EventOrigin.local): - """ Fire an event. """ - with self._lock: - # Copy the list of the current listeners because some listeners - # remove themselves as a listener while being executed which - # causes the iterator to be confused. - get = self._listeners.get - listeners = get(MATCH_ALL, []) + get(event_type, []) - - event = Event(event_type, event_data, origin) - - if event_type != EVENT_TIME_CHANGED: - _LOGGER.info("Bus:Handling %s", event) - - if not listeners: - return - - job_priority = JobPriority.from_event_type(event_type) - - for func in listeners: - self._pool.add_job(job_priority, (func, event)) - - def listen(self, event_type, listener): - """ Listen for all events or events of a specific type. - - To listen to all events specify the constant ``MATCH_ALL`` - as event_type. - """ - with self._lock: - if event_type in self._listeners: - self._listeners[event_type].append(listener) - else: - self._listeners[event_type] = [listener] - - def listen_once(self, event_type, listener): - """ Listen once for event of a specific type. - - To listen to all events specify the constant ``MATCH_ALL`` - as event_type. - - Note: at the moment it is impossible to remove a one time listener. - """ - @ft.wraps(listener) - def onetime_listener(event): - """ Removes listener from eventbus and then fires listener. """ - if not hasattr(onetime_listener, 'run'): - # Set variable so that we will never run twice. - # Because the event bus might have to wait till a thread comes - # available to execute this listener it might occur that the - # listener gets lined up twice to be executed. - # This will make sure the second time it does nothing. - onetime_listener.run = True - - self.remove_listener(event_type, onetime_listener) - - listener(event) - - self.listen(event_type, onetime_listener) - - def remove_listener(self, event_type, listener): - """ Removes a listener of a specific event_type. """ - with self._lock: - try: - self._listeners[event_type].remove(listener) - - # delete event_type list if empty - if not self._listeners[event_type]: - self._listeners.pop(event_type) - - except (KeyError, ValueError): - # KeyError is key event_type listener did not exist - # ValueError if listener did not exist within event_type - pass - - -class State(object): - """ - Object to represent a state within the state machine. - - entity_id: the entity that is represented. - state: the state of the entity - attributes: extra information on entity and state - last_changed: last time the state was changed, not the attributes. - last_updated: last time this object was updated. - """ - - __slots__ = ['entity_id', 'state', 'attributes', - 'last_changed', 'last_updated'] - - def __init__(self, entity_id, state, attributes=None, last_changed=None): - if not ENTITY_ID_PATTERN.match(entity_id): - raise InvalidEntityFormatError(( - "Invalid entity id encountered: {}. " - "Format should be .").format(entity_id)) - - self.entity_id = entity_id.lower() - self.state = state - self.attributes = attributes or {} - self.last_updated = dt.datetime.now() - - # Strip microsecond from last_changed else we cannot guarantee - # state == State.from_dict(state.as_dict()) - # This behavior occurs because to_dict uses datetime_to_str - # which does not preserve microseconds - self.last_changed = util.strip_microseconds( - last_changed or self.last_updated) - - @property - def domain(self): - """ Returns domain of this state. """ - return util.split_entity_id(self.entity_id)[0] - - def copy(self): - """ Creates a copy of itself. """ - return State(self.entity_id, self.state, - dict(self.attributes), self.last_changed) - - def as_dict(self): - """ Converts State to a dict to be used within JSON. - Ensures: state == State.from_dict(state.as_dict()) """ - - return {'entity_id': self.entity_id, - 'state': self.state, - 'attributes': self.attributes, - 'last_changed': util.datetime_to_str(self.last_changed)} - - @classmethod - def from_dict(cls, json_dict): - """ Static method to create a state from a dict. - Ensures: state == State.from_json_dict(state.to_json_dict()) """ - - if not (json_dict and - 'entity_id' in json_dict and - 'state' in json_dict): - return None - - last_changed = json_dict.get('last_changed') - - if last_changed: - last_changed = util.str_to_datetime(last_changed) - - return cls(json_dict['entity_id'], json_dict['state'], - json_dict.get('attributes'), last_changed) - - def __eq__(self, other): - return (self.__class__ == other.__class__ and - self.entity_id == other.entity_id and - self.state == other.state and - self.attributes == other.attributes) - - def __repr__(self): - attr = "; {}".format(util.repr_helper(self.attributes)) \ - if self.attributes else "" - - return "".format( - self.entity_id, self.state, attr, - util.datetime_to_str(self.last_changed)) - - -class StateMachine(object): - """ Helper class that tracks the state of different entities. """ - - def __init__(self, bus): - self._states = {} - self._bus = bus - self._lock = threading.Lock() - - def entity_ids(self, domain_filter=None): - """ List of entity ids that are being tracked. """ - if domain_filter is not None: - domain_filter = domain_filter.lower() - - return [state.entity_id for key, state - in self._states.items() - if util.split_entity_id(key)[0] == domain_filter] - else: - return list(self._states.keys()) - - def all(self): - """ Returns a list of all states. """ - return [state.copy() for state in self._states.values()] - - def get(self, entity_id): - """ Returns the state of the specified entity. """ - state = self._states.get(entity_id.lower()) - - # Make a copy so people won't mutate the state - return state.copy() if state else None - - def get_since(self, point_in_time): - """ - Returns all states that have been changed since point_in_time. - """ - point_in_time = util.strip_microseconds(point_in_time) - - with self._lock: - return [state for state in self._states.values() - if state.last_updated >= point_in_time] - - def is_state(self, entity_id, state): - """ Returns True if entity exists and is specified state. """ - entity_id = entity_id.lower() - - return (entity_id in self._states and - self._states[entity_id].state == state) - - def remove(self, entity_id): - """ Removes an entity from the state machine. - - Returns boolean to indicate if an entity was removed. """ - entity_id = entity_id.lower() - - with self._lock: - return self._states.pop(entity_id, None) is not None - - def set(self, entity_id, new_state, attributes=None): - """ Set the state of an entity, add entity if it does not exist. - - Attributes is an optional dict to specify attributes of this state. - - If you just update the attributes and not the state, last changed will - not be affected. - """ - entity_id = entity_id.lower() - new_state = str(new_state) - attributes = attributes or {} - - with self._lock: - old_state = self._states.get(entity_id) - - is_existing = old_state is not None - same_state = is_existing and old_state.state == new_state - same_attr = is_existing and old_state.attributes == attributes - - # If state did not exist or is different, set it - if not (same_state and same_attr): - last_changed = old_state.last_changed if same_state else None - - state = State(entity_id, new_state, attributes, last_changed) - self._states[entity_id] = state - - event_data = {'entity_id': entity_id, 'new_state': state} - - if old_state: - event_data['old_state'] = old_state - - self._bus.fire(EVENT_STATE_CHANGED, event_data) - - def track_change(self, entity_ids, action, from_state=None, to_state=None): - """ - Track specific state changes. - entity_ids, from_state and to_state can be string or list. - Use list to match multiple. - - Returns the listener that listens on the bus for EVENT_STATE_CHANGED. - Pass the return value into hass.bus.remove_listener to remove it. - """ - from_state = _process_match_param(from_state) - to_state = _process_match_param(to_state) - - # Ensure it is a lowercase list with entity ids we want to match on - if isinstance(entity_ids, str): - entity_ids = (entity_ids.lower(),) - else: - entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) - - @ft.wraps(action) - def state_listener(event): - """ The listener that listens for specific state changes. """ - if event.data['entity_id'] not in entity_ids: - return - - if 'old_state' in event.data: - old_state = event.data['old_state'].state - else: - old_state = None - - if _matcher(old_state, from_state) and \ - _matcher(event.data['new_state'].state, to_state): - - action(event.data['entity_id'], - event.data.get('old_state'), - event.data['new_state']) - - self._bus.listen(EVENT_STATE_CHANGED, state_listener) - - return state_listener - - -# pylint: disable=too-few-public-methods -class ServiceCall(object): - """ Represents a call to a service. """ - - __slots__ = ['domain', 'service', 'data'] - - def __init__(self, domain, service, data=None): - self.domain = domain - self.service = service - self.data = data or {} - - def __repr__(self): - if self.data: - return "".format( - self.domain, self.service, util.repr_helper(self.data)) - else: - return "".format(self.domain, self.service) - - -class ServiceRegistry(object): - """ Offers services over the eventbus. """ - - def __init__(self, bus, pool=None): - self._services = {} - self._lock = threading.Lock() - self._pool = pool or create_worker_pool() - self._bus = bus - self._cur_id = 0 - bus.listen(EVENT_CALL_SERVICE, self._event_to_service_call) - - @property - def services(self): - """ Dict with per domain a list of available services. """ - with self._lock: - return {domain: list(self._services[domain].keys()) - for domain in self._services} - - def has_service(self, domain, service): - """ Returns True if specified service exists. """ - return service in self._services.get(domain, []) - - def register(self, domain, service, service_func): - """ Register a service. """ - with self._lock: - if domain in self._services: - self._services[domain][service] = service_func - else: - self._services[domain] = {service: service_func} - - self._bus.fire( - EVENT_SERVICE_REGISTERED, - {ATTR_DOMAIN: domain, ATTR_SERVICE: service}) - - def call(self, domain, service, service_data=None, blocking=False): - """ - Calls specified service. - Specify blocking=True to wait till service is executed. - Waits a maximum of SERVICE_CALL_LIMIT. - - If blocking = True, will return boolean if service executed - succesfully within SERVICE_CALL_LIMIT. - - This method will fire an event to call the service. - This event will be picked up by this ServiceRegistry and any - other ServiceRegistry that is listening on the EventBus. - - Because the service is sent as an event you are not allowed to use - the keys ATTR_DOMAIN and ATTR_SERVICE in your service_data. - """ - call_id = self._generate_unique_id() - event_data = service_data or {} - event_data[ATTR_DOMAIN] = domain - event_data[ATTR_SERVICE] = service - event_data[ATTR_SERVICE_CALL_ID] = call_id - - if blocking: - executed_event = threading.Event() - - def service_executed(call): - """ - Called when a service is executed. - Will set the event if matches our service call. - """ - if call.data[ATTR_SERVICE_CALL_ID] == call_id: - executed_event.set() - - self._bus.remove_listener( - EVENT_SERVICE_EXECUTED, service_executed) - - self._bus.listen(EVENT_SERVICE_EXECUTED, service_executed) - - self._bus.fire(EVENT_CALL_SERVICE, event_data) - - if blocking: - # wait will return False if event not set after our limit has - # passed. If not set, clean up the listener - if not executed_event.wait(SERVICE_CALL_LIMIT): - self._bus.remove_listener( - EVENT_SERVICE_EXECUTED, service_executed) - - return False - - return True - - def _event_to_service_call(self, event): - """ Calls a service from an event. """ - service_data = dict(event.data) - domain = service_data.pop(ATTR_DOMAIN, None) - service = service_data.pop(ATTR_SERVICE, None) - - with self._lock: - if domain in self._services and service in self._services[domain]: - service_call = ServiceCall(domain, service, service_data) - - # Add a job to the pool that calls _execute_service - self._pool.add_job(JobPriority.EVENT_SERVICE, - (self._execute_service, - (self._services[domain][service], - service_call))) - - def _execute_service(self, service_and_call): - """ Executes a service and fires a SERVICE_EXECUTED event. """ - service, call = service_and_call - - service(call) - - self._bus.fire( - EVENT_SERVICE_EXECUTED, { - ATTR_SERVICE_CALL_ID: call.data[ATTR_SERVICE_CALL_ID] - }) - - def _generate_unique_id(self): - """ Generates a unique service call id. """ - self._cur_id += 1 - return "{}-{}".format(id(self), self._cur_id) - - -class Timer(threading.Thread): - """ Timer will sent out an event every TIMER_INTERVAL seconds. """ - - def __init__(self, hass, interval=None): - threading.Thread.__init__(self) - - self.daemon = True - self.hass = hass - self.interval = interval or TIMER_INTERVAL - self._stop_event = threading.Event() - - # We want to be able to fire every time a minute starts (seconds=0). - # We want this so other modules can use that to make sure they fire - # every minute. - assert 60 % self.interval == 0, "60 % TIMER_INTERVAL should be 0!" - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, - lambda event: self.start()) - - def run(self): - """ Start the timer. """ - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - lambda event: self._stop_event.set()) - - _LOGGER.info("Timer:starting") - - last_fired_on_second = -1 - - calc_now = dt.datetime.now - interval = self.interval - - while not self._stop_event.isSet(): - now = calc_now() - - # First check checks if we are not on a second matching the - # timer interval. Second check checks if we did not already fire - # this interval. - if now.second % interval or \ - now.second == last_fired_on_second: - - # Sleep till it is the next time that we have to fire an event. - # Aim for halfway through the second that fits TIMER_INTERVAL. - # If TIMER_INTERVAL is 10 fire at .5, 10.5, 20.5, etc seconds. - # This will yield the best results because time.sleep() is not - # 100% accurate because of non-realtime OS's - slp_seconds = interval - now.second % interval + \ - .5 - now.microsecond/1000000.0 - - time.sleep(slp_seconds) - - now = calc_now() - - last_fired_on_second = now.second - - self.hass.bus.fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}) - - -class Config(object): - """ Configuration settings for Home Assistant. """ - - # pylint: disable=too-many-instance-attributes - def __init__(self): - self.latitude = None - self.longitude = None - self.temperature_unit = None - self.location_name = None - self.time_zone = None - - # List of loaded components - self.components = [] - - # Remote.API object pointing at local API - self.api = None - - # Directory that holds the configuration - self.config_dir = os.path.join(os.getcwd(), 'config') - - def auto_detect(self): - """ Will attempt to detect config of Home Assistant. """ - # Only detect if location or temp unit missing - if None not in (self.latitude, self.longitude, self.temperature_unit): - return - - _LOGGER.info('Auto detecting location and temperature unit') - - try: - info = requests.get('https://freegeoip.net/json/').json() - except requests.RequestException: - return - - if self.latitude is None and self.longitude is None: - self.latitude = info['latitude'] - self.longitude = info['longitude'] - - if self.temperature_unit is None: - # From Wikipedia: - # Fahrenheit is used in the Bahamas, Belize, the Cayman Islands, - # Palau, and the United States and associated territories of - # American Samoa and the U.S. Virgin Islands - if info['country_code'] in ('BS', 'BZ', 'KY', 'PW', - 'US', 'AS', 'VI'): - self.temperature_unit = TEMP_FAHRENHEIT - else: - self.temperature_unit = TEMP_CELCIUS - - if self.location_name is None: - self.location_name = info['city'] - - if self.time_zone is None: - self.time_zone = info['time_zone'] - - def path(self, path): - """ Returns path to the file within the config dir. """ - return os.path.join(self.config_dir, path) - - def temperature(self, value, unit): - """ Converts temperature to user preferred unit if set. """ - if not (unit and self.temperature_unit and - unit != self.temperature_unit): - return value, unit - - try: - if unit == TEMP_CELCIUS: - # Convert C to F - return round(float(value) * 1.8 + 32.0, 1), TEMP_FAHRENHEIT - - # Convert F to C - return round((float(value)-32.0)/1.8, 1), TEMP_CELCIUS - - except ValueError: - # Could not convert value to float - return value, unit - - -class HomeAssistantError(Exception): - """ General Home Assistant exception occured. """ - pass - - -class InvalidEntityFormatError(HomeAssistantError): - """ When an invalid formatted entity is encountered. """ - pass - - -class NoEntitySpecifiedError(HomeAssistantError): - """ When no entity is specified. """ - pass diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 13703dee416..2641961f5c3 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -4,7 +4,10 @@ from __future__ import print_function import sys import os import argparse -import importlib + +from homeassistant import bootstrap +import homeassistant.config as config_util +from homeassistant.const import __version__, EVENT_HOMEASSISTANT_START def validate_python(): @@ -13,93 +16,58 @@ def validate_python(): if major < 3 or (major == 3 and minor < 4): print("Home Assistant requires atleast Python 3.4") - sys.exit() - - -def validate_dependencies(): - """ Validate all dependencies that HA uses. """ - import_fail = False - - for module in ['requests']: - try: - importlib.import_module(module) - except ImportError: - import_fail = True - print( - 'Fatal Error: Unable to find dependency {}'.format(module)) - - if import_fail: - print(("Install dependencies by running: " - "pip3 install -r requirements.txt")) - sys.exit() - - -def ensure_path_and_load_bootstrap(): - """ Ensure sys load path is correct and load Home Assistant bootstrap. """ - try: - from homeassistant import bootstrap - - except ImportError: - # This is to add support to load Home Assistant using - # `python3 homeassistant` instead of `python3 -m homeassistant` - - # Insert the parent directory of this file into the module search path - sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - - from homeassistant import bootstrap - - return bootstrap - - -def validate_git_submodules(): - """ Validate the git submodules are cloned. """ - try: - # pylint: disable=no-name-in-module, unused-variable - from homeassistant.external.noop import WORKING # noqa - except ImportError: - print("Repository submodules have not been initialized") - print("Please run: git submodule update --init --recursive") - sys.exit() + sys.exit(1) def ensure_config_path(config_dir): - """ Gets the path to the configuration file. - Creates one if it not exists. """ + """ Validates configuration directory. """ + + lib_dir = os.path.join(config_dir, 'lib') # Test if configuration directory exists if not os.path.isdir(config_dir): - print(('Fatal Error: Unable to find specified configuration ' - 'directory {} ').format(config_dir)) - sys.exit() + if config_dir != config_util.get_default_config_dir(): + print(('Fatal Error: Specified configuration directory does ' + 'not exist {} ').format(config_dir)) + sys.exit(1) - # Try to use yaml configuration first - config_path = os.path.join(config_dir, 'configuration.yaml') - if not os.path.isfile(config_path): - config_path = os.path.join(config_dir, 'home-assistant.conf') - - # Ensure a config file exists to make first time usage easier - if not os.path.isfile(config_path): - config_path = os.path.join(config_dir, 'configuration.yaml') try: - with open(config_path, 'w') as conf: - conf.write("frontend:\n\n") - conf.write("discovery:\n\n") - conf.write("history:\n\n") - except IOError: - print(('Fatal Error: No configuration file found and unable ' - 'to write a default one to {}').format(config_path)) - sys.exit() + os.mkdir(config_dir) + except OSError: + print(('Fatal Error: Unable to create default configuration ' + 'directory {} ').format(config_dir)) + sys.exit(1) + + # Test if library directory exists + if not os.path.isdir(lib_dir): + try: + os.mkdir(lib_dir) + except OSError: + print(('Fatal Error: Unable to create library ' + 'directory {} ').format(lib_dir)) + sys.exit(1) + + +def ensure_config_file(config_dir): + """ Ensure configuration file exists. """ + config_path = config_util.ensure_config_exists(config_dir) + + if config_path is None: + print('Error getting configuration path') + sys.exit(1) return config_path def get_arguments(): """ Get parsed passed in arguments. """ - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser( + description="Home Assistant: Observe, Control, Automate.") + parser.add_argument('--version', action='version', version=__version__) parser.add_argument( '-c', '--config', metavar='path_to_config_dir', - default="config", + default=config_util.get_default_config_dir(), help="Directory that contains the Home Assistant configuration") parser.add_argument( '--demo-mode', @@ -109,43 +77,120 @@ def get_arguments(): '--open-ui', action='store_true', help='Open the webinterface in a browser') + parser.add_argument( + '--skip-pip', + action='store_true', + help='Skips pip install of required packages on startup') + parser.add_argument( + '-v', '--verbose', + action='store_true', + help="Enable verbose logging to file.") + parser.add_argument( + '--pid-file', + metavar='path_to_pid_file', + default=None, + help='Path to PID file useful for running as daemon') + parser.add_argument( + '--log-rotate-days', + type=int, + default=None, + help='Enables daily log rotation and keeps up to the specified days') + if os.name != "nt": + parser.add_argument( + '--daemon', + action='store_true', + help='Run Home Assistant as daemon') - return parser.parse_args() + arguments = parser.parse_args() + if os.name == "nt": + arguments.daemon = False + return arguments + + +def daemonize(): + """ Move current process to daemon process """ + # create first fork + pid = os.fork() + if pid > 0: + sys.exit(0) + + # decouple fork + os.setsid() + os.umask(0) + + # create second fork + pid = os.fork() + if pid > 0: + sys.exit(0) + + +def check_pid(pid_file): + """ Check that HA is not already running """ + # check pid file + try: + pid = int(open(pid_file, 'r').readline()) + except IOError: + # PID File does not exist + return + + try: + os.kill(pid, 0) + except OSError: + # PID does not exist + return + print('Fatal Error: HomeAssistant is already running.') + sys.exit(1) + + +def write_pid(pid_file): + """ Create PID File """ + pid = os.getpid() + try: + open(pid_file, 'w').write(str(pid)) + except IOError: + print('Fatal Error: Unable to write pid file {}'.format(pid_file)) + sys.exit(1) def main(): """ Starts Home Assistant. """ validate_python() - validate_dependencies() - - bootstrap = ensure_path_and_load_bootstrap() - - validate_git_submodules() args = get_arguments() config_dir = os.path.join(os.getcwd(), args.config) - config_path = ensure_config_path(config_dir) + ensure_config_path(config_dir) + + # daemon functions + if args.pid_file: + check_pid(args.pid_file) + if args.daemon: + daemonize() + if args.pid_file: + write_pid(args.pid_file) if args.demo_mode: - from homeassistant.components import http, demo - - # Demo mode only requires http and demo components. - hass = bootstrap.from_config_dict({ - http.DOMAIN: {}, - demo.DOMAIN: {} - }) + config = { + 'frontend': {}, + 'demo': {} + } + hass = bootstrap.from_config_dict( + config, config_dir=config_dir, daemon=args.daemon, + verbose=args.verbose, skip_pip=args.skip_pip, + log_rotate_days=args.log_rotate_days) else: - hass = bootstrap.from_config_file(config_path) + config_file = ensure_config_file(config_dir) + print('Config directory:', config_dir) + hass = bootstrap.from_config_file( + config_file, daemon=args.daemon, verbose=args.verbose, + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) if args.open_ui: - from homeassistant.const import EVENT_HOMEASSISTANT_START - def open_browser(event): """ Open the webinterface in a browser. """ - if hass.local_api is not None: + if hass.config.api is not None: import webbrowser - webbrowser.open(hass.local_api.base_url) + webbrowser.open(hass.config.api.base_url) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4a6aab53483..ca74f086632 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -10,24 +10,30 @@ start by calling homeassistant.start_home_assistant(bus) """ import os -import configparser -import yaml -import io +import sys import logging +import logging.handlers from collections import defaultdict -import homeassistant +import homeassistant.core as core +import homeassistant.util.dt as date_util +import homeassistant.util.package as pkg_util +import homeassistant.util.location as loc_util +import homeassistant.config as config_util import homeassistant.loader as loader 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, - CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE, TEMP_CELCIUS, - TEMP_FAHRENHEIT) + CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE, CONF_CUSTOMIZE, + TEMP_CELCIUS, TEMP_FAHRENHEIT) _LOGGER = logging.getLogger(__name__) -ATTR_COMPONENT = "component" +ATTR_COMPONENT = 'component' + +PLATFORM_FORMAT = '{}.{}' def setup_component(hass, domain, config=None): @@ -48,17 +54,30 @@ def setup_component(hass, domain, config=None): return False for component in components: - if component in hass.config.components: - continue - if not _setup_component(hass, component, config): return False return True +def _handle_requirements(hass, component, name): + """ Installs requirements for component. """ + if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'): + return True + + for req in component.REQUIREMENTS: + if not pkg_util.install_package(req, target=hass.config.path('lib')): + _LOGGER.error('Not initializing %s because could not install ' + 'dependency %s', name, req) + return False + + return True + + def _setup_component(hass, domain, config): """ Setup a component for Home Assistant. """ + if domain in hass.config.components: + return True component = loader.get_component(domain) missing_deps = [dep for dep in component.DEPENDENCIES @@ -66,47 +85,96 @@ def _setup_component(hass, domain, config): if missing_deps: _LOGGER.error( - "Not initializing %s because not all dependencies loaded: %s", + 'Not initializing %s because not all dependencies loaded: %s', domain, ", ".join(missing_deps)) + return False + if not _handle_requirements(hass, component, domain): return False try: - if component.setup(hass, config): - hass.config.components.append(component.DOMAIN) - - # Assumption: if a component does not depend on groups - # it communicates with devices - if group.DOMAIN not in component.DEPENDENCIES: - hass.pool.add_worker() - - hass.bus.fire( - EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN}) - - return True - - else: - _LOGGER.error("component %s failed to initialize", domain) - + if not component.setup(hass, config): + _LOGGER.error('component %s failed to initialize', domain) + return False except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error during setup of component %s", domain) + _LOGGER.exception('Error during setup of component %s', domain) + return False - return False + hass.config.components.append(component.DOMAIN) + + # Assumption: if a component does not depend on groups + # it communicates with devices + if group.DOMAIN not in component.DEPENDENCIES: + hass.pool.add_worker() + + hass.bus.fire( + EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN}) + + return True -# pylint: disable=too-many-branches, too-many-statements -def from_config_dict(config, hass=None): +def prepare_setup_platform(hass, config, domain, platform_name): + """ Loads a platform and makes sure dependencies are setup. """ + _ensure_loader_prepared(hass) + + platform_path = PLATFORM_FORMAT.format(domain, platform_name) + + platform = loader.get_component(platform_path) + + # Not found + if platform is None: + return None + + # Already loaded + elif platform_path in hass.config.components: + 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 + + if not _handle_requirements(hass, platform, platform_path): + return None + + return platform + + +def mount_local_lib_path(config_dir): + """ Add local library to Python Path """ + sys.path.insert(0, os.path.join(config_dir, 'lib')) + + +# pylint: disable=too-many-branches, too-many-statements, too-many-arguments +def from_config_dict(config, hass=None, config_dir=None, enable_log=True, + verbose=False, daemon=False, skip_pip=False, + log_rotate_days=None): """ Tries to configure Home Assistant from a config dict. Dynamically loads required components and its dependencies. """ if hass is None: - hass = homeassistant.HomeAssistant() + hass = core.HomeAssistant() + if config_dir is not None: + config_dir = os.path.abspath(config_dir) + hass.config.config_dir = config_dir + mount_local_lib_path(config_dir) - process_ha_core_config(hass, config.get(homeassistant.DOMAIN, {})) + process_ha_core_config(hass, config.get(core.DOMAIN, {})) - enable_logging(hass) + if enable_log: + enable_logging(hass, verbose, daemon, log_rotate_days) + + hass.config.skip_pip = skip_pip + if skip_pip: + _LOGGER.warning('Skipping pip installation of required modules. ' + 'This may cause issues.') _ensure_loader_prepared(hass) @@ -118,15 +186,15 @@ def from_config_dict(config, hass=None): # Filter out the repeating and common config section [homeassistant] components = (key for key in config.keys() - if ' ' not in key and key != homeassistant.DOMAIN) + if ' ' not in key and key != core.DOMAIN) if not core_components.setup(hass, config): - _LOGGER.error("Home Assistant core failed to initialize. " - "Further initialization aborted.") + _LOGGER.error('Home Assistant core failed to initialize. ' + 'Further initialization aborted.') return hass - _LOGGER.info("Home Assistant core initialized") + _LOGGER.info('Home Assistant core initialized') # Setup the components for domain in loader.load_order_components(components): @@ -135,48 +203,55 @@ def from_config_dict(config, hass=None): return hass -def from_config_file(config_path, hass=None): +def from_config_file(config_path, hass=None, verbose=False, daemon=False, + skip_pip=True, log_rotate_days=None): """ Reads the configuration file and tries to start all the required functionality. Will add functionality to 'hass' parameter if given, instantiates a new Home Assistant object if 'hass' is not given. """ if hass is None: - hass = homeassistant.HomeAssistant() + hass = core.HomeAssistant() # Set config dir to directory holding config file - hass.config.config_dir = os.path.abspath(os.path.dirname(config_path)) + config_dir = os.path.abspath(os.path.dirname(config_path)) + hass.config.config_dir = config_dir + mount_local_lib_path(config_dir) - config_dict = {} - # check config file type - if os.path.splitext(config_path)[1] == '.yaml': - # Read yaml - config_dict = yaml.load(io.open(config_path, 'r')) + enable_logging(hass, verbose, daemon, log_rotate_days) - # If YAML file was empty - if config_dict is None: - config_dict = {} + config_dict = config_util.load_config_file(config_path) - else: - # Read config - config = configparser.ConfigParser() - config.read(config_path) - - for section in config.sections(): - config_dict[section] = {} - - for key, val in config.items(section): - config_dict[section][key] = val - - return from_config_dict(config_dict, hass) + return from_config_dict(config_dict, hass, enable_log=False, + skip_pip=skip_pip) -def enable_logging(hass): +def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None): """ Setup the logging for home assistant. """ - logging.basicConfig(level=logging.INFO) + if not daemon: + logging.basicConfig(level=logging.INFO) + fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) " + "[%(name)s] %(message)s%(reset)s") + try: + from colorlog import ColoredFormatter + logging.getLogger().handlers[0].setFormatter(ColoredFormatter( + fmt, + datefmt='%y-%m-%d %H:%M:%S', + reset=True, + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red', + } + )) + except ImportError: + _LOGGER.warning( + "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('home-assistant.log') err_path_exists = os.path.isfile(err_log_path) # Check if we can write to the error log if it exists or that @@ -184,38 +259,95 @@ def enable_logging(hass): if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): - err_handler = logging.FileHandler( - err_log_path, mode='w', delay=True) + if log_rotate_days: + err_handler = logging.handlers.TimedRotatingFileHandler( + err_log_path, when='midnight', backupCount=log_rotate_days) + else: + err_handler = logging.FileHandler( + err_log_path, mode='w', delay=True) - err_handler.setLevel(logging.WARNING) + err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter( logging.Formatter('%(asctime)s %(name)s: %(message)s', - datefmt='%H:%M %d-%m-%y')) - logging.getLogger('').addHandler(err_handler) + datefmt='%y-%m-%d %H:%M:%S')) + logger = logging.getLogger('') + logger.addHandler(err_handler) + logger.setLevel(logging.INFO) # this sets the minimum log level else: _LOGGER.error( - "Unable to setup error log %s (access denied)", err_log_path) + 'Unable to setup error log %s (access denied)', err_log_path) def process_ha_core_config(hass, config): """ Processes the [homeassistant] section from the config. """ + hac = hass.config + + def set_time_zone(time_zone_str): + """ Helper method to set time zone in HA. """ + if time_zone_str is None: + return + + time_zone = date_util.get_time_zone(time_zone_str) + + if time_zone: + hac.time_zone = time_zone + date_util.set_default_time_zone(time_zone) + else: + _LOGGER.error('Received invalid time zone %s', time_zone_str) + for key, attr in ((CONF_LATITUDE, 'latitude'), (CONF_LONGITUDE, 'longitude'), - (CONF_NAME, 'location_name'), - (CONF_TIME_ZONE, 'time_zone')): + (CONF_NAME, 'location_name')): if key in config: - setattr(hass.config, attr, config[key]) + setattr(hac, attr, config[key]) + + set_time_zone(config.get(CONF_TIME_ZONE)) + + customize = config.get(CONF_CUSTOMIZE) + + if isinstance(customize, dict): + for entity_id, attrs in config.get(CONF_CUSTOMIZE, {}).items(): + if not isinstance(attrs, dict): + continue + Entity.overwrite_attribute(entity_id, attrs.keys(), attrs.values()) if CONF_TEMPERATURE_UNIT in config: unit = config[CONF_TEMPERATURE_UNIT] if unit == 'C': - hass.config.temperature_unit = TEMP_CELCIUS + hac.temperature_unit = TEMP_CELCIUS elif unit == 'F': - hass.config.temperature_unit = TEMP_FAHRENHEIT + hac.temperature_unit = TEMP_FAHRENHEIT - hass.config.auto_detect() + # If we miss some of the needed values, auto detect them + if None not in ( + hac.latitude, hac.longitude, hac.temperature_unit, hac.time_zone): + return + + _LOGGER.info('Auto detecting location and temperature unit') + + info = loc_util.detect_location_info() + + if info is None: + _LOGGER.error('Could not detect location information') + return + + if hac.latitude is None and hac.longitude is None: + hac.latitude = info.latitude + hac.longitude = info.longitude + + if hac.temperature_unit is None: + if info.use_fahrenheit: + hac.temperature_unit = TEMP_FAHRENHEIT + else: + hac.temperature_unit = TEMP_CELCIUS + + if hac.location_name is None: + hac.location_name = info.city + + if hac.time_zone is None: + set_time_zone(info.time_zone) def _ensure_loader_prepared(hass): diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 0b757766bc0..e5e917c5250 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -17,7 +17,7 @@ Each component should publish services only under its own domain. import itertools as it import logging -import homeassistant as ha +import homeassistant.core as ha import homeassistant.util as util from homeassistant.helpers import extract_entity_ids from homeassistant.loader import get_component diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index b5cdb9cae6c..108cc88741b 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -9,12 +9,13 @@ import logging import threading import json -import homeassistant as ha +import homeassistant.core as ha from homeassistant.helpers.state import TrackStates import homeassistant.remote as rem 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, HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_UNPROCESSABLE_ENTITY) @@ -42,6 +43,13 @@ def setup(hass, config): # /api/stream hass.http.register_path('GET', URL_API_STREAM, _handle_get_api_stream) + # /api/config + hass.http.register_path('GET', URL_API_CONFIG, _handle_get_api_config) + + # /api/bootstrap + hass.http.register_path( + 'GET', URL_API_BOOTSTRAP, _handle_get_api_bootstrap) + # /states hass.http.register_path('GET', URL_API_STATES, _handle_get_api_states) hass.http.register_path( @@ -140,6 +148,23 @@ def _handle_get_api_stream(handler, path_match, data): hass.bus.remove_listener(MATCH_ALL, forward_events) +def _handle_get_api_config(handler, path_match, data): + """ Returns the Home Assistant config. """ + handler.write_json(handler.server.hass.config.as_dict()) + + +def _handle_get_api_bootstrap(handler, path_match, data): + """ Returns all data needed to bootstrap Home Assistant. """ + hass = handler.server.hass + + handler.write_json({ + 'config': hass.config.as_dict(), + 'states': hass.states.all(), + 'events': _events_json(hass), + 'services': _services_json(hass), + }) + + def _handle_get_api_states(handler, path_match, data): """ Returns a dict containing all entity ids and their state. """ handler.write_json(handler.server.hass.states.all()) @@ -190,9 +215,7 @@ def _handle_post_state_entity(handler, path_match, data): def _handle_get_api_events(handler, path_match, data): """ Handles getting overview of event listeners. """ - handler.write_json([{"event": key, "listener_count": value} - for key, value - in handler.server.hass.bus.listeners.items()]) + handler.write_json(_events_json(handler.server.hass)) def _handle_api_post_events_event(handler, path_match, event_data): @@ -227,10 +250,7 @@ def _handle_api_post_events_event(handler, path_match, event_data): def _handle_get_api_services(handler, path_match, data): """ Handles getting overview of services. """ - handler.write_json( - [{"domain": key, "services": value} - for key, value - in handler.server.hass.services.services.items()]) + handler.write_json(_services_json(handler.server.hass)) # pylint: disable=invalid-name @@ -312,3 +332,15 @@ def _handle_get_api_components(handler, path_match, data): """ Returns all the loaded components. """ handler.write_json(handler.server.hass.config.components) + + +def _services_json(hass): + """ Generate services data to JSONify. """ + return [{"domain": key, "services": value} + for key, value in hass.services.services.items()] + + +def _events_json(hass): + """ Generate event data to JSONify. """ + return [{"event": key, "listener_count": value} + for key, value in hass.bus.listeners.items()] diff --git a/homeassistant/components/arduino.py b/homeassistant/components/arduino.py new file mode 100644 index 00000000000..cbb319e2541 --- /dev/null +++ b/homeassistant/components/arduino.py @@ -0,0 +1,143 @@ +""" +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. +""" +import logging + +try: + from PyMata.pymata import PyMata +except ImportError: + PyMata = None + +from homeassistant.helpers import validate_config +from homeassistant.const import (EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) + +DOMAIN = "arduino" +DEPENDENCIES = [] +REQUIREMENTS = ['PyMata==2.07a'] +BOARD = None +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ Setup the Arduino component. """ + + global PyMata # pylint: disable=invalid-name + if PyMata is None: + from PyMata.pymata import PyMata as PyMata_ + PyMata = PyMata_ + + import serial + + if not validate_config(config, + {DOMAIN: ['port']}, + _LOGGER): + return False + + global BOARD + try: + BOARD = ArduinoBoard(config[DOMAIN]['port']) + except (serial.serialutil.SerialException, FileNotFoundError): + _LOGGER.exception("Your port is not accessible.") + return False + + if BOARD.get_firmata()[1] <= 2: + _LOGGER.error("The StandardFirmata sketch should be 2.2 or newer.") + return False + + def stop_arduino(event): + """ Stop the Arduino service. """ + BOARD.disconnect() + + def start_arduino(event): + """ Start the Arduino service. """ + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino) + + return True + + +class ArduinoBoard(object): + """ Represents an Arduino board. """ + + def __init__(self, port): + self._port = port + self._board = PyMata(self._port, verbose=False) + + def set_mode(self, pin, direction, mode): + """ Sets the mode and the direction of a given pin. """ + if mode == 'analog' and direction == 'in': + self._board.set_pin_mode(pin, + self._board.INPUT, + self._board.ANALOG) + elif mode == 'analog' and direction == 'out': + self._board.set_pin_mode(pin, + self._board.OUTPUT, + self._board.ANALOG) + elif mode == 'digital' and direction == 'in': + self._board.set_pin_mode(pin, + self._board.OUTPUT, + self._board.DIGITAL) + elif mode == 'digital' and direction == 'out': + self._board.set_pin_mode(pin, + self._board.OUTPUT, + self._board.DIGITAL) + elif mode == 'pwm': + self._board.set_pin_mode(pin, + self._board.OUTPUT, + self._board.PWM) + + def get_analog_inputs(self): + """ Get the values from the pins. """ + self._board.capability_query() + return self._board.get_analog_response_table() + + def set_digital_out_high(self, pin): + """ Sets a given digital pin to high. """ + self._board.digital_write(pin, 1) + + def set_digital_out_low(self, pin): + """ Sets a given digital pin to low. """ + self._board.digital_write(pin, 0) + + def get_digital_in(self, pin): + """ Gets the value from a given digital pin. """ + self._board.digital_read(pin) + + def get_analog_in(self, pin): + """ Gets the value from a given analog pin. """ + self._board.analog_read(pin) + + def get_firmata(self): + """ Return the version of the Firmata firmware. """ + return self._board.get_firmata_version() + + def disconnect(self): + """ Disconnects the board and closes the serial connection. """ + self._board.reset() + self._board.close() diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 21bea96201b..8dcb158dea4 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -6,7 +6,7 @@ Allows to setup simple automation rules via the config file. """ import logging -from homeassistant.loader import get_component +from homeassistant.bootstrap import prepare_setup_platform from homeassistant.helpers import config_per_platform from homeassistant.util import split_entity_id from homeassistant.const import ATTR_ENTITY_ID @@ -25,9 +25,10 @@ _LOGGER = logging.getLogger(__name__) def setup(hass, config): """ Sets up automation. """ + success = False for p_type, p_config in config_per_platform(config, DOMAIN, _LOGGER): - platform = get_component('automation.{}'.format(p_type)) + platform = prepare_setup_platform(hass, config, DOMAIN, p_type) if platform is None: _LOGGER.error("Unknown automation platform specified: %s", p_type) @@ -36,11 +37,12 @@ def setup(hass, config): if platform.register(hass, p_config, _get_action(hass, p_config)): _LOGGER.info( "Initialized %s rule %s", p_type, p_config.get(CONF_ALIAS, "")) + success = True else: _LOGGER.error( "Error setting up rule %s", p_config.get(CONF_ALIAS, "")) - return True + return success def _get_action(hass, config): @@ -56,13 +58,16 @@ def _get_action(hass, config): service_data = config.get(CONF_SERVICE_DATA, {}) if not isinstance(service_data, dict): - _LOGGER.error( - "%s should be a serialized JSON object", CONF_SERVICE_DATA) + _LOGGER.error("%s should be a dictionary", CONF_SERVICE_DATA) service_data = {} if CONF_SERVICE_ENTITY_ID in config: - service_data[ATTR_ENTITY_ID] = \ - config[CONF_SERVICE_ENTITY_ID].split(",") + try: + service_data[ATTR_ENTITY_ID] = \ + config[CONF_SERVICE_ENTITY_ID].split(",") + except AttributeError: + service_data[ATTR_ENTITY_ID] = \ + config[CONF_SERVICE_ENTITY_ID] hass.services.call(domain, service, service_data) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py new file mode 100644 index 00000000000..6b4e6b1e039 --- /dev/null +++ b/homeassistant/components/automation/mqtt.py @@ -0,0 +1,34 @@ +""" +homeassistant.components.automation.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers MQTT listening automation rules. +""" +import logging + +import homeassistant.components.mqtt as mqtt + +DEPENDENCIES = ['mqtt'] + +CONF_TOPIC = 'mqtt_topic' +CONF_PAYLOAD = 'mqtt_payload' + + +def register(hass, config, action): + """ Listen for state changes based on `config`. """ + topic = config.get(CONF_TOPIC) + payload = config.get(CONF_PAYLOAD) + + if topic is None: + logging.getLogger(__name__).error( + "Missing configuration key %s", CONF_TOPIC) + return False + + def mqtt_automation_listener(msg_topic, msg_payload, qos): + """ Listens for MQTT messages. """ + if payload is None or payload == msg_payload: + action() + + mqtt.subscribe(hass, topic, mqtt_automation_listener) + + return True diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index c8adfe95bbe..ba96debf9ac 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -6,6 +6,7 @@ Offers state listening automation rules. """ import logging +from homeassistant.helpers.event import track_state_change from homeassistant.const import MATCH_ALL @@ -30,7 +31,7 @@ def register(hass, config, action): """ Listens for state changes and calls action. """ action() - hass.states.track_change( - entity_id, state_automation_listener, from_state, to_state) + track_state_change( + hass, entity_id, state_automation_listener, from_state, to_state) return True diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 7e38960534d..77bd40a7a41 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -5,6 +5,7 @@ homeassistant.components.automation.time Offers time listening automation rules. """ from homeassistant.util import convert +from homeassistant.helpers.event import track_time_change CONF_HOURS = "time_hours" CONF_MINUTES = "time_minutes" @@ -21,8 +22,7 @@ def register(hass, config, action): """ Listens for time changes and calls action. """ action() - hass.track_time_change( - time_automation_listener, - hour=hours, minute=minutes, second=seconds) + track_time_change(hass, time_automation_listener, + hour=hours, minute=minutes, second=seconds) return True diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py new file mode 100644 index 00000000000..e34d4169fa2 --- /dev/null +++ b/homeassistant/components/camera/__init__.py @@ -0,0 +1,229 @@ +# pylint: disable=too-many-lines +""" +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 +""" +import requests +import logging +import time +import re +from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + HTTP_NOT_FOUND, + ATTR_ENTITY_ID, + ) + +from homeassistant.helpers.entity_component import EntityComponent + + +DOMAIN = 'camera' +DEPENDENCIES = ['http'] +GROUP_NAME_ALL_CAMERAS = 'all_cameras' +SCAN_INTERVAL = 30 +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SWITCH_ACTION_RECORD = 'record' +SWITCH_ACTION_SNAPSHOT = 'snapshot' + +SERVICE_CAMERA = 'camera_service' + +STATE_RECORDING = 'recording' + +DEFAULT_RECORDING_SECONDS = 30 + +# Maps discovered services to their platforms +DISCOVERY_PLATFORMS = {} + +FILE_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S-%f' +DIR_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S' + +REC_DIR_PREFIX = 'recording-' +REC_IMG_PREFIX = 'recording_image-' + +STATE_STREAMING = 'streaming' +STATE_IDLE = 'idle' + +CAMERA_PROXY_URL = '/api/camera_proxy_stream/{0}' +CAMERA_STILL_URL = '/api/camera_proxy/{0}' +ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?time={1}' + +MULTIPART_BOUNDARY = '--jpegboundary' +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. """ + + component = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, + DISCOVERY_PLATFORMS) + + component.setup(config) + + # ------------------------------------------------------------------------- + # CAMERA COMPONENT ENDPOINTS + # ------------------------------------------------------------------------- + # The following defines the endpoints for serving images from the camera + # via the HA http server. This is means that you can access images from + # your camera outside of your LAN without the need for port forwards etc. + + # Because the authentication header can't be added in image requests these + # endpoints are secured with session based security. + + # pylint: disable=unused-argument + 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 = None + if entity_id in component.entities.keys(): + camera = component.entities[entity_id] + + if camera: + response = camera.camera_image() + handler.wfile.write(response) + else: + handler.send_response(HTTP_NOT_FOUND) + + hass.http.register_path( + 'GET', + re.compile(r'/api/camera_proxy/(?P[a-zA-Z\._0-9]+)'), + _proxy_camera_image) + + # 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. + 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 = None + if entity_id in component.entities.keys(): + camera = component.entities[entity_id] + + if not camera: + handler.send_response(HTTP_NOT_FOUND) + handler.end_headers() + return + + try: + camera.is_streaming = True + camera.update_ha_state() + + handler.request.sendall(bytes('HTTP/1.1 200 OK\r\n', 'utf-8')) + handler.request.sendall(bytes( + 'Content-type: multipart/x-mixed-replace; \ + boundary=--jpgboundary\r\n\r\n', 'utf-8')) + handler.request.sendall(bytes('--jpgboundary\r\n', 'utf-8')) + + # MJPEG_START_HEADER.format() + + while True: + + img_bytes = camera.camera_image() + + headers_str = '\r\n'.join(( + 'Content-length: {}'.format(len(img_bytes)), + 'Content-type: image/jpeg', + )) + '\r\n\r\n' + + handler.request.sendall( + bytes(headers_str, 'utf-8') + + img_bytes + + bytes('\r\n', 'utf-8')) + + handler.request.sendall( + bytes('--jpgboundary\r\n', 'utf-8')) + + except (requests.RequestException, IOError): + camera.is_streaming = False + camera.update_ha_state() + + camera.is_streaming = False + + hass.http.register_path( + 'GET', + re.compile( + r'/api/camera_proxy_stream/(?P[a-zA-Z\._0-9]+)'), + _proxy_camera_mjpeg_stream) + + return True + + +class Camera(Entity): + """ The base class for camera components """ + + def __init__(self): + self.is_streaming = False + + @property + # pylint: disable=no-self-use + def is_recording(self): + """ 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 """ + return None + + @property + # pylint: disable=no-self-use + def model(self): + """ Returns string of camera model """ + return None + + def camera_image(self): + """ Return bytes of camera image """ + raise NotImplementedError() + + @property + def state(self): + """ Returns the state of the entity. """ + if self.is_recording: + return STATE_RECORDING + elif self.is_streaming: + return STATE_STREAMING + else: + return STATE_IDLE + + @property + def state_attributes(self): + """ Returns optional state attributes. """ + attr = { + ATTR_ENTITY_PICTURE: ENTITY_IMAGE_URL.format( + self.entity_id, time.time()), + } + + if self.model: + attr['model_name'] = self.model + + if self.brand: + attr['brand'] = self.brand + + return attr diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py new file mode 100644 index 00000000000..7e4a24ffdfe --- /dev/null +++ b/homeassistant/components/camera/generic.py @@ -0,0 +1,91 @@ +""" +homeassistant.components.camera.generic +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for IP Cameras. + +This component provides basic support for IP cameras. For the basic support to +work you camera must support accessing a JPEG snapshot via a URL and you will +need to specify the "still_image_url" parameter which should be the location of +the JPEG image. + +As part of the basic support the following features will be provided: +- MJPEG video streaming +- Saving a snapshot +- Recording(JPEG frame capture) + +To use this component, add the following to your configuration.yaml file. + +camera: + platform: generic + name: Door Camera + username: YOUR_USERNAME + password: YOUR_PASSWORD + still_image_url: http://YOUR_CAMERA_IP_AND_PORT/image.jpg + +Variables: + +still_image_url +*Required +The URL your camera serves the image on, eg. http://192.168.1.21:2112/ + +name +*Optional +This parameter allows you to override the name of your camera in Home +Assistant. + +username +*Optional +The username for accessing your camera. + +password +*Optional +The password for accessing your camera. +""" +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 + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Adds a generic IP Camera. """ + if not validate_config({DOMAIN: config}, {DOMAIN: ['still_image_url']}, + _LOGGER): + return None + + add_devices_callback([GenericCamera(config)]) + + +# pylint: disable=too-many-instance-attributes +class GenericCamera(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', 'Generic Camera') + self._username = device_info.get('username') + self._password = device_info.get('password') + self._still_image_url = device_info['still_image_url'] + + def camera_image(self): + """ Return a still image reponse from the camera. """ + if self._username and self._password: + response = requests.get( + self._still_image_url, + auth=HTTPBasicAuth(self._username, self._password)) + else: + response = requests.get(self._still_image_url) + + return response.content + + @property + def name(self): + """ Return the name of this device. """ + return self._name diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index bf78e13a094..fd2ad60d211 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -8,9 +8,9 @@ This is more a proof of concept. import logging import re -import homeassistant +from homeassistant import core from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) + ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) DOMAIN = "conversation" DEPENDENCIES = [] @@ -44,7 +44,7 @@ def setup(hass, config): entity_ids = [ state.entity_id for state in hass.states.all() - if state.attributes.get(ATTR_FRIENDLY_NAME, "").lower() == name] + if state.name.lower() == name] if not entity_ids: logger.error( @@ -52,16 +52,14 @@ def setup(hass, config): return if command == 'on': - hass.services.call( - homeassistant.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) + hass.services.call(core.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity_ids, + }, blocking=True) elif command == 'off': - hass.services.call( - homeassistant.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) + hass.services.call(core.DOMAIN, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity_ids, + }, blocking=True) else: logger.error( diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 48d8476759f..5dc91b28370 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -1,20 +1,20 @@ """ homeassistant.components.demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Sets up a demo environment that mimics interaction with devices +Sets up a demo environment that mimics interaction with devices. """ import time -import homeassistant as ha +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) + CONF_PLATFORM, ATTR_ENTITY_PICTURE, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME) DOMAIN = "demo" -DEPENDENCIES = [] +DEPENDENCIES = ['introduction', 'conversation'] COMPONENTS_WITH_DEMO_PLATFORM = [ 'switch', 'light', 'thermostat', 'sensor', 'media_player', 'notify'] @@ -32,7 +32,13 @@ def setup(hass, config): hass.states.set('a.Demo_Mode', 'Enabled') # Setup sun - loader.get_component('sun').setup(hass, config) + if not hass.config.latitude: + hass.config.latitude = '32.87336' + + if not hass.config.longitude: + hass.config.longitude = '117.22743' + + bootstrap.setup_component(hass, 'sun') # Setup demo platforms for component in COMPONENTS_WITH_DEMO_PLATFORM: @@ -40,17 +46,29 @@ def setup(hass, config): hass, component, {component: {CONF_PLATFORM: 'demo'}}) # Setup room groups - lights = hass.states.entity_ids('light') - switches = hass.states.entity_ids('switch') - group.setup_group(hass, 'living room', [lights[0], lights[1], switches[0]]) - group.setup_group(hass, 'bedroom', [lights[2], switches[1]]) + lights = sorted(hass.states.entity_ids('light')) + switches = sorted(hass.states.entity_ids('switch')) + media_players = sorted(hass.states.entity_ids('media_player')) + group.setup_group(hass, 'living room', [lights[2], lights[1], switches[0], + media_players[1]]) + 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://194.218.96.92/jpg/image.jpg', + }}) # Setup scripts bootstrap.setup_component( hass, 'script', {'script': { 'demo': { - 'alias': 'Demo {}'.format(lights[0]), + 'alias': 'Toggle {}'.format(lights[0].split('.')[1]), 'sequence': [{ 'execute_service': 'light.turn_off', 'service_data': {ATTR_ENTITY_ID: lights[0]} @@ -87,17 +105,17 @@ def setup(hass, config): # Setup fake device tracker hass.states.set("device_tracker.paulus", "home", {ATTR_ENTITY_PICTURE: - "http://graph.facebook.com/schoutsen/picture"}) + "http://graph.facebook.com/297400035/picture", + ATTR_FRIENDLY_NAME: 'Paulus'}) hass.states.set("device_tracker.anne_therese", "not_home", - {ATTR_ENTITY_PICTURE: - "http://graph.facebook.com/anne.t.frederiksen/picture"}) + {ATTR_FRIENDLY_NAME: 'Anne Therese'}) hass.states.set("group.all_devices", "home", { "auto": True, ATTR_ENTITY_ID: [ - "device_tracker.Paulus", - "device_tracker.Anne_Therese" + "device_tracker.paulus", + "device_tracker.anne_therese" ] }) diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index 4740336767f..67da9e26a82 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -6,8 +6,10 @@ Provides functionality to turn on lights based on the state of the sun and devices. """ import logging -from datetime import datetime, timedelta +from datetime import timedelta +from homeassistant.helpers.event import track_point_in_time, track_state_change +import homeassistant.util.dt as dt_util from homeassistant.const import STATE_HOME, STATE_NOT_HOME from . import light, sun, device_tracker, group @@ -90,14 +92,14 @@ def setup(hass, config): if start_point: for index, light_id in enumerate(light_ids): - hass.track_point_in_time(turn_on(light_id), - (start_point + - index * LIGHT_TRANSITION_TIME)) + track_point_in_time( + hass, turn_on(light_id), + (start_point + index * LIGHT_TRANSITION_TIME)) # Track every time sun rises so we can schedule a time-based # pre-sun set event - hass.states.track_change(sun.ENTITY_ID, schedule_light_on_sun_rise, - sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON) + track_state_change(hass, sun.ENTITY_ID, schedule_light_on_sun_rise, + sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON) # If the sun is already above horizon # schedule the time-based pre-sun set event @@ -115,7 +117,7 @@ def setup(hass, config): new_state.state == STATE_HOME: # These variables are needed for the elif check - now = datetime.now() + now = dt_util.now() start_point = calc_time_for_light_when_sunset() # Do we need lights? @@ -156,13 +158,13 @@ def setup(hass, config): light.turn_off(hass, light_ids) # Track home coming of each device - hass.states.track_change( - device_entity_ids, check_light_on_dev_state_change, + track_state_change( + hass, device_entity_ids, check_light_on_dev_state_change, STATE_NOT_HOME, STATE_HOME) # Track when all devices are gone to shut down lights - hass.states.track_change( - device_group, check_light_on_dev_state_change, + track_state_change( + hass, device_group, check_light_on_dev_state_change, STATE_HOME, STATE_NOT_HOME) return True diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 6d4db7ad7ed..fd706b3d73a 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -8,15 +8,18 @@ import logging import threading import os import csv -from datetime import datetime, timedelta +from datetime import timedelta -from homeassistant.loader import get_component from homeassistant.helpers import validate_config +from homeassistant.helpers.entity import _OVERWRITE import homeassistant.util as util +import homeassistant.util.dt as dt_util +from homeassistant.bootstrap import prepare_setup_platform +from homeassistant.helpers.event import track_utc_time_change from homeassistant.const import ( STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, - CONF_PLATFORM) + CONF_PLATFORM, DEVICE_DEFAULT_NAME) from homeassistant.components import group DOMAIN = "device_tracker" @@ -40,6 +43,8 @@ CONF_SECONDS = "interval_seconds" DEFAULT_CONF_SECONDS = 12 +TRACK_NEW_DEVICES = "track_new_devices" + _LOGGER = logging.getLogger(__name__) @@ -58,18 +63,19 @@ def setup(hass, config): tracker_type = config[DOMAIN].get(CONF_PLATFORM) - tracker_implementation = get_component( - 'device_tracker.{}'.format(tracker_type)) + tracker_implementation = \ + prepare_setup_platform(hass, config, DOMAIN, tracker_type) if tracker_implementation is None: - _LOGGER.error("Unknown device_tracker type specified.") + _LOGGER.error("Unknown device_tracker type specified: %s.", + tracker_type) return False device_scanner = tracker_implementation.get_scanner(hass, config) if device_scanner is None: - _LOGGER.error("Failed to initialize device scanner for %s", + _LOGGER.error("Failed to initialize device scanner: %s", tracker_type) return False @@ -77,7 +83,10 @@ def setup(hass, config): seconds = util.convert(config[DOMAIN].get(CONF_SECONDS), int, DEFAULT_CONF_SECONDS) - tracker = DeviceTracker(hass, device_scanner, seconds) + track_new_devices = config[DOMAIN].get(TRACK_NEW_DEVICES) or False + _LOGGER.info("Tracking new devices: %s", track_new_devices) + + tracker = DeviceTracker(hass, device_scanner, seconds, track_new_devices) # We only succeeded if we got to parse the known devices file return not tracker.invalid_known_devices_file @@ -86,13 +95,16 @@ def setup(hass, config): class DeviceTracker(object): """ Class that tracks which devices are home and which are not. """ - def __init__(self, hass, device_scanner, seconds): + def __init__(self, hass, device_scanner, seconds, track_new_devices): self.hass = hass self.device_scanner = device_scanner self.lock = threading.Lock() + # Do we track new devices by default? + self.track_new_devices = track_new_devices + # Dictionary to keep track of known devices and devices we track self.tracked = {} self.untracked_devices = set() @@ -113,7 +125,7 @@ class DeviceTracker(object): """ Reload known devices file. """ self._read_known_devices_file() - self.update_devices(datetime.now()) + self.update_devices(dt_util.utcnow()) dev_group.update_tracked_entity_ids(self.device_entity_ids) @@ -125,7 +137,7 @@ class DeviceTracker(object): seconds = range(0, 60, seconds) _LOGGER.info("Device tracker interval second=%s", seconds) - hass.track_time_change(update_device_state, second=seconds) + track_utc_time_change(hass, update_device_state, second=seconds) hass.services.register(DOMAIN, SERVICE_DEVICE_TRACKER_RELOAD, @@ -151,66 +163,40 @@ class DeviceTracker(object): state = STATE_HOME if is_home else STATE_NOT_HOME + # overwrite properties that have been set in the config file + attr = dict(dev_info['state_attr']) + attr.update(_OVERWRITE.get(dev_info['entity_id'], {})) + self.hass.states.set( - dev_info['entity_id'], state, - dev_info['state_attr']) + dev_info['entity_id'], state, attr) def update_devices(self, now): """ Update device states based on the found devices. """ if not self.lock.acquire(False): return - found_devices = set(dev.upper() for dev in - self.device_scanner.scan_devices()) + try: + found_devices = set(dev.upper() for dev in + self.device_scanner.scan_devices()) - for device in self.tracked: - is_home = device in found_devices + for device in self.tracked: + is_home = device in found_devices - self._update_state(now, device, is_home) + self._update_state(now, device, is_home) - if is_home: - found_devices.remove(device) + if is_home: + found_devices.remove(device) - # Did we find any devices that we didn't know about yet? - new_devices = found_devices - self.untracked_devices + # Did we find any devices that we didn't know about yet? + new_devices = found_devices - self.untracked_devices - if new_devices: - self.untracked_devices.update(new_devices) + if new_devices: + if not self.track_new_devices: + self.untracked_devices.update(new_devices) - # Write new devices to known devices file - if not self.invalid_known_devices_file: - - known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) - - try: - # If file does not exist we will write the header too - is_new_file = not os.path.isfile(known_dev_path) - - with open(known_dev_path, 'a') as outp: - _LOGGER.info( - "Found %d new devices, updating %s", - len(new_devices), known_dev_path) - - writer = csv.writer(outp) - - if is_new_file: - writer.writerow(( - "device", "name", "track", "picture")) - - for device in new_devices: - # See if the device scanner knows the name - # else defaults to unknown device - dname = self.device_scanner.get_device_name(device) - name = dname or "unknown device" - - writer.writerow((device, name, 0, "")) - - except IOError: - _LOGGER.exception( - "Error updating %s with %d new devices", - known_dev_path, len(new_devices)) - - self.lock.release() + self._update_known_devices_file(new_devices) + finally: + self.lock.release() # pylint: disable=too-many-branches def _read_known_devices_file(self): @@ -226,7 +212,6 @@ class DeviceTracker(object): self.untracked_devices.clear() with open(known_dev_path) as inp: - default_last_seen = datetime(1990, 1, 1) # To track which devices need an entity_id assigned need_entity_id = [] @@ -247,10 +232,7 @@ class DeviceTracker(object): # We found a new device need_entity_id.append(device) - self.tracked[device] = { - 'name': row['name'], - 'last_seen': default_last_seen - } + self._track_device(device, row['name']) # Update state_attr with latest from file state_attr = { @@ -275,21 +257,7 @@ class DeviceTracker(object): self.tracked.pop(device) - # Setup entity_ids for the new devices - used_entity_ids = [info['entity_id'] for device, info - in self.tracked.items() - if device not in need_entity_id] - - for device in need_entity_id: - name = self.tracked[device]['name'] - - entity_id = util.ensure_unique_string( - ENTITY_ID_FORMAT.format(util.slugify(name)), - used_entity_ids) - - used_entity_ids.append(entity_id) - - self.tracked[device]['entity_id'] = entity_id + self._generate_entity_ids(need_entity_id) if not self.tracked: _LOGGER.warning( @@ -308,3 +276,72 @@ class DeviceTracker(object): finally: self.lock.release() + + def _update_known_devices_file(self, new_devices): + """ Add new devices to known devices file. """ + if not self.invalid_known_devices_file: + known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) + + try: + # If file does not exist we will write the header too + is_new_file = not os.path.isfile(known_dev_path) + + with open(known_dev_path, 'a') as outp: + _LOGGER.info("Found %d new devices, updating %s", + len(new_devices), known_dev_path) + + writer = csv.writer(outp) + + if is_new_file: + writer.writerow(("device", "name", "track", "picture")) + + for device in new_devices: + # See if the device scanner knows the name + # else defaults to unknown device + name = self.device_scanner.get_device_name(device) or \ + DEVICE_DEFAULT_NAME + + track = 0 + if self.track_new_devices: + self._track_device(device, name) + track = 1 + + writer.writerow((device, name, track, "")) + + if self.track_new_devices: + self._generate_entity_ids(new_devices) + + except IOError: + _LOGGER.exception("Error updating %s with %d new devices", + known_dev_path, len(new_devices)) + + def _track_device(self, device, name): + """ + Add a device to the list of tracked devices. + Does not generate the entity id yet. + """ + default_last_seen = dt_util.utcnow().replace(year=1990) + + self.tracked[device] = { + 'name': name, + 'last_seen': default_last_seen, + 'state_attr': {ATTR_FRIENDLY_NAME: name} + } + + def _generate_entity_ids(self, need_entity_id): + """ Generate entity ids for a list of devices. """ + # Setup entity_ids for the new devices + used_entity_ids = [info['entity_id'] for device, info + in self.tracked.items() + if device not in need_entity_id] + + for device in need_entity_id: + name = self.tracked[device]['name'] + + entity_id = util.ensure_unique_string( + ENTITY_ID_FORMAT.format(util.slugify(name)), + used_entity_ids) + + used_entity_ids.append(entity_id) + + self.tracked[device]['entity_id'] = entity_id diff --git a/homeassistant/components/device_tracker/actiontec.py b/homeassistant/components/device_tracker/actiontec.py new file mode 100644 index 00000000000..f926b182983 --- /dev/null +++ b/homeassistant/components/device_tracker/actiontec.py @@ -0,0 +1,191 @@ +""" +homeassistant.components.device_tracker.actiontec +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning an Actiontec MI424WR +(Verizon FIOS) router for device presence. + +This device tracker needs telnet to be enabled on the router. + +Configuration: + +To use the Actiontec tracker you will need to add something like the +following to your configuration.yaml file. If you experience disconnects +you can modify the home_interval variable. + +device_tracker: + platform: actiontec + host: YOUR_ROUTER_IP + username: YOUR_ADMIN_USERNAME + password: YOUR_ADMIN_PASSWORD + # optional: + home_interval: 10 + +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. + +home_interval +*Optional +If the home_interval is set then the component will not let a device +be AWAY if it has been HOME in the last home_interval minutes. This is +in addition to the 3 minute wait built into the device_tracker component. +""" +import logging +from datetime import timedelta +from collections import namedtuple +import re +import threading +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.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}))') + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """ Validates config and returns an Actiontec scanner. """ + if not validate_config(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"]) + + +class ActiontecDeviceScanner(object): + """ + This class queries a an actiontec router for connected devices. + Adapted from DD-WRT scanner. + """ + + def __init__(self, config): + 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): + """ + Scans for new devices and return a list containing found device ids. + """ + + self._update_info() + return [client.mac for client in self.last_results] + + def get_device_name(self, device): + """ Returns the name of the given device or None if we don't know. """ + if not self.last_results: + return None + for client in self.last_results: + if client.mac == device: + return client.ip + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the Actiontec MI424WR router is up + to date. Returns boolean if scanning successful. + """ + _LOGGER.info("Scanning") + if not self.success_init: + 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) + _LOGGER.info("actiontec scan successful") + return True + + def get_actiontec_data(self): + """ Retrieve data from Actiontec MI424WR and return parsed result. """ + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b'Username: ') + telnet.write((self.username + '\n').encode('ascii')) + telnet.read_until(b'Password: ') + telnet.write((self.password + '\n').encode('ascii')) + prompt = telnet.read_until( + b'Wireless Broadband Router> ').split(b'\n')[-1] + telnet.write('firewall mac_cache_dump\n'.encode('ascii')) + telnet.write('\n'.encode('ascii')) + telnet.read_until(prompt) + leases_result = telnet.read_until(prompt).split(b'\n')[1:-1] + telnet.write('exit\n'.encode('ascii')) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router," + + " is telnet enabled?") + return None + + devices = {} + for lease in leases_result: + match = _LEASES_REGEX.search(lease.decode('utf-8')) + if match is not None: + devices[match.group('ip')] = { + 'ip': match.group('ip'), + 'mac': match.group('mac').upper() + } + return devices diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py new file mode 100644 index 00000000000..68ff8390216 --- /dev/null +++ b/homeassistant/components/device_tracker/aruba.py @@ -0,0 +1,148 @@ +""" +homeassistant.components.device_tracker.aruba +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a Aruba Access Point for device +presence. + +This device tracker needs telnet to be enabled on the router. + +Configuration: + +To use the Aruba tracker you will need to add something like the following +to your configuration.yaml file. You also need to enable Telnet in the +configuration page of your router. + +device_tracker: + platform: aruba + host: YOUR_ACCESS_POINT_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. +""" +import logging +from datetime import timedelta +import re +import threading +import telnetlib + +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=10) + +_LOGGER = logging.getLogger(__name__) + +_DEVICES_REGEX = re.compile( + r'(?P([^\s]+))\s+' + + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """ Validates config and returns a Aruba scanner. """ + if not validate_config(config, + {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return None + + scanner = ArubaDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class ArubaDeviceScanner(object): + """ This class queries a Aruba Acces Point for connected devices. """ + def __init__(self, config): + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + + self.lock = threading.Lock() + + self.last_results = {} + + # Test the router is accessible + data = self.get_aruba_data() + self.success_init = data is not None + + def scan_devices(self): + """ + Scans for new devices and return a list containing found device IDs. + """ + + self._update_info() + return [client['mac'] for client in self.last_results] + + def get_device_name(self, device): + """ Returns the name of the given device or None if we don't know. """ + if not self.last_results: + return None + for client in self.last_results: + if client['mac'] == device: + return client['name'] + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the Aruba Access Point is up to date. + Returns boolean if scanning successful. + """ + if not self.success_init: + return False + + with self.lock: + data = self.get_aruba_data() + if not data: + return False + + self.last_results = data.values() + return True + + def get_aruba_data(self): + """ Retrieve data from Aruba Access Point and return parsed result. """ + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b'User: ') + telnet.write((self.username + '\r\n').encode('ascii')) + telnet.read_until(b'Password: ') + telnet.write((self.password + '\r\n').encode('ascii')) + telnet.read_until(b'#') + telnet.write(('show clients\r\n').encode('ascii')) + devices_result = telnet.read_until(b'#').split(b'\r\n') + telnet.write('exit\r\n'.encode('ascii')) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router," + + " is telnet enabled?") + return + + devices = {} + for device in devices_result: + match = _DEVICES_REGEX.search(device.decode('utf-8')) + if match: + devices[match.group('ip')] = { + 'ip': match.group('ip'), + 'mac': match.group('mac').upper(), + 'name': match.group('name') + } + return devices diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py new file mode 100644 index 00000000000..c0b29ab420f --- /dev/null +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -0,0 +1,171 @@ +""" +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. +""" +import logging +from datetime import timedelta +import re +import threading +import telnetlib + +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__) + +_LEASES_REGEX = re.compile( + r'\w+\s' + + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' + + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + + r'(?P([^\s]+))') + +_IP_NEIGH_REGEX = re.compile( + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + + r'\w+\s' + + r'\w+\s' + + r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' + + r'(?P(\w+))') + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """ Validates config and returns an ASUS-WRT scanner. """ + if not validate_config(config, + {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return None + + scanner = AsusWrtDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class AsusWrtDeviceScanner(object): + """ + This class queries a router running ASUSWRT firmware + for connected devices. Adapted from DD-WRT scanner. + """ + + def __init__(self, config): + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + + self.lock = threading.Lock() + + self.last_results = {} + + # Test the router is accessible + data = self.get_asuswrt_data() + self.success_init = data is not None + + def scan_devices(self): + """ + Scans for new devices and return a list containing found device IDs. + """ + + self._update_info() + return [client['mac'] for client in self.last_results] + + def get_device_name(self, device): + """ Returns the name of the given device or None if we don't know. """ + if not self.last_results: + return None + for client in self.last_results: + if client['mac'] == device: + return client['host'] + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the ASUSWRT router is up to date. + Returns boolean if scanning successful. + """ + if not self.success_init: + return False + + with self.lock: + _LOGGER.info("Checking ARP") + data = self.get_asuswrt_data() + if not data: + return False + + active_clients = [client for client in data.values() if + client['status'] == 'REACHABLE' or + client['status'] == 'DELAY' or + client['status'] == 'STALE'] + self.last_results = active_clients + return True + + def get_asuswrt_data(self): + """ Retrieve data from ASUSWRT and return parsed result. """ + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b'login: ') + telnet.write((self.username + '\n').encode('ascii')) + telnet.read_until(b'Password: ') + telnet.write((self.password + '\n').encode('ascii')) + prompt_string = telnet.read_until(b'#').split(b'\n')[-1] + telnet.write('ip neigh\n'.encode('ascii')) + neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1] + telnet.write('cat /var/lib/misc/dnsmasq.leases\n'.encode('ascii')) + leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1] + telnet.write('exit\n'.encode('ascii')) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router," + + " is telnet enabled?") + return + + devices = {} + for lease in leases_result: + match = _LEASES_REGEX.search(lease.decode('utf-8')) + devices[match.group('ip')] = { + 'ip': match.group('ip'), + 'mac': match.group('mac').upper(), + 'host': match.group('host'), + 'status': '' + } + + for neighbor in neighbors: + match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) + 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 3d63c49209c..a9a4ac8e3f5 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -1,4 +1,34 @@ -""" Supports scanning a DD-WRT router. """ +""" +homeassistant.components.device_tracker.ddwrt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a DD-WRT router for device +presence. + +Configuration: + +To use the DD-WRT tracker you will need to add something like the following +to your configuration.yaml file. + +device_tracker: + platform: ddwrt + 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. +""" import logging from datetime import timedelta import re @@ -20,7 +50,7 @@ _DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}') # pylint: disable=unused-argument def get_scanner(hass, config): - """ Validates config and returns a DdWrt scanner. """ + """ Validates config and returns a DD-WRT scanner. """ if not validate_config(config, {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, _LOGGER): @@ -33,7 +63,8 @@ def get_scanner(hass, config): # pylint: disable=too-many-instance-attributes class DdWrtDeviceScanner(object): - """ This class queries a wireless router running DD-WRT firmware + """ + This class queries a wireless router running DD-WRT firmware for connected devices. Adapted from Tomato scanner. """ @@ -54,8 +85,9 @@ class DdWrtDeviceScanner(object): self.success_init = data is not None def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ + """ + Scans for new devices and return a list containing found device ids. + """ self._update_info() @@ -93,8 +125,10 @@ class DdWrtDeviceScanner(object): @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): - """ Ensures the information from the DdWrt router is up to date. - Returns boolean if scanning successful. """ + """ + Ensures the information from the DD-WRT router is up to date. + Returns boolean if scanning successful. + """ if not self.success_init: return False @@ -111,8 +145,8 @@ class DdWrtDeviceScanner(object): self.last_results = [] active_clients = data.get('active_wireless', None) if active_clients: - # This is really lame, instead of using JSON the ddwrt UI - # uses it's own data format for some reason and then + # This is really lame, instead of using JSON the DD-WRT UI + # uses its own data format for some reason and then # regex's out values so I guess I have to do the same, # LAME!!! @@ -132,7 +166,7 @@ class DdWrtDeviceScanner(object): return False def get_ddwrt_data(self, url): - """ Retrieve data from DD-WRT and return parsed result """ + """ Retrieve data from DD-WRT and return parsed result. """ try: response = requests.get( url, @@ -154,8 +188,7 @@ class DdWrtDeviceScanner(object): def _parse_ddwrt_response(data_str): - """ Parse the awful DD-WRT data format, why didn't they use JSON????. - This code is a python version of how they are parsing in the JS """ + """ Parse the DD-WRT data format. """ return { key: val for key, val in _DDWRT_DATA_REGEX .findall(data_str)} diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index 637c48ddf26..4cbc6a2d492 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -1,4 +1,37 @@ -""" Supports scanning a OpenWRT router. """ +""" +homeassistant.components.device_tracker.luci +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a OpenWRT router for device +presence. + +It's required that the luci RPC package is installed on the OpenWRT router: +# opkg install luci-mod-rpc + +Configuration: + +To use the Luci tracker you will need to add something like the following +to your configuration.yaml file. + +device_tracker: + platform: luci + 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. +""" import logging import json from datetime import timedelta @@ -31,7 +64,8 @@ def get_scanner(hass, config): # pylint: disable=too-many-instance-attributes class LuciDeviceScanner(object): - """ This class queries a wireless router running OpenWrt firmware + """ + This class queries a wireless router running OpenWrt firmware for connected devices. Adapted from Tomato scanner. # opkg install luci-mod-rpc @@ -60,8 +94,9 @@ class LuciDeviceScanner(object): self.success_init = self.token is not None def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ + """ + Scans for new devices and return a list containing found device ids. + """ self._update_info() @@ -79,17 +114,20 @@ class LuciDeviceScanner(object): hosts = [x for x in result.values() if x['.type'] == 'host' and 'mac' in x and 'name' in x] - mac2name_list = [(x['mac'], x['name']) for x in hosts] + mac2name_list = [ + (x['mac'].upper(), x['name']) for x in hosts] self.mac2name = dict(mac2name_list) else: # Error, handled in the _req_json_rpc return - return self.mac2name.get(device, None) + 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. """ + """ + Ensures the information from the Luci router is up to date. + Returns boolean if scanning successful. + """ if not self.success_init: return False @@ -143,6 +181,6 @@ def _req_json_rpc(url, method, *args, **kwargs): def _get_token(host, username, password): - """ Get authentication token for the given host+username+password """ + """ Get authentication token for the given host+username+password. """ url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host) return _req_json_rpc(url, 'login', username, password) diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index aac09536746..88fd7aed78a 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -1,10 +1,39 @@ -""" Supports scanning a Netgear router. """ +""" +homeassistant.components.device_tracker.netgear +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a Netgear router for device +presence. + +Configuration: + +To use the Netgear tracker you will need to add something like the following +to your configuration.yaml file. + +device_tracker: + platform: netgear + 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. +""" import logging from datetime import timedelta import threading 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 @@ -12,58 +41,57 @@ from homeassistant.components.device_tracker import DOMAIN MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['pynetgear==0.3'] def get_scanner(hass, config): """ Validates config and returns a Netgear scanner. """ - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): + info = config[DOMAIN] + host = info.get(CONF_HOST) + username = info.get(CONF_USERNAME) + password = info.get(CONF_PASSWORD) + + if password is not None and host is None: + _LOGGER.warning('Found username or password but no host') return None - info = config[DOMAIN] - - scanner = NetgearDeviceScanner( - info[CONF_HOST], info[CONF_USERNAME], info[CONF_PASSWORD]) + scanner = NetgearDeviceScanner(host, username, password) return scanner if scanner.success_init else None class NetgearDeviceScanner(object): - """ This class queries a Netgear wireless router using the SOAP-api. """ + """ This class queries a Netgear wireless router using the SOAP-API. """ def __init__(self, host, username, password): + import pynetgear + self.last_results = [] - - try: - # Pylint does not play nice if not every folders has an __init__.py - # pylint: disable=no-name-in-module, import-error - import homeassistant.external.pynetgear.pynetgear as pynetgear - except ImportError: - _LOGGER.exception( - ("Failed to import pynetgear. " - "Did you maybe not run `git submodule init` " - "and `git submodule update`?")) - - self.success_init = False - - return - - self._api = pynetgear.Netgear(host, username, password) self.lock = threading.Lock() + if host is None: + print("BIER") + self._api = pynetgear.Netgear() + elif username is None: + self._api = pynetgear.Netgear(password, host) + else: + self._api = pynetgear.Netgear(password, host, username) + _LOGGER.info("Logging in") - self.success_init = self._api.login() + results = self._api.get_attached_devices() + + self.success_init = results is not None if self.success_init: - self._update_info() + self.last_results = results else: _LOGGER.error("Failed to Login") def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ + """ + Scans for new devices and return a list containing found device ids. + """ self._update_info() return (device.mac for device in self.last_results) @@ -78,8 +106,10 @@ class NetgearDeviceScanner(object): @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): - """ Retrieves latest information from the Netgear router. - Returns boolean if scanning successful. """ + """ + Retrieves latest information from the Netgear router. + Returns boolean if scanning successful. + """ if not self.success_init: return diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index b221a815fb8..5c619e001a3 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -1,13 +1,36 @@ -""" Supports scanning using nmap. """ +""" +homeassistant.components.device_tracker.nmap +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a network with nmap. + +Configuration: + +To use the nmap tracker you will need to add something like the following +to your configuration.yaml file. + +device_tracker: + platform: nmap_tracker + hosts: 192.168.1.1/24 + +Variables: + +hosts +*Required +The IP addresses to scan in the network-prefix notation (192.168.1.1/24) or +the range notation (192.168.1.1-255). + +home_interval +*Optional +Number of minutes it will not scan devices that it found in previous results. +This is to save battery. +""" import logging -from datetime import timedelta, datetime +from datetime import timedelta from collections import namedtuple import subprocess import re -from libnmap.process import NmapProcess -from libnmap.parser import NmapParser, NmapParserException - +import homeassistant.util.dt as dt_util from homeassistant.const import CONF_HOSTS from homeassistant.helpers import validate_config from homeassistant.util import Throttle, convert @@ -21,6 +44,8 @@ _LOGGER = logging.getLogger(__name__) # interval in minutes to exclude devices from a scan while they are home CONF_HOME_INTERVAL = "home_interval" +REQUIREMENTS = ['python-nmap==0.4.1'] + def get_scanner(hass, config): """ Validates config and returns a Nmap scanner. """ @@ -36,7 +61,7 @@ Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) def _arp(ip_address): - """ Get the MAC address for a given IP """ + """ Get the MAC address for a given IP. """ cmd = ['arp', '-n', ip_address] arp = subprocess.Popen(cmd, stdout=subprocess.PIPE) out, _ = arp.communicate() @@ -44,11 +69,11 @@ def _arp(ip_address): if match: return match.group(0) _LOGGER.info("No MAC address found for %s", ip_address) - return '' + return None class NmapDeviceScanner(object): - """ This class scans for devices using nmap """ + """ This class scans for devices using nmap. """ def __init__(self, config): self.last_results = [] @@ -57,13 +82,13 @@ class NmapDeviceScanner(object): minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0) self.home_interval = timedelta(minutes=minutes) - self.success_init = True - self._update_info() + self.success_init = self._update_info() _LOGGER.info("nmap scanner initialized") def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ + """ + Scans for new devices and return a list containing found device ids. + """ self._update_info() @@ -80,46 +105,21 @@ class NmapDeviceScanner(object): else: return None - def _parse_results(self, stdout): - """ Parses results from an nmap scan. - Returns True if successful, False otherwise. """ - try: - results = NmapParser.parse(stdout) - now = datetime.now() - self.last_results = [] - for host in results.hosts: - if host.is_up(): - if host.hostnames: - name = host.hostnames[0] - else: - name = host.ipv4 - if host.mac: - mac = host.mac - else: - mac = _arp(host.ipv4) - if mac: - device = Device(mac, name, host.ipv4, now) - self.last_results.append(device) - _LOGGER.info("nmap scan successful") - return True - except NmapParserException as parse_exc: - _LOGGER.error("failed to parse nmap results: %s", parse_exc.msg) - self.last_results = [] - return False - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): - """ Scans the network for devices. - Returns boolean if scanning successful. """ - if not self.success_init: - return False - + """ + Scans the network for devices. + Returns boolean if scanning successful. + """ _LOGGER.info("Scanning") + from nmap import PortScanner, PortScannerError + scanner = PortScanner() + options = "-F --host-timeout 5" exclude_targets = set() if self.home_interval: - now = datetime.now() + now = dt_util.now() for host in self.last_results: if host.last_update + self.home_interval > now: exclude_targets.add(host) @@ -127,14 +127,24 @@ class NmapDeviceScanner(object): target_list = [t.ip for t in exclude_targets] options += " --exclude {}".format(",".join(target_list)) - nmap = NmapProcess(targets=self.hosts, options=options) - - nmap.run() - - if nmap.rc == 0: - if self._parse_results(nmap.stdout): - self.last_results.extend(exclude_targets) - else: - self.last_results = [] - _LOGGER.error(nmap.stderr) + try: + result = scanner.scan(hosts=self.hosts, arguments=options) + except PortScannerError: return False + + now = dt_util.now() + self.last_results = [] + for ipv4, info in result['scan'].items(): + if info['status']['state'] != 'up': + continue + name = info['hostnames'][0] if info['hostnames'] else ipv4 + # Mac address only returned if nmap ran as root + mac = info['addresses'].get('mac') or _arp(ipv4) + if mac is None: + continue + device = Device(mac.upper(), name, ipv4, now) + self.last_results.append(device) + self.last_results.extend(exclude_targets) + + _LOGGER.info("nmap scan successful") + return True diff --git a/homeassistant/components/device_tracker/thomson.py b/homeassistant/components/device_tracker/thomson.py new file mode 100644 index 00000000000..408daa94d81 --- /dev/null +++ b/homeassistant/components/device_tracker/thomson.py @@ -0,0 +1,160 @@ +""" +homeassistant.components.device_tracker.thomson +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a THOMSON router for device +presence. + +This device tracker needs telnet to be enabled on the router. + +Configuration: + +To use the THOMSON tracker you will need to add something like the following +to your configuration.yaml file. + +device_tracker: + platform: thomson + 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. +""" +import logging +from datetime import timedelta +import re +import threading +import telnetlib + +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=10) + +_LOGGER = logging.getLogger(__name__) + +_DEVICES_REGEX = re.compile( + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' + + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + + r'(?P([^\s]+))\s+' + + r'(?P([^\s]+))\s+' + + r'(?P([^\s]+))\s+' + + r'(?P([^\s]+))\s+' + + r'(?P([^\s]+))') + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """ Validates config and returns a THOMSON scanner. """ + if not validate_config(config, + {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return None + + scanner = ThomsonDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class ThomsonDeviceScanner(object): + """ + This class queries a router running THOMSON firmware + for connected devices. Adapted from ASUSWRT scanner. + """ + + def __init__(self, config): + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + + self.lock = threading.Lock() + + self.last_results = {} + + # Test the router is accessible + data = self.get_thomson_data() + self.success_init = data is not None + + def scan_devices(self): + """ Scans for new devices and return a + list containing found device ids. """ + + self._update_info() + return [client['mac'] for client in self.last_results] + + def get_device_name(self, device): + """ Returns the name of the given device + or None if we don't know. """ + if not self.last_results: + return None + for client in self.last_results: + if client['mac'] == device: + return client['host'] + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the THOMSON router is up to date. + Returns boolean if scanning successful. + """ + if not self.success_init: + return False + + with self.lock: + _LOGGER.info("Checking ARP") + data = self.get_thomson_data() + if not data: + return False + + # flag C stands for CONNECTED + active_clients = [client for client in data.values() if + client['status'].find('C') != -1] + self.last_results = active_clients + return True + + def get_thomson_data(self): + """ Retrieve data from THOMSON and return parsed result. """ + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b'Username : ') + telnet.write((self.username + '\r\n').encode('ascii')) + telnet.read_until(b'Password : ') + telnet.write((self.password + '\r\n').encode('ascii')) + telnet.read_until(b'=>') + telnet.write(('hostmgr list\r\n').encode('ascii')) + devices_result = telnet.read_until(b'=>').split(b'\r\n') + telnet.write('exit\r\n'.encode('ascii')) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router," + + " is telnet enabled?") + return + + devices = {} + for device in devices_result: + match = _DEVICES_REGEX.search(device.decode('utf-8')) + if match: + devices[match.group('ip')] = { + 'ip': match.group('ip'), + 'mac': match.group('mac').upper(), + 'host': match.group('host'), + 'status': match.group('status') + } + return devices diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index 265dcf84b57..a23b7b80ff0 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -1,4 +1,40 @@ -""" Supports scanning a Tomato router. """ +""" +homeassistant.components.device_tracker.tomato +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a Tomato router for device +presence. + +Configuration: + +To use the Tomato tracker you will need to add something like the following +to your configuration.yaml file. + +device_tracker: + platform: tomato + host: YOUR_ROUTER_IP + username: YOUR_ADMIN_USERNAME + password: YOUR_ADMIN_PASSWORD + http_id: ABCDEFG + +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. + +http_id +*Required +The value can be obtained by logging in to the Tomato admin interface and +search for http_id in the page source code. +""" import logging import json from datetime import timedelta diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py new file mode 100755 index 00000000000..6b12000cf45 --- /dev/null +++ b/homeassistant/components/device_tracker/tplink.py @@ -0,0 +1,189 @@ +""" +homeassistant.components.device_tracker.tplink +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a TP-Link router for device +presence. + +Configuration: + +To use the TP-Link tracker you will need to add something like the following +to your configuration.yaml file. + +device_tracker: + platform: tplink + 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. +""" +import base64 +import logging +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 TP-Link scanner. """ + if not validate_config(config, + {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return None + + scanner = Tplink2DeviceScanner(config[DOMAIN]) + + if not scanner.success_init: + scanner = TplinkDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class TplinkDeviceScanner(object): + """ + This class queries a wireless router running TP-Link firmware + for connected devices. + """ + + def __init__(self, config): + host = config[CONF_HOST] + username, password = config[CONF_USERNAME], config[CONF_PASSWORD] + + self.parse_macs = re.compile('[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-' + + '[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}') + + self.host = host + self.username = username + self.password = password + + self.last_results = {} + self.lock = threading.Lock() + self.success_init = self._update_info() + + def scan_devices(self): + """ + Scans for new devices and return a list containing found device ids. + """ + + self._update_info() + + return self.last_results + + # pylint: disable=no-self-use + def get_device_name(self, device): + """ + The TP-Link firmware doesn't save the name of the wireless device. + """ + + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the TP-Link router is up to date. + Returns boolean if scanning successful. + """ + + with self.lock: + _LOGGER.info("Loading wireless clients...") + + url = 'http://{}/userRpm/WlanStationRpm.htm'.format(self.host) + referer = 'http://{}'.format(self.host) + page = requests.get(url, auth=(self.username, self.password), + headers={'referer': referer}) + + result = self.parse_macs.findall(page.text) + + if result: + self.last_results = [mac.replace("-", ":") for mac in result] + return True + + return False + + +class Tplink2DeviceScanner(TplinkDeviceScanner): + """ + This class queries a wireless router running newer version of TP-Link + firmware for connected devices. + """ + + def scan_devices(self): + """ + Scans for new devices and return a list containing found device ids. + """ + + self._update_info() + return self.last_results.keys() + + # pylint: disable=no-self-use + def get_device_name(self, device): + """ + The TP-Link firmware doesn't save the name of the wireless device. + """ + + return self.last_results.get(device) + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Ensures the information from the TP-Link router is up to date. + Returns boolean if scanning successful. + """ + + with self.lock: + _LOGGER.info("Loading wireless clients...") + + url = 'http://{}/data/map_access_wireless_client_grid.json'\ + .format(self.host) + referer = 'http://{}'.format(self.host) + + # Router uses Authorization cookie instead of header + # Let's create the cookie + username_password = '{}:{}'.format(self.username, self.password) + b64_encoded_username_password = base64.b64encode( + username_password.encode('ascii') + ).decode('ascii') + cookie = 'Authorization=Basic {}'\ + .format(b64_encoded_username_password) + + response = requests.post(url, headers={'referer': referer, + 'cookie': cookie}) + + try: + result = response.json().get('data') + except ValueError: + _LOGGER.error("Router didn't respond with JSON. " + "Check if credentials are correct.") + return False + + if result: + self.last_results = { + device['mac_addr'].replace('-', ':'): device['name'] + for device in result + } + return True + + return False diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 2a9cb64f6ec..c21249fbc60 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -1,18 +1,17 @@ """ +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. Knows which components handle certain types, will make sure they are loaded before the EVENT_PLATFORM_DISCOVERED is fired. - """ import logging import threading -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.netdisco.netdisco.const as services - from homeassistant import bootstrap from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_PLATFORM_DISCOVERED, @@ -20,13 +19,22 @@ from homeassistant.const import ( DOMAIN = "discovery" DEPENDENCIES = [] +REQUIREMENTS = ['netdisco==0.3'] SCAN_INTERVAL = 300 # seconds +# Next 3 lines for now a mirror from netdisco.const +# Should setup a mapping netdisco.const -> own constants +SERVICE_WEMO = 'belkin_wemo' +SERVICE_HUE = 'philips_hue' +SERVICE_CAST = 'google_cast' +SERVICE_NETGEAR = 'netgear_router' + SERVICE_HANDLERS = { - services.BELKIN_WEMO: "switch", - services.GOOGLE_CAST: "media_player", - services.PHILIPS_HUE: "light", + SERVICE_WEMO: "switch", + SERVICE_CAST: "media_player", + SERVICE_HUE: "light", + SERVICE_NETGEAR: 'device_tracker', } @@ -53,14 +61,7 @@ def setup(hass, config): """ Starts a discovery service. """ logger = logging.getLogger(__name__) - try: - from homeassistant.external.netdisco.netdisco.service import \ - DiscoveryService - except ImportError: - logger.exception( - "Unable to import netdisco. " - "Did you install all the zeroconf dependency?") - return False + from netdisco.service import DiscoveryService # Disable zeroconf logging, it spams logging.getLogger('zeroconf').setLevel(logging.CRITICAL) @@ -78,6 +79,13 @@ def setup(hass, config): if not component: return + # Hack - fix when device_tracker supports discovery + if service == SERVICE_NETGEAR: + bootstrap.setup_component(hass, component, { + 'device_tracker': {'platform': 'netgear'} + }) + return + # This component cannot be setup. if not bootstrap.setup_component(hass, component, config): return diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8ff722e41b2..902b14e38b3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -20,13 +20,21 @@ INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template') _LOGGER = logging.getLogger(__name__) +FRONTEND_URLS = [ + URL_ROOT, '/logbook', '/history', '/devService', '/devState', '/devEvent'] +STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)') + + def setup(hass, config): """ Setup serving the frontend. """ if 'http' not in hass.config.components: _LOGGER.error('Dependency http is not loaded') return False - hass.http.register_path('GET', URL_ROOT, _handle_get_root, False) + 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) # Static files hass.http.register_path( @@ -47,7 +55,7 @@ def _handle_get_root(handler, path_match, data): handler.end_headers() if handler.server.development: - app_url = "polymer/home-assistant.html" + app_url = "home-assistant-polymer/src/home-assistant.html" else: app_url = "frontend-{}.html".format(version.VERSION) diff --git a/homeassistant/components/frontend/index.html.template b/homeassistant/components/frontend/index.html.template index c4da4f0369d..8906e8902a0 100644 --- a/homeassistant/components/frontend/index.html.template +++ b/homeassistant/components/frontend/index.html.template @@ -5,24 +5,47 @@ Home Assistant - - - - - - - + href='/static/favicon-apple-180x180.png'> + + + - - -

Initializing Home Assistant

- - - + + + +
+ +
Initializing
+
+ + + diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index f580952cfeb..8c6b05726da 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 = "a063d1482fd49e9297d64e1329324f1c" +VERSION = "35ecb5457a9ff0f4142c2605b53eb843" diff --git a/homeassistant/components/frontend/www_static/__init__.py b/homeassistant/components/frontend/www_static/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/frontend/www_static/favicon-apple-180x180.png b/homeassistant/components/frontend/www_static/favicon-apple-180x180.png new file mode 100644 index 00000000000..20117d00f22 Binary files /dev/null and b/homeassistant/components/frontend/www_static/favicon-apple-180x180.png differ diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index aaf95520da6..02d96975a0e 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,10 +1,2304 @@ - - + - - - + .dev-tools { + padding: 0 8px; + } \ 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 new file mode 160000 index 00000000000..b0b12e20e0f --- /dev/null +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -0,0 +1 @@ +Subproject commit b0b12e20e0f61df849c414c2dfbcf9923f784631 diff --git a/homeassistant/components/frontend/www_static/images/__init__.py b/homeassistant/components/frontend/www_static/images/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/frontend/www_static/polymer/bower.json b/homeassistant/components/frontend/www_static/polymer/bower.json deleted file mode 100644 index 77c951048e5..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/bower.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "Home Assistant", - "version": "0.1.0", - "authors": [ - "Paulus Schoutsen " - ], - "main": "splash-login.html", - "license": "MIT", - "private": true, - "ignore": [ - "bower_components" - ], - "dependencies": { - "webcomponentsjs": "Polymer/webcomponentsjs#~0.5.5", - "font-roboto": "Polymer/font-roboto#~0.5.5", - "core-header-panel": "polymer/core-header-panel#~0.5.5", - "core-toolbar": "polymer/core-toolbar#~0.5.5", - "core-tooltip": "Polymer/core-tooltip#~0.5.5", - "core-menu": "polymer/core-menu#~0.5.5", - "core-item": "Polymer/core-item#~0.5.5", - "core-input": "Polymer/core-input#~0.5.5", - "core-icons": "polymer/core-icons#~0.5.5", - "core-image": "polymer/core-image#~0.5.5", - "core-style": "polymer/core-style#~0.5.5", - "core-label": "polymer/core-label#~0.5.5", - "paper-toast": "Polymer/paper-toast#~0.5.5", - "paper-dialog": "Polymer/paper-dialog#~0.5.5", - "paper-spinner": "Polymer/paper-spinner#~0.5.5", - "paper-button": "Polymer/paper-button#~0.5.5", - "paper-input": "Polymer/paper-input#~0.5.5", - "paper-toggle-button": "polymer/paper-toggle-button#~0.5.5", - "paper-icon-button": "polymer/paper-icon-button#~0.5.5", - "paper-menu-button": "polymer/paper-menu-button#~0.5.5", - "paper-dropdown": "polymer/paper-dropdown#~0.5.5", - "paper-item": "polymer/paper-item#~0.5.5", - "paper-slider": "polymer/paper-slider#~0.5.5", - "paper-checkbox": "polymer/paper-checkbox#~0.5.5", - "color-picker-element": "~0.0.2", - "google-apis": "GoogleWebComponents/google-apis#~0.4.2", - "core-drawer-panel": "polymer/core-drawer-panel#~0.5.5", - "core-scroll-header-panel": "polymer/core-scroll-header-panel#~0.5.5", - "moment": "~2.9.0" - } -} diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html deleted file mode 100644 index 195a9cb1109..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-content.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-content.html deleted file mode 100644 index b3b7a3b6363..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-content.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html deleted file mode 100755 index cbafabac21a..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-scene.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-scene.html deleted file mode 100644 index 4f2a1403e09..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-scene.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html deleted file mode 100644 index 63c6708ce67..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html deleted file mode 100755 index fc4eb895e23..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card.html deleted file mode 100644 index 3305f755a33..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/domain-icon.html b/homeassistant/components/frontend/www_static/polymer/components/domain-icon.html deleted file mode 100644 index ba2074ab7ca..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/domain-icon.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/entity-list.html b/homeassistant/components/frontend/www_static/polymer/components/entity-list.html deleted file mode 100644 index e54e03f409a..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/entity-list.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/events-list.html b/homeassistant/components/frontend/www_static/polymer/components/events-list.html deleted file mode 100644 index ddf89dab379..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/events-list.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/ha-modals.html b/homeassistant/components/frontend/www_static/polymer/components/ha-modals.html deleted file mode 100644 index ef0090142d8..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/ha-modals.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/ha-notifications.html b/homeassistant/components/frontend/www_static/polymer/components/ha-notifications.html deleted file mode 100644 index 3072da99e9a..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/ha-notifications.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/loading-box.html b/homeassistant/components/frontend/www_static/polymer/components/loading-box.html deleted file mode 100644 index 5049ec20054..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/loading-box.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/recent-states.html b/homeassistant/components/frontend/www_static/polymer/components/recent-states.html deleted file mode 100644 index 408c0448836..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/recent-states.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/relative-ha-datetime.html b/homeassistant/components/frontend/www_static/polymer/components/relative-ha-datetime.html deleted file mode 100644 index 910622c5bc7..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/relative-ha-datetime.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/services-list.html b/homeassistant/components/frontend/www_static/polymer/components/services-list.html deleted file mode 100644 index 809c8079246..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/services-list.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-badge.html b/homeassistant/components/frontend/www_static/polymer/components/state-badge.html deleted file mode 100644 index ad362e6f048..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-badge.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-cards.html b/homeassistant/components/frontend/www_static/polymer/components/state-cards.html deleted file mode 100755 index 913fc117ccb..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-cards.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-info.html b/homeassistant/components/frontend/www_static/polymer/components/state-info.html deleted file mode 100755 index 05c9ff643bf..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-info.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-timeline.html b/homeassistant/components/frontend/www_static/polymer/components/state-timeline.html deleted file mode 100644 index 9ded10dd3ae..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-timeline.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/stream-status.html b/homeassistant/components/frontend/www_static/polymer/components/stream-status.html deleted file mode 100644 index d31efc61d79..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/stream-status.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/dialogs/ha-dialog.html b/homeassistant/components/frontend/www_static/polymer/dialogs/ha-dialog.html deleted file mode 100644 index 2cf8de644f0..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/dialogs/ha-dialog.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/dialogs/more-info-dialog.html b/homeassistant/components/frontend/www_static/polymer/dialogs/more-info-dialog.html deleted file mode 100644 index 98c88b7db35..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/dialogs/more-info-dialog.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/home-assistant-js b/homeassistant/components/frontend/www_static/polymer/home-assistant-js deleted file mode 160000 index e048bf6ece9..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/home-assistant-js +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e048bf6ece91983b9f03aafeb414ae5c535288a2 diff --git a/homeassistant/components/frontend/www_static/polymer/home-assistant.html b/homeassistant/components/frontend/www_static/polymer/home-assistant.html deleted file mode 100644 index 808fcea8f87..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/home-assistant.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/home-assistant-main.html b/homeassistant/components/frontend/www_static/polymer/layouts/home-assistant-main.html deleted file mode 100644 index ade9c9d166b..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/home-assistant-main.html +++ /dev/null @@ -1,246 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/login-form.html b/homeassistant/components/frontend/www_static/polymer/layouts/login-form.html deleted file mode 100644 index 7b0628fabfc..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/login-form.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-base.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-base.html deleted file mode 100644 index b105723974c..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-base.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-call-service.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-call-service.html deleted file mode 100644 index 78e1b414d09..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-call-service.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-fire-event.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-fire-event.html deleted file mode 100644 index ef4af2cc1fb..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-fire-event.html +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-set-state.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-set-state.html deleted file mode 100644 index dd3032ac5b2..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-set-state.html +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-history.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-history.html deleted file mode 100644 index c1576e2f59e..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-history.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-states.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-states.html deleted file mode 100644 index 11c29c05a55..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-states.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-configurator.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-configurator.html deleted file mode 100644 index 256ee3d5eb6..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-configurator.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-content.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-content.html deleted file mode 100644 index b80a016686b..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-content.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-default.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-default.html deleted file mode 100644 index 454ac813ead..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-default.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-group.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-group.html deleted file mode 100644 index 6355ea6da8a..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-group.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-light.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-light.html deleted file mode 100644 index d0b2de56acc..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-light.html +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-script.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-script.html deleted file mode 100644 index d1e75702fbb..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-script.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-sun.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-sun.html deleted file mode 100644 index 96c92357d1b..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-sun.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-thermostat.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-thermostat.html deleted file mode 100644 index 356d49a85c3..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-thermostat.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html b/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html deleted file mode 100644 index 2d8b6d6e536..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-js.html b/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-js.html deleted file mode 100644 index 8d7cedcd778..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-js.html +++ /dev/null @@ -1,83 +0,0 @@ - - - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-style.html b/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-style.html deleted file mode 100644 index 17049015294..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-style.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - @-webkit-keyframes ha-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } - } - @keyframes ha-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } - } - - .ha-spin { - -webkit-animation: ha-spin 2s infinite linear; - animation: ha-spin 2s infinite linear; - } - - - core-scroll-header-panel, core-header-panel { - background-color: #E5E5E5; - } - - core-toolbar { - background: #03a9f4; - color: white; - font-weight: normal; - } - - - - :host { - font-family: RobotoDraft, 'Helvetica Neue', Helvetica, Arial; - - min-width: 350px; - max-width: 700px; - - /* First two are from core-transition-bottom */ - transition: - transform 0.2s ease-in-out, - opacity 0.2s ease-in, - top .3s, - left .3s !important; - } - - :host .sidebar { - margin-left: 30px; - } - - @media all and (max-width: 620px) { - :host.two-column { - margin: 0; - width: 100%; - max-height: calc(100% - 64px); - bottom: 0px; - left: 0px; - right: 0px; - } - - :host .sidebar { - display: none; - } - } - - @media all and (max-width: 464px) { - :host { - margin: 0; - width: 100%; - max-height: calc(100% - 64px); - bottom: 0px; - left: 0px; - right: 0px; - } - } - - html /deep/ .ha-form paper-input { - display: block; - } - - html /deep/ .ha-form paper-input:first-child { - padding-top: 0; - } - - - - .data-entry { - margin-bottom: 8px; - } - - .data-entry:last-child { - margin-bottom: 0; - } - - .data-entry .key { - margin-right: 8px; - } - - .data-entry .value { - text-align: right; - word-break: break-all; - } - - - - paper-toggle-button::shadow .toggle-ink { - color: #039be5; - } - - paper-toggle-button::shadow [checked] .toggle-bar { - background-color: #039be5; - } - - paper-toggle-button::shadow [checked] .toggle-button { - background-color: #039be5; - } - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/moment-js.html b/homeassistant/components/frontend/www_static/polymer/resources/moment-js.html deleted file mode 100644 index 70e14e72d7f..00000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/moment-js.html +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/homeassistant/components/frontend/www_static/splash.png b/homeassistant/components/frontend/www_static/splash.png new file mode 100644 index 00000000000..582140a2bc3 Binary files /dev/null and b/homeassistant/components/frontend/www_static/splash.png differ diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js new file mode 100644 index 00000000000..ec6063b7a58 --- /dev/null +++ b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js @@ -0,0 +1,12 @@ +/** + * @license + * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt + * 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 diff --git a/homeassistant/components/frontend/www_static/webcomponents.min.js b/homeassistant/components/frontend/www_static/webcomponents.min.js deleted file mode 100644 index 474305f73fc..00000000000 --- a/homeassistant/components/frontend/www_static/webcomponents.min.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license - * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. - * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt - * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt - * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt - * 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.5.5 -window.WebComponents=window.WebComponents||{},function(e){var t=e.flags||{},n="webcomponents.js",r=document.querySelector('script[src*="'+n+'"]');if(!t.noOpts){if(location.search.slice(1).split("&").forEach(function(e){e=e.split("="),e[0]&&(t[e[0]]=e[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}(WebComponents),WebComponents.flags.shadow&&("undefined"==typeof WeakMap&&!function(){var e=Object.defineProperty,t=Date.now()%1e9,n=function(){this.name="__st"+(1e9*Math.random()>>>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}(),window.ShadowDOMPolyfill={},function(e){"use strict";function t(){if("undefined"!=typeof chrome&&chrome.app&&chrome.app.runtime)return!1;if(navigator.getDeviceStorage)return!1;try{var e=new Function("return true;");return e()}catch(t){return!1}}function n(e){if(!e)throw new Error("Assertion failed")}function r(e,t){for(var n=W(t),r=0;rl;l++)c[l]=new Array(s),c[l][0]=l;for(var u=0;s>u;u++)c[0][u]=u;for(var l=1;a>l;l++)for(var u=1;s>u;u++)if(this.equals(e[t+u-1],r[o+l-1]))c[l][u]=c[l-1][u-1];else{var d=c[l-1][u]+1,p=c[l][u-1]+1;c[l][u]=p>d?d:p}return c},spliceOperationsFromEditDistances:function(e){for(var t=e.length-1,n=e[0].length-1,s=e[t][n],c=[];t>0||n>0;)if(0!=t)if(0!=n){var l,u=e[t-1][n-1],d=e[t-1][n],p=e[t][n-1];l=p>d?u>d?d:u:u>p?p:u,l==u?(u==s?c.push(r):(c.push(o),s=u),t--,n--):l==d?(c.push(a),t--,s=d):(c.push(i),n--,s=p)}else c.push(a),t--;else c.push(i),n--;return c.reverse(),c},calcSplices:function(e,n,s,c,l,u){var d=0,p=0,f=Math.min(s-n,u-l);if(0==n&&0==l&&(d=this.sharedPrefix(e,c,f)),s==e.length&&u==c.length&&(p=this.sharedSuffix(e,c,f-d)),n+=d,l+=d,s-=p,u-=p,s-n==0&&u-l==0)return[];if(n==s){for(var h=t(n,[],0);u>l;)h.removed.push(c[l++]);return[h]}if(l==u)return[t(n,[],s-n)];for(var m=this.spliceOperationsFromEditDistances(this.calcEditDistances(e,n,s,c,l,u)),h=void 0,w=[],v=n,g=l,b=0;br;r++)if(!this.equals(e[r],t[r]))return r;return n},sharedSuffix:function(e,t,n){for(var r=e.length,o=t.length,i=0;n>i&&this.equals(e[--r],t[--o]);)i++;return i},calculateSplices:function(e,t){return this.calcSplices(e,0,e.length,t,0,t.length)},equals:function(e,t){return e===t}},e.ArraySplice=n}(window.ShadowDOMPolyfill),function(e){"use strict";function t(){a=!1;var e=i.slice(0);i=[];for(var t=0;t0){for(var u=0;u0&&r.length>0;){var i=n.pop(),a=r.pop();if(i!==a)break;o=i}return o}function u(e,t,n){t instanceof G.Window&&(t=t.document);var o,i=k(t),a=k(n),s=r(n,e),o=l(i,a);o||(o=a.root);for(var c=o;c;c=c.parent)for(var u=0;u0;i--)if(!g(t[i],e,o,t,r))return!1;return!0}function w(e,t,n,r){var o=it,i=t[0]||n;return g(i,e,o,t,r)}function v(e,t,n,r){for(var o=at,i=1;i0&&g(n,e,o,t,r)}function g(e,t,n,r,o){var i=z.get(e);if(!i)return!0;var a=o||s(r,e);if(a===e){if(n===ot)return!0;n===at&&(n=it)}else if(n===at&&!t.bubbles)return!0;if("relatedTarget"in t){var c=q(t),l=c.relatedTarget;if(l){if(l instanceof Object&&l.addEventListener){var d=V(l),p=u(t,e,d);if(p===a)return!0}else p=null;J.set(t,p)}}Z.set(t,n);var f=t.type,h=!1;X.set(t,a),$.set(t,e),i.depth++;for(var m=0,w=i.length;w>m;m++){var v=i[m];if(v.removed)h=!0;else if(!(v.type!==f||!v.capture&&n===ot||v.capture&&n===at))try{if("function"==typeof v.handler?v.handler.call(e,t):v.handler.handleEvent(t),et.get(t))return!1}catch(g){I||(I=g)}}if(i.depth--,h&&0===i.depth){var b=i.slice();i.length=0;for(var m=0;mr;r++)t[r]=a(e[r]);return t.length=o,t}function o(e,t){e.prototype[t]=function(){return r(i(this)[t].apply(i(this),arguments))}}var i=e.unsafeUnwrap,a=e.wrap,s={enumerable:!1};n.prototype={item:function(e){return this[e]}},t(n.prototype,"item"),e.wrappers.NodeList=n,e.addWrapNodeListMethod=o,e.wrapNodeList=r}(window.ShadowDOMPolyfill),function(e){"use strict";e.wrapHTMLCollection=e.wrapNodeList,e.wrappers.HTMLCollection=e.wrappers.NodeList}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){L(e instanceof S)}function n(e){var t=new M;return t[0]=e,t.length=1,t}function r(e,t,n){C(t,"childList",{removedNodes:n,previousSibling:e.previousSibling,nextSibling:e.nextSibling})}function o(e,t){C(e,"childList",{removedNodes:t})}function i(e,t,r,o){if(e instanceof DocumentFragment){var i=s(e);B=!0;for(var a=i.length-1;a>=0;a--)e.removeChild(i[a]),i[a].parentNode_=t;B=!1;for(var a=0;ao;o++)r.appendChild(I(t[o]));return r}function w(e){if(void 0!==e.firstChild_)for(var t=e.firstChild_;t;){var n=t;t=t.nextSibling_,n.parentNode_=n.previousSibling_=n.nextSibling_=void 0}e.firstChild_=e.lastChild_=void 0}function v(e){if(e.invalidateShadowRenderer()){for(var t=e.firstChild;t;){L(t.parentNode===e);var n=t.nextSibling,r=I(t),o=r.parentNode;o&&Y.call(o,r),t.previousSibling_=t.nextSibling_=t.parentNode_=null,t=n}e.firstChild_=e.lastChild_=null}else for(var n,i=I(e),a=i.firstChild;a;)n=a.nextSibling,Y.call(i,a),a=n}function g(e){var t=e.parentNode;return t&&t.invalidateShadowRenderer()}function b(e){for(var t,n=0;ns;s++)i=b(t[s]),!o&&(a=v(i).root)&&a instanceof e.wrappers.ShadowRoot||(r[n++]=i);return n}function n(e){return String(e).replace(/\/deep\/|::shadow/g," ")}function r(e){return String(e).replace(/:host\(([^\s]+)\)/g,"$1").replace(/([^\s]):host/g,"$1").replace(":host","*").replace(/\^|\/shadow\/|\/shadow-deep\/|::shadow|\/deep\/|::content/g," ")}function o(e,t){for(var n,r=e.firstElementChild;r;){if(r.matches(t))return r;if(n=o(r,t))return n;r=r.nextElementSibling}return null}function i(e,t){return e.matches(t)}function a(e,t,n){var r=e.localName;return r===t||r===n&&e.namespaceURI===D}function s(){return!0}function c(e,t,n){return e.localName===n}function l(e,t){return e.namespaceURI===t}function u(e,t,n){return e.namespaceURI===t&&e.localName===n}function d(e,t,n,r,o,i){for(var a=e.firstElementChild;a;)r(a,o,i)&&(n[t++]=a),t=d(a,t,n,r,o,i),a=a.nextElementSibling;return t}function p(n,r,o,i,a){var s,c=g(this),l=v(this).root;if(l instanceof e.wrappers.ShadowRoot)return d(this,r,o,n,i,null);if(c instanceof C)s=T.call(c,i);else{if(!(c instanceof N))return d(this,r,o,n,i,null);s=S.call(c,i)}return t(s,r,o,a)}function f(n,r,o,i,a){var s,c=g(this),l=v(this).root;if(l instanceof e.wrappers.ShadowRoot)return d(this,r,o,n,i,a);if(c instanceof C)s=_.call(c,i,a);else{if(!(c instanceof N))return d(this,r,o,n,i,a);s=M.call(c,i,a)}return t(s,r,o,!1)}function h(n,r,o,i,a){var s,c=g(this),l=v(this).root;if(l instanceof e.wrappers.ShadowRoot)return d(this,r,o,n,i,a);if(c instanceof C)s=O.call(c,i,a);else{if(!(c instanceof N))return d(this,r,o,n,i,a);s=L.call(c,i,a)}return t(s,r,o,!1)}var m=e.wrappers.HTMLCollection,w=e.wrappers.NodeList,v=e.getTreeScope,g=e.unsafeUnwrap,b=e.wrap,y=document.querySelector,E=document.documentElement.querySelector,S=document.querySelectorAll,T=document.documentElement.querySelectorAll,M=document.getElementsByTagName,_=document.documentElement.getElementsByTagName,L=document.getElementsByTagNameNS,O=document.documentElement.getElementsByTagNameNS,C=window.Element,N=window.HTMLDocument||window.Document,D="http://www.w3.org/1999/xhtml",j={querySelector:function(t){var r=n(t),i=r!==t;t=r;var a,s=g(this),c=v(this).root;if(c instanceof e.wrappers.ShadowRoot)return o(this,t);if(s instanceof C)a=b(E.call(s,t));else{if(!(s instanceof N))return o(this,t);a=b(y.call(s,t))}return a&&!i&&(c=v(a).root)&&c instanceof e.wrappers.ShadowRoot?o(this,t):a},querySelectorAll:function(e){var t=n(e),r=t!==e;e=t;var o=new w;return o.length=p.call(this,i,0,o,e,r),o -}},H={matches:function(t){return t=r(t),e.originalMatches.call(g(this),t)}},x={getElementsByTagName:function(e){var t=new m,n="*"===e?s:a;return t.length=f.call(this,n,0,t,e,e.toLowerCase()),t},getElementsByClassName:function(e){return this.querySelectorAll("."+e)},getElementsByTagNameNS:function(e,t){var n=new m,r=null;return r="*"===e?"*"===t?s:c:"*"===t?l:u,n.length=h.call(this,r,0,n,e||null,t),n}};e.GetElementsByInterface=x,e.SelectorsInterface=j,e.MatchesInterface=H}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){for(;e&&e.nodeType!==Node.ELEMENT_NODE;)e=e.nextSibling;return e}function n(e){for(;e&&e.nodeType!==Node.ELEMENT_NODE;)e=e.previousSibling;return e}var r=e.wrappers.NodeList,o={get firstElementChild(){return t(this.firstChild)},get lastElementChild(){return n(this.lastChild)},get childElementCount(){for(var e=0,t=this.firstElementChild;t;t=t.nextElementSibling)e++;return e},get children(){for(var e=new r,t=0,n=this.firstElementChild;n;n=n.nextElementSibling)e[t++]=n;return e.length=t,e},remove:function(){var e=this.parentNode;e&&e.removeChild(this)}},i={get nextElementSibling(){return t(this.nextSibling)},get previousElementSibling(){return n(this.previousSibling)}};e.ChildNodeInterface=i,e.ParentNodeInterface=o}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r.call(this,e)}var n=e.ChildNodeInterface,r=e.wrappers.Node,o=e.enqueueMutation,i=e.mixin,a=e.registerWrapper,s=e.unsafeUnwrap,c=window.CharacterData;t.prototype=Object.create(r.prototype),i(t.prototype,{get textContent(){return this.data},set textContent(e){this.data=e},get data(){return s(this).data},set data(e){var t=s(this).data;o(this,"characterData",{oldValue:t}),s(this).data=e}}),i(t.prototype,n),a(c,t,document.createTextNode("")),e.wrappers.CharacterData=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){return e>>>0}function n(e){r.call(this,e)}var r=e.wrappers.CharacterData,o=(e.enqueueMutation,e.mixin),i=e.registerWrapper,a=window.Text;n.prototype=Object.create(r.prototype),o(n.prototype,{splitText:function(e){e=t(e);var n=this.data;if(e>n.length)throw new Error("IndexSizeError");var r=n.slice(0,e),o=n.slice(e);this.data=r;var i=this.ownerDocument.createTextNode(o);return this.parentNode&&this.parentNode.insertBefore(i,this.nextSibling),i}}),i(a,n,document.createTextNode("")),e.wrappers.Text=n}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){return i(e).getAttribute("class")}function n(e,t){a(e,"attributes",{name:"class",namespace:null,oldValue:t})}function r(t){e.invalidateRendererBasedOnAttribute(t,"class")}function o(e,o,i){var a=e.ownerElement_;if(null==a)return o.apply(e,i);var s=t(a),c=o.apply(e,i);return t(a)!==s&&(n(a,s),r(a)),c}if(!window.DOMTokenList)return void console.warn("Missing DOMTokenList prototype, please include a compatible classList polyfill such as http://goo.gl/uTcepH.");var i=e.unsafeUnwrap,a=e.enqueueMutation,s=DOMTokenList.prototype.add;DOMTokenList.prototype.add=function(){o(this,s,arguments)};var c=DOMTokenList.prototype.remove;DOMTokenList.prototype.remove=function(){o(this,c,arguments)};var l=DOMTokenList.prototype.toggle;DOMTokenList.prototype.toggle=function(){return o(this,l,arguments)}}(window.ShadowDOMPolyfill),function(e){"use strict";function t(t,n){var r=t.parentNode;if(r&&r.shadowRoot){var o=e.getRendererForHost(r);o.dependsOnAttribute(n)&&o.invalidate()}}function n(e,t,n){u(e,"attributes",{name:t,namespace:null,oldValue:n})}function r(e){a.call(this,e)}var o=e.ChildNodeInterface,i=e.GetElementsByInterface,a=e.wrappers.Node,s=e.ParentNodeInterface,c=e.SelectorsInterface,l=e.MatchesInterface,u=(e.addWrapNodeListMethod,e.enqueueMutation),d=e.mixin,p=(e.oneOf,e.registerWrapper),f=e.unsafeUnwrap,h=e.wrappers,m=window.Element,w=["matches","mozMatchesSelector","msMatchesSelector","webkitMatchesSelector"].filter(function(e){return m.prototype[e]}),v=w[0],g=m.prototype[v],b=new WeakMap;r.prototype=Object.create(a.prototype),d(r.prototype,{createShadowRoot:function(){var t=new h.ShadowRoot(this);f(this).polymerShadowRoot_=t;var n=e.getRendererForHost(this);return n.invalidate(),t},get shadowRoot(){return f(this).polymerShadowRoot_||null},setAttribute:function(e,r){var o=f(this).getAttribute(e);f(this).setAttribute(e,r),n(this,e,o),t(this,e)},removeAttribute:function(e){var r=f(this).getAttribute(e);f(this).removeAttribute(e),n(this,e,r),t(this,e)},get classList(){var e=b.get(this);if(!e){if(e=f(this).classList,!e)return;e.ownerElement_=this,b.set(this,e)}return e},get className(){return f(this).className},set className(e){this.setAttribute("class",e)},get id(){return f(this).id},set id(e){this.setAttribute("id",e)}}),w.forEach(function(e){"matches"!==e&&(r.prototype[e]=function(e){return this.matches(e)})}),m.prototype.webkitCreateShadowRoot&&(r.prototype.webkitCreateShadowRoot=r.prototype.createShadowRoot),d(r.prototype,o),d(r.prototype,i),d(r.prototype,s),d(r.prototype,c),d(r.prototype,l),p(m,r,document.createElementNS(null,"x")),e.invalidateRendererBasedOnAttribute=t,e.matchesNames=w,e.originalMatches=g,e.wrappers.Element=r}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){switch(e){case"&":return"&";case"<":return"<";case">":return">";case'"':return""";case" ":return" "}}function n(e){return e.replace(L,t)}function r(e){return e.replace(O,t)}function o(e){for(var t={},n=0;n";case Node.TEXT_NODE:var u=e.data;return t&&N[t.localName]?u:r(u);case Node.COMMENT_NODE:return"";default:throw console.error(e),new Error("not implemented")}}function a(e){e instanceof _.HTMLTemplateElement&&(e=e.content);for(var t="",n=e.firstChild;n;n=n.nextSibling)t+=i(n,e);return t}function s(e,t,n){var r=n||"div";e.textContent="";var o=T(e.ownerDocument.createElement(r));o.innerHTML=t;for(var i;i=o.firstChild;)e.appendChild(M(i))}function c(e){h.call(this,e)}function l(e,t){var n=T(e.cloneNode(!1));n.innerHTML=t;for(var r,o=T(document.createDocumentFragment());r=n.firstChild;)o.appendChild(r);return M(o)}function u(t){return function(){return e.renderAllPending(),S(this)[t]}}function d(e){m(c,e,u(e))}function p(t){Object.defineProperty(c.prototype,t,{get:u(t),set:function(n){e.renderAllPending(),S(this)[t]=n},configurable:!0,enumerable:!0})}function f(t){Object.defineProperty(c.prototype,t,{value:function(){return e.renderAllPending(),S(this)[t].apply(S(this),arguments)},configurable:!0,enumerable:!0})}var h=e.wrappers.Element,m=e.defineGetter,w=e.enqueueMutation,v=e.mixin,g=e.nodesWereAdded,b=e.nodesWereRemoved,y=e.registerWrapper,E=e.snapshotNodeList,S=e.unsafeUnwrap,T=e.unwrap,M=e.wrap,_=e.wrappers,L=/[&\u00A0"]/g,O=/[&\u00A0<>]/g,C=o(["area","base","br","col","command","embed","hr","img","input","keygen","link","meta","param","source","track","wbr"]),N=o(["style","script","xmp","iframe","noembed","noframes","plaintext","noscript"]),D=/MSIE/.test(navigator.userAgent),j=window.HTMLElement,H=window.HTMLTemplateElement;c.prototype=Object.create(h.prototype),v(c.prototype,{get innerHTML(){return a(this)},set innerHTML(e){if(D&&N[this.localName])return void(this.textContent=e);var t=E(this.childNodes);this.invalidateShadowRenderer()?this instanceof _.HTMLTemplateElement?s(this.content,e):s(this,e,this.tagName):!H&&this instanceof _.HTMLTemplateElement?s(this.content,e):S(this).innerHTML=e;var n=E(this.childNodes);w(this,"childList",{addedNodes:n,removedNodes:t}),b(t),g(n,this)},get outerHTML(){return i(this,this.parentNode)},set outerHTML(e){var t=this.parentNode;if(t){t.invalidateShadowRenderer();var n=l(t,e);t.replaceChild(n,this)}},insertAdjacentHTML:function(e,t){var n,r;switch(String(e).toLowerCase()){case"beforebegin":n=this.parentNode,r=this;break;case"afterend":n=this.parentNode,r=this.nextSibling;break;case"afterbegin":n=this,r=this.firstChild;break;case"beforeend":n=this,r=null;break;default:return}var o=l(n,t);n.insertBefore(o,r)},get hidden(){return this.hasAttribute("hidden")},set hidden(e){e?this.setAttribute("hidden",""):this.removeAttribute("hidden")}}),["clientHeight","clientLeft","clientTop","clientWidth","offsetHeight","offsetLeft","offsetTop","offsetWidth","scrollHeight","scrollWidth"].forEach(d),["scrollLeft","scrollTop"].forEach(p),["getBoundingClientRect","getClientRects","scrollIntoView"].forEach(f),y(j,c,document.createElement("b")),e.wrappers.HTMLElement=c,e.getInnerHTML=a,e.setInnerHTML=s}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.unsafeUnwrap,a=e.wrap,s=window.HTMLCanvasElement;t.prototype=Object.create(n.prototype),r(t.prototype,{getContext:function(){var e=i(this).getContext.apply(i(this),arguments);return e&&a(e)}}),o(s,t,document.createElement("canvas")),e.wrappers.HTMLCanvasElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=window.HTMLContentElement;t.prototype=Object.create(n.prototype),r(t.prototype,{constructor:t,get select(){return this.getAttribute("select")},set select(e){this.setAttribute("select",e)},setAttribute:function(e,t){n.prototype.setAttribute.call(this,e,t),"select"===String(e).toLowerCase()&&this.invalidateShadowRenderer(!0)}}),i&&o(i,t),e.wrappers.HTMLContentElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.wrapHTMLCollection,a=e.unwrap,s=window.HTMLFormElement;t.prototype=Object.create(n.prototype),r(t.prototype,{get elements(){return i(a(this).elements)}}),o(s,t,document.createElement("form")),e.wrappers.HTMLFormElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r.call(this,e)}function n(e,t){if(!(this instanceof n))throw new TypeError("DOM object constructor cannot be called as a function.");var o=i(document.createElement("img"));r.call(this,o),a(o,this),void 0!==e&&(o.width=e),void 0!==t&&(o.height=t)}var r=e.wrappers.HTMLElement,o=e.registerWrapper,i=e.unwrap,a=e.rewrap,s=window.HTMLImageElement;t.prototype=Object.create(r.prototype),o(s,t,document.createElement("img")),n.prototype=t.prototype,e.wrappers.HTMLImageElement=t,e.wrappers.Image=n}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=(e.mixin,e.wrappers.NodeList,e.registerWrapper),o=window.HTMLShadowElement;t.prototype=Object.create(n.prototype),t.prototype.constructor=t,o&&r(o,t),e.wrappers.HTMLShadowElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){if(!e.defaultView)return e;var t=d.get(e);if(!t){for(t=e.implementation.createHTMLDocument("");t.lastChild;)t.removeChild(t.lastChild);d.set(e,t)}return t}function n(e){for(var n,r=t(e.ownerDocument),o=c(r.createDocumentFragment());n=e.firstChild;)o.appendChild(n);return o}function r(e){if(o.call(this,e),!p){var t=n(e);u.set(this,l(t))}}var o=e.wrappers.HTMLElement,i=e.mixin,a=e.registerWrapper,s=e.unsafeUnwrap,c=e.unwrap,l=e.wrap,u=new WeakMap,d=new WeakMap,p=window.HTMLTemplateElement;r.prototype=Object.create(o.prototype),i(r.prototype,{constructor:r,get content(){return p?l(s(this).content):u.get(this)}}),p&&a(p,r),e.wrappers.HTMLTemplateElement=r}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.registerWrapper,o=window.HTMLMediaElement;o&&(t.prototype=Object.create(n.prototype),r(o,t,document.createElement("audio")),e.wrappers.HTMLMediaElement=t)}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r.call(this,e)}function n(e){if(!(this instanceof n))throw new TypeError("DOM object constructor cannot be called as a function.");var t=i(document.createElement("audio"));r.call(this,t),a(t,this),t.setAttribute("preload","auto"),void 0!==e&&t.setAttribute("src",e)}var r=e.wrappers.HTMLMediaElement,o=e.registerWrapper,i=e.unwrap,a=e.rewrap,s=window.HTMLAudioElement;s&&(t.prototype=Object.create(r.prototype),o(s,t,document.createElement("audio")),n.prototype=t.prototype,e.wrappers.HTMLAudioElement=t,e.wrappers.Audio=n)}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){return e.replace(/\s+/g," ").trim()}function n(e){o.call(this,e)}function r(e,t,n,i){if(!(this instanceof r))throw new TypeError("DOM object constructor cannot be called as a function.");var a=c(document.createElement("option"));o.call(this,a),s(a,this),void 0!==e&&(a.text=e),void 0!==t&&a.setAttribute("value",t),n===!0&&a.setAttribute("selected",""),a.selected=i===!0}var o=e.wrappers.HTMLElement,i=e.mixin,a=e.registerWrapper,s=e.rewrap,c=e.unwrap,l=e.wrap,u=window.HTMLOptionElement;n.prototype=Object.create(o.prototype),i(n.prototype,{get text(){return t(this.textContent)},set text(e){this.textContent=t(String(e))},get form(){return l(c(this).form)}}),a(u,n,document.createElement("option")),r.prototype=n.prototype,e.wrappers.HTMLOptionElement=n,e.wrappers.Option=r}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.unwrap,a=e.wrap,s=window.HTMLSelectElement;t.prototype=Object.create(n.prototype),r(t.prototype,{add:function(e,t){"object"==typeof t&&(t=i(t)),i(this).add(i(e),t)},remove:function(e){return void 0===e?void n.prototype.remove.call(this):("object"==typeof e&&(e=i(e)),void i(this).remove(e))},get form(){return a(i(this).form)}}),o(s,t,document.createElement("select")),e.wrappers.HTMLSelectElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.unwrap,a=e.wrap,s=e.wrapHTMLCollection,c=window.HTMLTableElement;t.prototype=Object.create(n.prototype),r(t.prototype,{get caption(){return a(i(this).caption)},createCaption:function(){return a(i(this).createCaption())},get tHead(){return a(i(this).tHead)},createTHead:function(){return a(i(this).createTHead())},createTFoot:function(){return a(i(this).createTFoot())},get tFoot(){return a(i(this).tFoot)},get tBodies(){return s(i(this).tBodies)},createTBody:function(){return a(i(this).createTBody())},get rows(){return s(i(this).rows)},insertRow:function(e){return a(i(this).insertRow(e))}}),o(c,t,document.createElement("table")),e.wrappers.HTMLTableElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.wrapHTMLCollection,a=e.unwrap,s=e.wrap,c=window.HTMLTableSectionElement;t.prototype=Object.create(n.prototype),r(t.prototype,{constructor:t,get rows(){return i(a(this).rows)},insertRow:function(e){return s(a(this).insertRow(e))}}),o(c,t,document.createElement("thead")),e.wrappers.HTMLTableSectionElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.wrapHTMLCollection,a=e.unwrap,s=e.wrap,c=window.HTMLTableRowElement;t.prototype=Object.create(n.prototype),r(t.prototype,{get cells(){return i(a(this).cells)},insertCell:function(e){return s(a(this).insertCell(e))}}),o(c,t,document.createElement("tr")),e.wrappers.HTMLTableRowElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){switch(e.localName){case"content":return new n(e);case"shadow":return new o(e);case"template":return new i(e)}r.call(this,e)}var n=e.wrappers.HTMLContentElement,r=e.wrappers.HTMLElement,o=e.wrappers.HTMLShadowElement,i=e.wrappers.HTMLTemplateElement,a=(e.mixin,e.registerWrapper),s=window.HTMLUnknownElement;t.prototype=Object.create(r.prototype),a(s,t),e.wrappers.HTMLUnknownElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.wrappers.Element,n=e.wrappers.HTMLElement,r=e.registerObject,o=e.defineWrapGetter,i="http://www.w3.org/2000/svg",a=document.createElementNS(i,"title"),s=r(a),c=Object.getPrototypeOf(s.prototype).constructor;if(!("classList"in a)){var l=Object.getOwnPropertyDescriptor(t.prototype,"classList");Object.defineProperty(n.prototype,"classList",l),delete t.prototype.classList}o(c,"ownerSVGElement"),e.wrappers.SVGElement=c}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){p.call(this,e)}var n=e.mixin,r=e.registerWrapper,o=e.unwrap,i=e.wrap,a=window.SVGUseElement,s="http://www.w3.org/2000/svg",c=i(document.createElementNS(s,"g")),l=document.createElementNS(s,"use"),u=c.constructor,d=Object.getPrototypeOf(u.prototype),p=d.constructor;t.prototype=Object.create(d),"instanceRoot"in l&&n(t.prototype,{get instanceRoot(){return i(o(this).instanceRoot)},get animatedInstanceRoot(){return i(o(this).animatedInstanceRoot)}}),r(a,t,l),e.wrappers.SVGUseElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.EventTarget,r=e.mixin,o=e.registerWrapper,i=e.unsafeUnwrap,a=e.wrap,s=window.SVGElementInstance;s&&(t.prototype=Object.create(n.prototype),r(t.prototype,{get correspondingElement(){return a(i(this).correspondingElement)},get correspondingUseElement(){return a(i(this).correspondingUseElement)},get parentNode(){return a(i(this).parentNode)},get childNodes(){throw new Error("Not implemented")},get firstChild(){return a(i(this).firstChild)},get lastChild(){return a(i(this).lastChild)},get previousSibling(){return a(i(this).previousSibling)},get nextSibling(){return a(i(this).nextSibling)}}),o(s,t),e.wrappers.SVGElementInstance=t)}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){o(e,this)}var n=e.mixin,r=e.registerWrapper,o=e.setWrapper,i=e.unsafeUnwrap,a=e.unwrap,s=e.unwrapIfNeeded,c=e.wrap,l=window.CanvasRenderingContext2D;n(t.prototype,{get canvas(){return c(i(this).canvas)},drawImage:function(){arguments[0]=s(arguments[0]),i(this).drawImage.apply(i(this),arguments)},createPattern:function(){return arguments[0]=a(arguments[0]),i(this).createPattern.apply(i(this),arguments)}}),r(l,t,document.createElement("canvas").getContext("2d")),e.wrappers.CanvasRenderingContext2D=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){o(e,this)}var n=e.mixin,r=e.registerWrapper,o=e.setWrapper,i=e.unsafeUnwrap,a=e.unwrapIfNeeded,s=e.wrap,c=window.WebGLRenderingContext;if(c){n(t.prototype,{get canvas(){return s(i(this).canvas)},texImage2D:function(){arguments[5]=a(arguments[5]),i(this).texImage2D.apply(i(this),arguments)},texSubImage2D:function(){arguments[6]=a(arguments[6]),i(this).texSubImage2D.apply(i(this),arguments)}});var l=/WebKit/.test(navigator.userAgent)?{drawingBufferHeight:null,drawingBufferWidth:null}:{};r(c,t,l),e.wrappers.WebGLRenderingContext=t}}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r(e,this)}var n=e.registerWrapper,r=e.setWrapper,o=e.unsafeUnwrap,i=e.unwrap,a=e.unwrapIfNeeded,s=e.wrap,c=window.Range;t.prototype={get startContainer(){return s(o(this).startContainer)},get endContainer(){return s(o(this).endContainer)},get commonAncestorContainer(){return s(o(this).commonAncestorContainer)},setStart:function(e,t){o(this).setStart(a(e),t)},setEnd:function(e,t){o(this).setEnd(a(e),t)},setStartBefore:function(e){o(this).setStartBefore(a(e))},setStartAfter:function(e){o(this).setStartAfter(a(e))},setEndBefore:function(e){o(this).setEndBefore(a(e))},setEndAfter:function(e){o(this).setEndAfter(a(e))},selectNode:function(e){o(this).selectNode(a(e))},selectNodeContents:function(e){o(this).selectNodeContents(a(e))},compareBoundaryPoints:function(e,t){return o(this).compareBoundaryPoints(e,i(t))},extractContents:function(){return s(o(this).extractContents())},cloneContents:function(){return s(o(this).cloneContents())},insertNode:function(e){o(this).insertNode(a(e))},surroundContents:function(e){o(this).surroundContents(a(e))},cloneRange:function(){return s(o(this).cloneRange())},isPointInRange:function(e,t){return o(this).isPointInRange(a(e),t)},comparePoint:function(e,t){return o(this).comparePoint(a(e),t)},intersectsNode:function(e){return o(this).intersectsNode(a(e))},toString:function(){return o(this).toString()}},c.prototype.createContextualFragment&&(t.prototype.createContextualFragment=function(e){return s(o(this).createContextualFragment(e))}),n(window.Range,t,document.createRange()),e.wrappers.Range=t}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.GetElementsByInterface,n=e.ParentNodeInterface,r=e.SelectorsInterface,o=e.mixin,i=e.registerObject,a=i(document.createDocumentFragment());o(a.prototype,n),o(a.prototype,r),o(a.prototype,t);var s=i(document.createComment(""));e.wrappers.Comment=s,e.wrappers.DocumentFragment=a}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){var t=d(u(e).ownerDocument.createDocumentFragment());n.call(this,t),c(t,this);var o=e.shadowRoot;f.set(this,o),this.treeScope_=new r(this,a(o||e)),p.set(this,e)}var n=e.wrappers.DocumentFragment,r=e.TreeScope,o=e.elementFromPoint,i=e.getInnerHTML,a=e.getTreeScope,s=e.mixin,c=e.rewrap,l=e.setInnerHTML,u=e.unsafeUnwrap,d=e.unwrap,p=new WeakMap,f=new WeakMap,h=/[ \t\n\r\f]/;t.prototype=Object.create(n.prototype),s(t.prototype,{constructor:t,get innerHTML(){return i(this)},set innerHTML(e){l(this,e),this.invalidateShadowRenderer()},get olderShadowRoot(){return f.get(this)||null},get host(){return p.get(this)||null},invalidateShadowRenderer:function(){return p.get(this).invalidateShadowRenderer()},elementFromPoint:function(e,t){return o(this,this.ownerDocument,e,t)},getElementById:function(e){return h.test(e)?null:this.querySelector('[id="'+e+'"]')}}),e.wrappers.ShadowRoot=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){e.previousSibling_=e.previousSibling,e.nextSibling_=e.nextSibling,e.parentNode_=e.parentNode}function n(n,o,i){var a=x(n),s=x(o),c=i?x(i):null;if(r(o),t(o),i)n.firstChild===i&&(n.firstChild_=i),i.previousSibling_=i.previousSibling;else{n.lastChild_=n.lastChild,n.lastChild===n.firstChild&&(n.firstChild_=n.firstChild);var l=R(a.lastChild);l&&(l.nextSibling_=l.nextSibling)}e.originalInsertBefore.call(a,s,c)}function r(n){var r=x(n),o=r.parentNode;if(o){var i=R(o);t(n),n.previousSibling&&(n.previousSibling.nextSibling_=n),n.nextSibling&&(n.nextSibling.previousSibling_=n),i.lastChild===n&&(i.lastChild_=n),i.firstChild===n&&(i.firstChild_=n),e.originalRemoveChild.call(o,r)}}function o(e){I.set(e,[])}function i(e){var t=I.get(e);return t||I.set(e,t=[]),t}function a(e){for(var t=[],n=0,r=e.firstChild;r;r=r.nextSibling)t[n++]=r;return t}function s(){for(var e=0;em;m++){var w=R(i[u++]);s.get(w)||r(w)}for(var v=f.addedCount,g=i[u]&&R(i[u]),m=0;v>m;m++){var b=o[l++],y=b.node;n(t,y,g),s.set(y,!0),b.sync(s)}d+=v}for(var p=d;p=0;o--){var i=r[o],a=m(i);if(a){var s=i.olderShadowRoot;s&&(n=h(s));for(var c=0;c=0;u--)l=Object.create(l);["createdCallback","attachedCallback","detachedCallback","attributeChangedCallback"].forEach(function(e){var t=o[e];t&&(l[e]=function(){C(this)instanceof r||M(this),t.apply(C(this),arguments)})});var d={prototype:l};i&&(d["extends"]=i),r.prototype=o,r.prototype.constructor=r,e.constructorTable.set(l,r),e.nativePrototypeTable.set(o,l);x.call(O(this),t,d);return r},b([window.HTMLDocument||window.Document],["registerElement"])}b([window.HTMLBodyElement,window.HTMLDocument||window.Document,window.HTMLHeadElement,window.HTMLHtmlElement],["appendChild","compareDocumentPosition","contains","getElementsByClassName","getElementsByTagName","getElementsByTagNameNS","insertBefore","querySelector","querySelectorAll","removeChild","replaceChild"]),b([window.HTMLBodyElement,window.HTMLHeadElement,window.HTMLHtmlElement],y),b([window.HTMLDocument||window.Document],["adoptNode","importNode","contains","createComment","createDocumentFragment","createElement","createElementNS","createEvent","createEventNS","createRange","createTextNode","elementFromPoint","getElementById","getElementsByName","getSelection"]),E(t.prototype,l),E(t.prototype,d),E(t.prototype,f),E(t.prototype,{get implementation(){var e=D.get(this); -return e?e:(e=new a(O(this).implementation),D.set(this,e),e)},get defaultView(){return C(O(this).defaultView)}}),S(window.Document,t,document.implementation.createHTMLDocument("")),window.HTMLDocument&&S(window.HTMLDocument,t),N([window.HTMLBodyElement,window.HTMLDocument||window.Document,window.HTMLHeadElement]),s(a,"createDocumentType"),s(a,"createDocument"),s(a,"createHTMLDocument"),c(a,"hasFeature"),S(window.DOMImplementation,a),b([window.DOMImplementation],["createDocumentType","createDocument","createHTMLDocument","hasFeature"]),e.adoptNodeNoRemove=r,e.wrappers.DOMImplementation=a,e.wrappers.Document=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.EventTarget,r=e.wrappers.Selection,o=e.mixin,i=e.registerWrapper,a=e.renderAllPending,s=e.unwrap,c=e.unwrapIfNeeded,l=e.wrap,u=window.Window,d=window.getComputedStyle,p=window.getDefaultComputedStyle,f=window.getSelection;t.prototype=Object.create(n.prototype),u.prototype.getComputedStyle=function(e,t){return l(this||window).getComputedStyle(c(e),t)},p&&(u.prototype.getDefaultComputedStyle=function(e,t){return l(this||window).getDefaultComputedStyle(c(e),t)}),u.prototype.getSelection=function(){return l(this||window).getSelection()},delete window.getComputedStyle,delete window.getDefaultComputedStyle,delete window.getSelection,["addEventListener","removeEventListener","dispatchEvent"].forEach(function(e){u.prototype[e]=function(){var t=l(this||window);return t[e].apply(t,arguments)},delete window[e]}),o(t.prototype,{getComputedStyle:function(e,t){return a(),d.call(s(this),c(e),t)},getSelection:function(){return a(),new r(f.call(s(this)))},get document(){return l(s(this).document)}}),p&&(t.prototype.getDefaultComputedStyle=function(e,t){return a(),p.call(s(this),c(e),t)}),i(u,t,window),e.wrappers.Window=t}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.unwrap,n=window.DataTransfer||window.Clipboard,r=n.prototype.setDragImage;r&&(n.prototype.setDragImage=function(e,n,o){r.call(this,t(e),n,o)})}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){var t;t=e instanceof i?e:new i(e&&o(e)),r(t,this)}var n=e.registerWrapper,r=e.setWrapper,o=e.unwrap,i=window.FormData;i&&(n(i,t,new i),e.wrappers.FormData=t)}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.unwrapIfNeeded,n=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.send=function(e){return n.call(this,t(e))}}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){var t=n[e],r=window[t];if(r){var o=document.createElement(e),i=o.constructor;window[t]=i}}var n=(e.isWrapperFor,{a:"HTMLAnchorElement",area:"HTMLAreaElement",audio:"HTMLAudioElement",base:"HTMLBaseElement",body:"HTMLBodyElement",br:"HTMLBRElement",button:"HTMLButtonElement",canvas:"HTMLCanvasElement",caption:"HTMLTableCaptionElement",col:"HTMLTableColElement",content:"HTMLContentElement",data:"HTMLDataElement",datalist:"HTMLDataListElement",del:"HTMLModElement",dir:"HTMLDirectoryElement",div:"HTMLDivElement",dl:"HTMLDListElement",embed:"HTMLEmbedElement",fieldset:"HTMLFieldSetElement",font:"HTMLFontElement",form:"HTMLFormElement",frame:"HTMLFrameElement",frameset:"HTMLFrameSetElement",h1:"HTMLHeadingElement",head:"HTMLHeadElement",hr:"HTMLHRElement",html:"HTMLHtmlElement",iframe:"HTMLIFrameElement",img:"HTMLImageElement",input:"HTMLInputElement",keygen:"HTMLKeygenElement",label:"HTMLLabelElement",legend:"HTMLLegendElement",li:"HTMLLIElement",link:"HTMLLinkElement",map:"HTMLMapElement",marquee:"HTMLMarqueeElement",menu:"HTMLMenuElement",menuitem:"HTMLMenuItemElement",meta:"HTMLMetaElement",meter:"HTMLMeterElement",object:"HTMLObjectElement",ol:"HTMLOListElement",optgroup:"HTMLOptGroupElement",option:"HTMLOptionElement",output:"HTMLOutputElement",p:"HTMLParagraphElement",param:"HTMLParamElement",pre:"HTMLPreElement",progress:"HTMLProgressElement",q:"HTMLQuoteElement",script:"HTMLScriptElement",select:"HTMLSelectElement",shadow:"HTMLShadowElement",source:"HTMLSourceElement",span:"HTMLSpanElement",style:"HTMLStyleElement",table:"HTMLTableElement",tbody:"HTMLTableSectionElement",template:"HTMLTemplateElement",textarea:"HTMLTextAreaElement",thead:"HTMLTableSectionElement",time:"HTMLTimeElement",title:"HTMLTitleElement",tr:"HTMLTableRowElement",track:"HTMLTrackElement",ul:"HTMLUListElement",video:"HTMLVideoElement"});Object.keys(n).forEach(t),Object.getOwnPropertyNames(e.wrappers).forEach(function(t){window[t]=e.wrappers[t]})}(window.ShadowDOMPolyfill),function(e){function t(e,t){var n="";return Array.prototype.forEach.call(e,function(e){n+=e.textContent+"\n\n"}),t||(n=n.replace(d,"")),n}function n(e){var t=document.createElement("style");return t.textContent=e,t}function r(e){var t=n(e);document.head.appendChild(t);var r=[];if(t.sheet)try{r=t.sheet.cssRules}catch(o){}else console.warn("sheet not found",t);return t.parentNode.removeChild(t),r}function o(){N.initialized=!0,document.body.appendChild(N);var e=N.contentDocument,t=e.createElement("base");t.href=document.baseURI,e.head.appendChild(t)}function i(e){N.initialized||o(),document.body.appendChild(N),e(N.contentDocument),document.body.removeChild(N)}function a(e,t){if(t){var o;if(e.match("@import")&&j){var a=n(e);i(function(e){e.head.appendChild(a.impl),o=Array.prototype.slice.call(a.sheet.cssRules,0),t(o)})}else o=r(e),t(o)}}function s(e){e&&l().appendChild(document.createTextNode(e))}function c(e,t){var r=n(e);r.setAttribute(t,""),r.setAttribute(x,""),document.head.appendChild(r)}function l(){return D||(D=document.createElement("style"),D.setAttribute(x,""),D[x]=!0),D}var u={strictStyling:!1,registry:{},shimStyling:function(e,n,r){var o=this.prepareRoot(e,n,r),i=this.isTypeExtension(r),a=this.makeScopeSelector(n,i),s=t(o,!0);s=this.scopeCssText(s,a),e&&(e.shimmedStyle=s),this.addCssToDocument(s,n)},shimStyle:function(e,t){return this.shimCssText(e.textContent,t)},shimCssText:function(e,t){return e=this.insertDirectives(e),this.scopeCssText(e,t)},makeScopeSelector:function(e,t){return e?t?"[is="+e+"]":e:""},isTypeExtension:function(e){return e&&e.indexOf("-")<0},prepareRoot:function(e,t,n){var r=this.registerRoot(e,t,n);return this.replaceTextInStyles(r.rootStyles,this.insertDirectives),this.removeStyles(e,r.rootStyles),this.strictStyling&&this.applyScopeToContent(e,t),r.scopeStyles},removeStyles:function(e,t){for(var n,r=0,o=t.length;o>r&&(n=t[r]);r++)n.parentNode.removeChild(n)},registerRoot:function(e,t,n){var r=this.registry[t]={root:e,name:t,extendsName:n},o=this.findStyles(e);r.rootStyles=o,r.scopeStyles=r.rootStyles;var i=this.registry[r.extendsName];return i&&(r.scopeStyles=i.scopeStyles.concat(r.scopeStyles)),r},findStyles:function(e){if(!e)return[];var t=e.querySelectorAll("style");return Array.prototype.filter.call(t,function(e){return!e.hasAttribute(R)})},applyScopeToContent:function(e,t){e&&(Array.prototype.forEach.call(e.querySelectorAll("*"),function(e){e.setAttribute(t,"")}),Array.prototype.forEach.call(e.querySelectorAll("template"),function(e){this.applyScopeToContent(e.content,t)},this))},insertDirectives:function(e){return e=this.insertPolyfillDirectivesInCssText(e),this.insertPolyfillRulesInCssText(e)},insertPolyfillDirectivesInCssText:function(e){return e=e.replace(p,function(e,t){return t.slice(0,-2)+"{"}),e.replace(f,function(e,t){return t+" {"})},insertPolyfillRulesInCssText:function(e){return e=e.replace(h,function(e,t){return t.slice(0,-1)}),e.replace(m,function(e,t,n,r){var o=e.replace(t,"").replace(n,"");return r+o})},scopeCssText:function(e,t){var n=this.extractUnscopedRulesFromCssText(e);if(e=this.insertPolyfillHostInCssText(e),e=this.convertColonHost(e),e=this.convertColonHostContext(e),e=this.convertShadowDOMSelectors(e),t){var e,r=this;a(e,function(n){e=r.scopeRules(n,t)})}return e=e+"\n"+n,e.trim()},extractUnscopedRulesFromCssText:function(e){for(var t,n="";t=w.exec(e);)n+=t[1].slice(0,-1)+"\n\n";for(;t=v.exec(e);)n+=t[0].replace(t[2],"").replace(t[1],t[3])+"\n\n";return n},convertColonHost:function(e){return this.convertColonRule(e,E,this.colonHostPartReplacer)},convertColonHostContext:function(e){return this.convertColonRule(e,S,this.colonHostContextPartReplacer)},convertColonRule:function(e,t,n){return e.replace(t,function(e,t,r,o){if(t=L,r){for(var i,a=r.split(","),s=[],c=0,l=a.length;l>c&&(i=a[c]);c++)i=i.trim(),s.push(n(t,i,o));return s.join(",")}return t+o})},colonHostContextPartReplacer:function(e,t,n){return t.match(g)?this.colonHostPartReplacer(e,t,n):e+t+n+", "+t+" "+e+n},colonHostPartReplacer:function(e,t,n){return e+t.replace(g,"")+n},convertShadowDOMSelectors:function(e){for(var t=0;t","+","~"],r=e,o="["+t+"]";return n.forEach(function(e){var t=r.split(e);r=t.map(function(e){var t=e.trim().replace(O,"");return t&&n.indexOf(t)<0&&t.indexOf(o)<0&&(e=t.replace(/([^:]*)(:*)(.*)/,"$1"+o+"$2$3")),e}).join(e)}),r},insertPolyfillHostInCssText:function(e){return e.replace(_,b).replace(M,g)},propertiesFromRule:function(e){var t=e.style.cssText;e.style.content&&!e.style.content.match(/['"]+|attr/)&&(t=t.replace(/content:[^;]*;/g,"content: '"+e.style.content+"';"));var n=e.style;for(var r in n)"initial"===n[r]&&(t+=r+": initial; ");return t},replaceTextInStyles:function(e,t){e&&t&&(e instanceof Array||(e=[e]),Array.prototype.forEach.call(e,function(e){e.textContent=t.call(this,e.textContent)},this))},addCssToDocument:function(e,t){e.match("@import")?c(e,t):s(e)}},d=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//gim,p=/\/\*\s*@polyfill ([^*]*\*+([^/*][^*]*\*+)*\/)([^{]*?){/gim,f=/polyfill-next-selector[^}]*content\:[\s]*?['"](.*?)['"][;\s]*}([^{]*?){/gim,h=/\/\*\s@polyfill-rule([^*]*\*+([^/*][^*]*\*+)*)\//gim,m=/(polyfill-rule)[^}]*(content\:[\s]*['"](.*?)['"])[;\s]*[^}]*}/gim,w=/\/\*\s@polyfill-unscoped-rule([^*]*\*+([^/*][^*]*\*+)*)\//gim,v=/(polyfill-unscoped-rule)[^}]*(content\:[\s]*['"](.*?)['"])[;\s]*[^}]*}/gim,g="-shadowcsshost",b="-shadowcsscontext",y=")(?:\\(((?:\\([^)(]*\\)|[^)(]*)+?)\\))?([^,{]*)",E=new RegExp("("+g+y,"gim"),S=new RegExp("("+b+y,"gim"),T="([>\\s~+[.,{:][\\s\\S]*)?$",M=/\:host/gim,_=/\:host-context/gim,L=g+"-no-combinator",O=new RegExp(g,"gim"),C=(new RegExp(b,"gim"),[/\^\^/g,/\^/g,/\/shadow\//g,/\/shadow-deep\//g,/::shadow/g,/\/deep\//g,/::content/g]),N=document.createElement("iframe");N.style.display="none";var D,j=navigator.userAgent.match("Chrome"),H="shim-shadowdom",x="shim-shadowdom-css",R="no-shim";if(window.ShadowDOMPolyfill){s("style { display: none !important; }\n");var P=ShadowDOMPolyfill.wrap(document),I=P.querySelector("head");I.insertBefore(l(),I.childNodes[0]),document.addEventListener("DOMContentLoaded",function(){e.urlResolver;if(window.HTMLImports&&!HTMLImports.useNative){var t="link[rel=stylesheet]["+H+"]",n="style["+H+"]";HTMLImports.importer.documentPreloadSelectors+=","+t,HTMLImports.importer.importsPreloadSelectors+=","+t,HTMLImports.parser.documentSelectors=[HTMLImports.parser.documentSelectors,t,n].join(",");var r=HTMLImports.parser.parseGeneric;HTMLImports.parser.parseGeneric=function(e){if(!e[x]){var t=e.__importElement||e;if(!t.hasAttribute(H))return void r.call(this,e);e.__resource&&(t=e.ownerDocument.createElement("style"),t.textContent=e.__resource),HTMLImports.path.resolveUrlsInStyle(t),t.textContent=u.shimStyle(t),t.removeAttribute(H,""),t.setAttribute(x,""),t[x]=!0,t.parentNode!==I&&(e.parentNode===I?I.replaceChild(t,e):this.addElementToDocument(t)),t.__importParsed=!0,this.markParsingComplete(e),this.parseNext()}};var o=HTMLImports.parser.hasResource;HTMLImports.parser.hasResource=function(e){return"link"===e.localName&&"stylesheet"===e.rel&&e.hasAttribute(H)?e.__resource:o.call(this,e)}}})}e.ShadowCSS=u}(window.WebComponents)),function(){window.ShadowDOMPolyfill?(window.wrap=ShadowDOMPolyfill.wrapIfNeeded,window.unwrap=ShadowDOMPolyfill.unwrapIfNeeded):window.wrap=window.unwrap=function(e){return e}}(window.WebComponents),window.HTMLImports=window.HTMLImports||{flags:{}},function(e){function t(e,t){t=t||h,r(function(){i(e,t)},t)}function n(e){return"complete"===e.readyState||e.readyState===v}function r(e,t){if(n(t))e&&e();else{var o=function(){("complete"===t.readyState||t.readyState===v)&&(t.removeEventListener(g,o),r(e,t))};t.addEventListener(g,o)}}function o(e){e.target.__loaded=!0}function i(e,t){function n(){s==c&&e&&e()}function r(e){o(e),s++,n()}var i=t.querySelectorAll("link[rel=import]"),s=0,c=i.length;if(c)for(var l,u=0;c>u&&(l=i[u]);u++)a(l)?r.call(l,{target:l}):(l.addEventListener("load",r),l.addEventListener("error",r));else n()}function a(e){return d?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)&&l(t)}function c(e){return"link"===e.localName&&"import"===e.rel}function l(e){var t=e["import"];t?o({target:e}):(e.addEventListener("load",o),e.addEventListener("error",o))}var u="import",d=Boolean(u in document.createElement("link")),p=Boolean(window.ShadowDOMPolyfill),f=function(e){return p?ShadowDOMPolyfill.wrapIfNeeded(e):e},h=f(document),m={get:function(){var e=HTMLImports.currentScript||document.currentScript||("complete"!==document.readyState?document.scripts[document.scripts.length-1]:null);return f(e)},configurable:!0};Object.defineProperty(document,"_currentScript",m),Object.defineProperty(h,"_currentScript",m);var w=/Trident|Edge/.test(navigator.userAgent),v=w?"complete":"interactive",g="readystatechange";d&&(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++)l(e)}()),t(function(){HTMLImports.ready=!0,HTMLImports.readyTime=(new Date).getTime();var e=h.createEvent("CustomEvent");e.initCustomEvent("HTMLImportsLoaded",!0,!0,{}),h.dispatchEvent(e)}),e.IMPORT_LINK_TYPE=u,e.useNative=d,e.rootDocument=h,e.whenReady=t,e.isIE=w}(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}(HTMLImports),HTMLImports.addModule(function(e){var t=/(url\()([^)]*)(\))/g,n=/(@import[\s]+(?!url\())([^;]*)(;)/g,r={resolveUrlsInStyle:function(e){var t=e.ownerDocument,n=t.createElement("a");return e.textContent=this.resolveUrlsInCssText(e.textContent,n),e},resolveUrlsInCssText:function(e,r){var o=this.replaceUrls(e,r,t);return o=this.replaceUrls(o,r,n)},replaceUrls:function(e,t,n){return e.replace(n,function(e,n,r,o){var i=r.replace(/["']/g,"");return t.href=i,i=t.href,n+"'"+i+"'"+o})}};e.path=r}),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(){if(4===i.readyState){var e=i.getResponseHeader("Location"),n=null;if(e)var n="/"===e.substr(0,1)?location.origin+e:e;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}),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}),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}),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,l=e.isIE,u=e.IMPORT_LINK_TYPE,d="link[rel="+u+"]",p={documentSelectors:d,importsSelectors:[d,"link[rel=stylesheet]","style","script:not([type])",'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(HTMLImports.__importsParsingHook&&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),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(r){t&&t(r),n.markParsingComplete(e),n.parseNext()};if(e.addEventListener("load",r),e.addEventListener("error",r),l&&"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&&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(){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["import"],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["import"]?!1:!0}};e.parser=p,e.IMPORT_SELECTOR=d}),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,l=e.Loader,u=e.Observer,d=e.parser,p={documents:{},documentPreloadSelectors:s,importsPreloadSelectors:[s].join(","),loadNode:function(e){f.addNode(e)},loadSubtree:function(e){var t=this.marshalNodes(e);f.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["import"]=c}d.parseNext()},bootDocument:function(e){this.loadSubtree(e),this.observer.observe(e),d.parseNext()},loadedAll:function(){d.parseNext()}},f=new l(p.loaded.bind(p),p.loadedAll.bind(p));if(p.observer=new u,!document.baseURI){var h={get:function(){var e=document.querySelector("base");return e?e.href:window.location.href},configurable:!0};Object.defineProperty(document,"baseURI",h),Object.defineProperty(c,"baseURI",h)}e.importer=p,e.importLoader=f}),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(){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},window.CustomEvent.prototype=window.Event.prototype),n();var o=e.rootDocument;"complete"===document.readyState||"interactive"===document.readyState&&!window.attachEvent?t():document.addEventListener("DOMContentLoaded",t)}}(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.useNative=!t.register&&e.hasNative&&!window.ShadowDOMPolyfill&&(!window.HTMLImports||HTMLImports.useNative)}(CustomElements),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){a=[],i(e,t),a=null}function i(e,t){if(e=wrap(e),!(a.indexOf(e)>=0)){a.push(e);for(var n,r=e.querySelectorAll("link[rel="+s+"]"),o=0,c=r.length;c>o&&(n=r[o]);o++)n["import"]&&i(n["import"],t);t(e)}}var a,s=window.HTMLImports?HTMLImports.IMPORT_LINK_TYPE:"none";e.forDocumentTree=o,e.forSubtree=t}),CustomElements.addModule(function(e){function t(e){return n(e)||r(e)}function n(t){return e.upgrade(t)?!0:void s(t)}function r(e){y(e,function(e){return n(e)?!0:void 0})}function o(e){s(e),p(e)&&y(e,function(e){s(e)})}function i(e){M.push(e),T||(T=!0,setTimeout(a))}function a(){T=!1;for(var e,t=M,n=0,r=t.length;r>n&&(e=t[n]);n++)e();M=[]}function s(e){S?i(function(){c(e)}):c(e)}function c(e){e.__upgraded__&&(e.attachedCallback||e.detachedCallback)&&!e.__attached&&p(e)&&(e.__attached=!0,e.attachedCallback&&e.attachedCallback())}function l(e){u(e),y(e,function(e){u(e)})}function u(e){S?i(function(){d(e)}):d(e)}function d(e){e.__upgraded__&&(e.attachedCallback||e.detachedCallback)&&e.__attached&&!p(e)&&(e.__attached=!1,e.detachedCallback&&e.detachedCallback())}function p(e){for(var t=e,n=wrap(document);t;){if(t==n)return!0;t=t.parentNode||t.host}}function f(e){if(e.shadowRoot&&!e.shadowRoot.__watched){b.dom&&console.log("watching shadow-root for: ",e.localName);for(var t=e.shadowRoot;t;)w(t),t=t.olderShadowRoot}}function h(e){if(b.dom){var n=e[0];if(n&&"childList"===n.type&&n.addedNodes&&n.addedNodes){for(var r=n.addedNodes[0];r&&r!==document&&!r.host;)r=r.parentNode;var o=r&&(r.URL||r._URL||r.host&&r.host.localName)||"";o=o.split("/?").shift().split("/").pop()}console.group("mutations (%d) [%s]",e.length,o||"")}e.forEach(function(e){"childList"===e.type&&(_(e.addedNodes,function(e){e.localName&&t(e)}),_(e.removedNodes,function(e){e.localName&&l(e)}))}),b.dom&&console.groupEnd()}function m(e){for(e=wrap(e),e||(e=wrap(document));e.parentNode;)e=e.parentNode;var t=e.__observer;t&&(h(t.takeRecords()),a())}function w(e){if(!e.__observer){var t=new MutationObserver(h);t.observe(e,{childList:!0,subtree:!0}),e.__observer=t}}function v(e){e=wrap(e),b.dom&&console.group("upgradeDocument: ",e.baseURI.split("/").pop()),t(e),w(e),b.dom&&console.groupEnd()}function g(e){E(e,v)}var b=e.flags,y=e.forSubtree,E=e.forDocumentTree,S=!window.MutationObserver||window.MutationObserver===window.JsMutationObserver;e.hasPolyfillMutations=S;var T=!1,M=[],_=Array.prototype.forEach.call.bind(Array.prototype.forEach),L=Element.prototype.createShadowRoot;L&&(Element.prototype.createShadowRoot=function(){var e=L.call(this);return CustomElements.watchShadow(this),e}),e.watchShadow=f,e.upgradeDocumentTree=g,e.upgradeSubtree=r,e.upgradeAll=t,e.attachedNode=o,e.takeRecords=m}),CustomElements.addModule(function(e){function t(t){if(!t.__upgraded__&&t.nodeType===Node.ELEMENT_NODE){var r=t.getAttribute("is"),o=e.getRegisteredDefinition(r||t.localName);if(o){if(r&&o.tag==t.localName)return n(t,o);if(!r&&!o["extends"])return n(t,o)}}}function n(t,n){return a.upgrade&&console.group("upgrade:",t.localName),n.is&&t.setAttribute("is",n.is),r(t,n),t.__upgraded__=!0,i(t),e.attachedNode(t),e.upgradeSubtree(t),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}),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(l(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=d(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 h(e){var t=L.call(this,e);return v(t),t}var m,w=e.upgradeDocumentTree,v=e.upgrade,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"],S={},T="http://www.w3.org/1999/xhtml",M=document.createElement.bind(document),_=document.createElementNS.bind(document),L=Node.prototype.cloneNode;m=Object.__proto__||y?function(e,t){return e instanceof t}:function(e,t){for(var n=e;n;){if(n===t.prototype)return!0;n=n.__proto__}return!1},document.registerElement=t,document.createElement=f,document.createElementNS=p,Node.prototype.cloneNode=h,e.registry=S,e["instanceof"]=m,e.reservedTagList=E,e.getRegisteredDefinition=l,document.register=document.registerElement}),function(e){function t(){a(wrap(document)),window.HTMLImports&&(HTMLImports.__importsParsingHook=function(e){a(wrap(e["import"]))}),CustomElements.ready=!0,setTimeout(function(){CustomElements.readyTime=Date.now(),window.HTMLImports&&(CustomElements.elapsed=CustomElements.readyTime-HTMLImports.readyTime),document.dispatchEvent(new CustomEvent("WebComponentsReady",{bubbles:!0}))})}var n=e.useNative,r=e.initializeModules,o=/Trident/.test(navigator.userAgent);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;if(window.wrap||(window.ShadowDOMPolyfill?(window.wrap=ShadowDOMPolyfill.wrapIfNeeded,window.unwrap=ShadowDOMPolyfill.unwrapIfNeeded):window.wrap=window.unwrap=function(e){return e}),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),"complete"===document.readyState||e.flags.eager)t();else if("interactive"!==document.readyState||window.attachEvent||window.HTMLImports&&!window.HTMLImports.ready){var s=window.HTMLImports&&!HTMLImports.ready?"HTMLImportsLoaded":"DOMContentLoaded";window.addEventListener(s,t)}else t()}(window.CustomElements),function(){Function.prototype.bind||(Function.prototype.bind=function(e){var t=this,n=Array.prototype.slice.call(arguments,1);return function(){var r=n.slice();return r.push.apply(r,arguments),t.apply(e,r)}})}(window.WebComponents),function(e){"use strict";function t(){window.Polymer===o&&(window.Polymer=function(){throw new Error('You tried to use polymer without loading it first. To load polymer, ')})}if(!window.performance){var n=Date.now();window.performance={now:function(){return Date.now()-n}}}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 r=[],o=function(e){"string"!=typeof e&&1===arguments.length&&Array.prototype.push.call(arguments,document._currentScript),r.push(arguments)};window.Polymer=o,e.consumeDeclarations=function(t){e.consumeDeclarations=function(){throw"Possible attempt to load Polymer twice"},t&&t(r),r=null},HTMLImports.useNative?t():addEventListener("DOMContentLoaded",t)}(window.WebComponents),function(){var e=document.createElement("style");e.textContent="body {transition: opacity ease-in 0.2s; } \nbody[unresolved] {opacity: 0; display: block; overflow: hidden; position: relative; } \n";var t=document.querySelector("head");t.insertBefore(e,t.firstChild)}(window.WebComponents),function(e){window.Platform=e}(window.WebComponents); \ No newline at end of file diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 4c5e6adb2c6..09a3ff97634 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -1,15 +1,17 @@ """ -homeassistant.components.groups -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +homeassistant.components.group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to group devices that can be turned on or off. """ -import homeassistant as ha +import homeassistant.core as ha from homeassistant.helpers import generate_entity_id +from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.entity import Entity import homeassistant.util as util from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_ON, STATE_OFF, + ATTR_ENTITY_ID, STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN) DOMAIN = "group" @@ -101,44 +103,50 @@ def get_entity_ids(hass, entity_id, domain_filter=None): def setup(hass, config): """ Sets up all groups found definded in the configuration. """ for name, entity_ids in config.get(DOMAIN, {}).items(): - # Support old deprecated method - 2/28/2015 if isinstance(entity_ids, str): - entity_ids = entity_ids.split(",") - + entity_ids = [ent.strip() for ent in entity_ids.split(",")] setup_group(hass, name, entity_ids) return True -class Group(object): +class Group(Entity): """ Tracks a group of entity ids. """ + + # pylint: disable=too-many-instance-attributes + def __init__(self, hass, name, entity_ids=None, user_defined=True): self.hass = hass - self.name = name + self._name = name + self._state = STATE_UNKNOWN self.user_defined = user_defined - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) - self.tracking = [] - self.group_on, self.group_off = None, None + self.group_on = None + self.group_off = None if entity_ids is not None: self.update_tracked_entity_ids(entity_ids) else: - self.force_update() + self.update_ha_state(True) + + @property + def should_poll(self): + return False + + @property + def name(self): + return self._name @property def state(self): - """ Return the current state from the group. """ - return self.hass.states.get(self.entity_id) + return self._state @property - def state_attr(self): - """ State attributes of this group. """ + def state_attributes(self): return { ATTR_ENTITY_ID: self.tracking, ATTR_AUTO: not self.user_defined, - ATTR_FRIENDLY_NAME: self.name } def update_tracked_entity_ids(self, entity_ids): @@ -147,71 +155,69 @@ class Group(object): self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) self.group_on, self.group_off = None, None - self.force_update() + self.update_ha_state(True) self.start() - def force_update(self): - """ Query all the tracked states and update group state. """ - for entity_id in self.tracking: - state = self.hass.states.get(entity_id) - - if state is not None: - self._update_group_state(state.entity_id, None, state) - - # If parsing the entitys did not result in a state, set UNKNOWN - if self.state is None: - self.hass.states.set( - self.entity_id, STATE_UNKNOWN, self.state_attr) - def start(self): """ Starts the tracking. """ - self.hass.states.track_change(self.tracking, self._update_group_state) + track_state_change( + self.hass, self.tracking, self._state_changed_listener) def stop(self): """ Unregisters the group from Home Assistant. """ self.hass.states.remove(self.entity_id) self.hass.bus.remove_listener( - ha.EVENT_STATE_CHANGED, self._update_group_state) + ha.EVENT_STATE_CHANGED, self._state_changed_listener) - def _update_group_state(self, entity_id, old_state, new_state): - """ Updates the group state based on a state change by - a tracked entity. """ + def update(self): + """ Query all the tracked states and determine current group state. """ + self._state = STATE_UNKNOWN + + for entity_id in self.tracking: + state = self.hass.states.get(entity_id) + + if state is not None: + self._process_tracked_state(state) + + def _state_changed_listener(self, entity_id, old_state, new_state): + """ Listener to receive state changes of tracked entities. """ + self._process_tracked_state(new_state) + self.update_ha_state() + + def _process_tracked_state(self, tr_state): + """ Updates group state based on a new state of a tracked entity. """ # We have not determined type of group yet if self.group_on is None: - self.group_on, self.group_off = _get_group_on_off(new_state.state) + self.group_on, self.group_off = _get_group_on_off(tr_state.state) if self.group_on is not None: # New state of the group is going to be based on the first # state that we can recognize - self.hass.states.set( - self.entity_id, new_state.state, self.state_attr) + self._state = tr_state.state return # There is already a group state - cur_gr_state = self.hass.states.get(self.entity_id).state + cur_gr_state = self._state group_on, group_off = self.group_on, self.group_off - # if cur_gr_state = OFF and new_state = ON: set ON - # if cur_gr_state = ON and new_state = OFF: research + # if cur_gr_state = OFF and tr_state = ON: set ON + # if cur_gr_state = ON and tr_state = OFF: research # else: ignore - if cur_gr_state == group_off and new_state.state == group_on: + if cur_gr_state == group_off and tr_state.state == group_on: + self._state = group_on - self.hass.states.set( - self.entity_id, group_on, self.state_attr) + elif cur_gr_state == group_on and tr_state.state == group_off: - elif (cur_gr_state == group_on and - new_state.state == group_off): - - # Check if any of the other states is still on + # Set to off if no other states are on if not any(self.hass.states.is_state(ent_id, group_on) - for ent_id in self.tracking if entity_id != ent_id): - self.hass.states.set( - self.entity_id, group_off, self.state_attr) + for ent_id in self.tracking + if tr_state.entity_id != ent_id): + self._state = group_off def setup_group(hass, name, entity_ids, user_defined=True): diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 10c4fdb41f6..1d2ccc9ab14 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -5,15 +5,20 @@ homeassistant.components.history Provide pre-made queries on top of the recorder component. """ import re -from datetime import datetime, timedelta +from datetime import timedelta from itertools import groupby from collections import defaultdict +import homeassistant.util.dt as dt_util import homeassistant.components.recorder as recorder +from homeassistant.const import HTTP_BAD_REQUEST DOMAIN = 'history' DEPENDENCIES = ['recorder', 'http'] +URL_HISTORY_PERIOD = re.compile( + r'/api/history/period(?:/(?P\d{4}-\d{1,2}-\d{1,2})|)') + def last_5_states(entity_id): """ Return the last 5 states for entity_id. """ @@ -22,7 +27,7 @@ def last_5_states(entity_id): query = """ SELECT * FROM states WHERE entity_id=? AND last_changed=last_updated - ORDER BY last_changed DESC LIMIT 0, 5 + ORDER BY state_id DESC LIMIT 0, 5 """ return recorder.query_states(query, (entity_id, )) @@ -30,7 +35,7 @@ def last_5_states(entity_id): def state_changes_during_period(start_time, end_time=None, entity_id=None): """ - Return states changes during period start_time - end_time. + Return states changes during UTC period start_time - end_time. """ where = "last_changed=last_updated AND last_changed > ? " data = [start_time] @@ -50,8 +55,10 @@ def state_changes_during_period(start_time, end_time=None, entity_id=None): result = defaultdict(list) + entity_ids = [entity_id] if entity_id is not None else None + # Get the states at the start time - for state in get_states(start_time): + for state in get_states(start_time, entity_ids): state.last_changed = start_time result[state.entity_id].append(state) @@ -62,13 +69,17 @@ def state_changes_during_period(start_time, end_time=None, entity_id=None): return result -def get_states(point_in_time, entity_ids=None, run=None): +def get_states(utc_point_in_time, entity_ids=None, run=None): """ Returns the states at a specific point in time. """ if run is None: - run = recorder.run_information(point_in_time) + run = recorder.run_information(utc_point_in_time) + + # History did not run before utc_point_in_time + if run is None: + return [] where = run.where_after_start_run + "AND created < ? " - where_data = [point_in_time] + where_data = [utc_point_in_time] if entity_ids is not None: where += "AND entity_id IN ({}) ".format( @@ -87,13 +98,14 @@ def get_states(point_in_time, entity_ids=None, run=None): return recorder.query_states(query, where_data) -def get_state(point_in_time, entity_id, run=None): +def get_state(utc_point_in_time, entity_id, run=None): """ Return a state at a specific point in time. """ - states = get_states(point_in_time, (entity_id,), run) + states = get_states(utc_point_in_time, (entity_id,), run) return states[0] if states else None +# pylint: disable=unused-argument def setup(hass, config): """ Setup history hooks. """ hass.http.register_path( @@ -103,12 +115,12 @@ def setup(hass, config): r'recent_states'), _api_last_5_states) - hass.http.register_path( - 'GET', re.compile(r'/api/history/period'), _api_history_period) + hass.http.register_path('GET', URL_HISTORY_PERIOD, _api_history_period) return True +# pylint: disable=unused-argument # pylint: disable=invalid-name def _api_last_5_states(handler, path_match, data): """ Return the last 5 states for an entity id as JSON. """ @@ -119,10 +131,25 @@ def _api_last_5_states(handler, path_match, data): def _api_history_period(handler, path_match, data): """ Return history over a period of time. """ - # 1 day for now.. - start_time = datetime.now() - timedelta(seconds=86400) + date_str = path_match.group('date') + one_day = timedelta(seconds=86400) + + if date_str: + start_date = dt_util.date_str_to_date(date_str) + + if start_date is None: + handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST) + return + + start_time = dt_util.as_utc(dt_util.start_of_local_day(start_date)) + else: + start_time = dt_util.utcnow() - one_day + + end_time = start_time + one_day + + print("Fetchign", start_time, end_time) entity_id = data.get('filter_entity_id') handler.write_json( - state_changes_during_period(start_time, entity_id=entity_id).values()) + state_changes_during_period(start_time, end_time, entity_id).values()) diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index fed43cb43de..0b4f6165bed 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -1,6 +1,6 @@ """ homeassistant.components.httpinterface -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This module provides an API and a HTTP interface for debug purposes. @@ -77,11 +77,16 @@ 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 +from http import cookies from socketserver import ThreadingMixIn from urllib.parse import urlparse, parse_qs -import homeassistant as ha +import homeassistant.core as ha from homeassistant.const import ( SERVER_PORT, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_ACCEPT_ENCODING, @@ -90,6 +95,7 @@ from homeassistant.const import ( HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_UNPROCESSABLE_ENTITY) import homeassistant.remote as rem import homeassistant.util as util +import homeassistant.util.dt as date_util import homeassistant.bootstrap as bootstrap DOMAIN = "http" @@ -99,15 +105,20 @@ CONF_API_PASSWORD = "api_password" CONF_SERVER_HOST = "server_host" CONF_SERVER_PORT = "server_port" CONF_DEVELOPMENT = "development" +CONF_SESSIONS_ENABLED = "sessions_enabled" DATA_API_PASSWORD = 'api_password' +# Throttling time in seconds for expired sessions check +MIN_SEC_SESSION_CLEARING = timedelta(seconds=20) +SESSION_TIMEOUT_SECONDS = 1800 +SESSION_KEY = 'sessionId' + _LOGGER = logging.getLogger(__name__) def setup(hass, config=None): """ Sets up the HTTP API and debug interface. """ - if config is None or DOMAIN not in config: config = {DOMAIN: {}} @@ -125,9 +136,16 @@ def setup(hass, config=None): development = str(config[DOMAIN].get(CONF_DEVELOPMENT, "")) == "1" - server = HomeAssistantHTTPServer( - (server_host, server_port), RequestHandler, hass, api_password, - development, no_password_set) + sessions_enabled = config[DOMAIN].get(CONF_SESSIONS_ENABLED, True) + + try: + server = HomeAssistantHTTPServer( + (server_host, server_port), RequestHandler, hass, api_password, + development, no_password_set, sessions_enabled) + except OSError: + # Happens if address already in use + _LOGGER.exception("Error setting up HTTP server") + return False hass.bus.listen_once( ha.EVENT_HOMEASSISTANT_START, @@ -140,6 +158,7 @@ def setup(hass, config=None): return True +# pylint: disable=too-many-instance-attributes class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): """ Handle HTTP requests in a threaded fashion. """ # pylint: disable=too-few-public-methods @@ -149,7 +168,8 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): # pylint: disable=too-many-arguments def __init__(self, server_address, request_handler_class, - hass, api_password, development, no_password_set): + hass, api_password, development, no_password_set, + sessions_enabled): super().__init__(server_address, request_handler_class) self.server_address = server_address @@ -158,6 +178,7 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): self.development = development self.no_password_set = no_password_set self.paths = [] + self.sessions = SessionStore(sessions_enabled) # We will lazy init this one if needed self.event_forwarder = None @@ -166,10 +187,12 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): _LOGGER.info("running http in development mode") def start(self): - """ Starts the server. """ - self.hass.bus.listen_once( - ha.EVENT_HOMEASSISTANT_STOP, - lambda event: self.shutdown()) + """ Starts the HTTP server. """ + def stop_http(event): + """ Stops the HTTP server. """ + self.shutdown() + + self.hass.bus.listen_once(ha.EVENT_HOMEASSISTANT_STOP, stop_http) _LOGGER.info( "Starting web interface at http://%s:%d", *self.server_address) @@ -182,9 +205,14 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): self.serve_forever() def register_path(self, method, url, callback, require_auth=True): - """ Regitsters a path wit the server. """ + """ Registers a path wit the server. """ self.paths.append((method, url, callback, require_auth)) + def log_message(self, fmt, *args): + """ Redirect built-in log to HA logging """ + # pylint: disable=no-self-use + _LOGGER.info(fmt, *args) + # pylint: disable=too-many-public-methods,too-many-locals class RequestHandler(SimpleHTTPRequestHandler): @@ -197,6 +225,15 @@ class RequestHandler(SimpleHTTPRequestHandler): server_version = "HomeAssistant/1.0" + def __init__(self, req, client_addr, server): + """ Contructor, call the base constructor and set up session """ + self._session = None + SimpleHTTPRequestHandler.__init__(self, req, client_addr, server) + + def log_message(self, fmt, *arguments): + """ Redirect built-in log to HA logging """ + _LOGGER.info(fmt, *arguments) + def _handle_request(self, method): # pylint: disable=too-many-branches """ Does some common checks and calls appropriate method. """ url = urlparse(self.path) @@ -225,6 +262,7 @@ 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: @@ -233,6 +271,10 @@ class RequestHandler(SimpleHTTPRequestHandler): 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) + if '_METHOD' in data: method = data.pop('_METHOD') @@ -271,6 +313,10 @@ class RequestHandler(SimpleHTTPRequestHandler): "API password missing or incorrect.", HTTP_UNAUTHORIZED) else: + if self._session is None and require_auth: + self._session = self.server.sessions.create( + api_password) + handle_request_method(self, path_match, data) elif path_matched_but_not_method: @@ -313,6 +359,8 @@ class RequestHandler(SimpleHTTPRequestHandler): if location: self.send_header('Location', location) + self.set_session_cookie_header() + self.end_headers() if data is not None: @@ -342,6 +390,7 @@ class RequestHandler(SimpleHTTPRequestHandler): self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type) self.set_cache_header() + self.set_session_cookie_header() if do_gzip: gzip_data = gzip.compress(inp.read()) @@ -377,3 +426,113 @@ class RequestHandler(SimpleHTTPRequestHandler): 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: + return None + + session_id = self.get_current_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 + + return None + + def get_current_session_id(self): + """ + Extracts the current session id from the + cookie or returns None if not set + """ + cookie = cookies.SimpleCookie() + + if self.headers.get('Cookie', None) is not None: + cookie.load(self.headers.get("Cookie")) + + if cookie.get(SESSION_KEY, False): + return cookie[SESSION_KEY].value + + return None + + +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 + + def reset_expiry(self): + """ Resets the expiry based on current time """ + self._expiry = date_util.utcnow() + timedelta( + seconds=SESSION_TIMEOUT_SECONDS) + + @property + def is_expired(self): + """ Return true if the session is expired based on the expiry time """ + return self._expiry < date_util.utcnow() + + +class SessionStore: + """ Responsible for storing and retrieving http sessions """ + def __init__(self, enabled=True): + """ Set up the session store """ + self._sessions = {} + self.enabled = enabled + self.session_lock = threading.RLock() + + @Throttle(MIN_SEC_SESSION_CLEARING) + 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) + + 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 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 + + 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 create(self, api_password): + """ Creates a new session and adds it to the sessions """ + if self.enabled is not True: + return 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 diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py new file mode 100644 index 00000000000..1eacd61bcee --- /dev/null +++ b/homeassistant/components/ifttt.py @@ -0,0 +1,79 @@ +""" +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 + +""" +import logging +import requests + +from homeassistant.helpers import validate_config + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "ifttt" + +SERVICE_TRIGGER = 'trigger' + +ATTR_EVENT = 'event' +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 """ + data = { + ATTR_EVENT: event, + ATTR_VALUE1: value1, + ATTR_VALUE2: value2, + ATTR_VALUE3: value3, + } + hass.services.call(DOMAIN, SERVICE_TRIGGER, data) + + +def setup(hass, config): + """ Setup the ifttt service component """ + + if not validate_config(config, {DOMAIN: ['key']}, _LOGGER): + return False + + key = config[DOMAIN]['key'] + + def trigger_service(call): + """ Handle ifttt trigger service calls. """ + event = call.data.get(ATTR_EVENT) + value1 = call.data.get(ATTR_VALUE1) + value2 = call.data.get(ATTR_VALUE2) + value3 = call.data.get(ATTR_VALUE3) + if event is None: + return + + try: + import pyfttt as pyfttt + pyfttt.send_event(key, event, value1, value2, value3) + except requests.exceptions.RequestException: + _LOGGER.exception("Error communicating with IFTTT") + + hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service) + + return True diff --git a/homeassistant/components/introduction.py b/homeassistant/components/introduction.py new file mode 100644 index 00000000000..3a1af572a30 --- /dev/null +++ b/homeassistant/components/introduction.py @@ -0,0 +1,44 @@ +""" +homeassistant.components.introduction +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Component that will help guide the user taking its first steps. +""" +import logging + +DOMAIN = 'introduction' +DEPENDENCIES = [] + + +def setup(hass, config=None): + """ Setup the introduction component. """ + log = logging.getLogger(__name__) + log.info(""" + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Hello, and welcome to Home Assistant! + + We'll hope that we can make all your dreams come true. + + Here are some resources to get started: + + - Configuring Home Assistant: + https://home-assistant.io/getting-started/configuration.html + + - Available components: + https://home-assistant.io/components/ + + - Troubleshooting your configuration: + https://home-assistant.io/getting-started/troubleshooting-configuration.html + + - Getting help: + https://home-assistant.io/help/ + + This message is generated by the introduction component. You can + disable it in configuration.yaml. + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + """) + + return True diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py new file mode 100644 index 00000000000..63c7b6c4af6 --- /dev/null +++ b/homeassistant/components/isy994.py @@ -0,0 +1,232 @@ +""" +homeassistant.components.isy994 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +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 +""" +import logging +from urllib.parse import urlparse + +from homeassistant import bootstrap +from homeassistant.loader import get_component +from homeassistant.helpers import validate_config +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, EVENT_PLATFORM_DISCOVERED, + EVENT_HOMEASSISTANT_STOP, ATTR_SERVICE, ATTR_DISCOVERED, + ATTR_FRIENDLY_NAME) + +DOMAIN = "isy994" +DEPENDENCIES = [] +REQUIREMENTS = ['PyISY==1.0.5'] +DISCOVER_LIGHTS = "isy994.lights" +DISCOVER_SWITCHES = "isy994.switches" +DISCOVER_SENSORS = "isy994.sensors" +ISY = None +SENSOR_STRING = 'Sensor' +HIDDEN_STRING = '{HIDE ME}' +CONF_TLS_VER = 'tls' + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ + Setup ISY994 component. + This will automatically import associated lights, switches, and sensors. + """ + try: + import PyISY + except ImportError: + _LOGGER.error("Error while importing dependency PyISY.") + return False + + # pylint: disable=global-statement + # check for required values in configuration file + if not validate_config(config, + {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return False + + # pull and parse standard configuration + user = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + host = urlparse(config[DOMAIN][CONF_HOST]) + addr = host.geturl() + if host.scheme == 'http': + addr = addr.replace('http://', '') + https = False + elif host.scheme == 'https': + addr = addr.replace('https://', '') + https = True + else: + _LOGGER.error('isy994 host value in configuration file is invalid.') + return False + port = host.port + addr = addr.replace(':{}'.format(port), '') + + # pull and parse optional configuration + global SENSOR_STRING + global HIDDEN_STRING + SENSOR_STRING = str(config[DOMAIN].get('sensor_string', SENSOR_STRING)) + HIDDEN_STRING = str(config[DOMAIN].get('hidden_string', HIDDEN_STRING)) + tls_version = config[DOMAIN].get(CONF_TLS_VER, None) + + # connect to ISY controller + global ISY + ISY = PyISY.ISY(addr, port, user, password, use_https=https, + tls_ver=tls_version, log=_LOGGER) + if not ISY.connected: + return False + + # listen for HA stop to disconnect + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) + + # Load components for the devices in the ISY controller that we support + for comp_name, discovery in ((('sensor', DISCOVER_SENSORS), + ('light', DISCOVER_LIGHTS), + ('switch', DISCOVER_SWITCHES))): + component = get_component(comp_name) + bootstrap.setup_component(hass, component.DOMAIN, config) + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, + {ATTR_SERVICE: discovery, + ATTR_DISCOVERED: {}}) + + ISY.auto_update = True + return True + + +def stop(event): + """ Cleanup the ISY subscription. """ + ISY.auto_update = False + + +class ISYDeviceABC(ToggleEntity): + """ Abstract Class for an ISY device. """ + + _attrs = {} + _onattrs = [] + _states = [] + _dtype = None + _domain = None + _name = None + + 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. \ + subscribe('changed', self.on_update) + + def __del__(self): + """ cleanup subscriptions because it is the right thing to do. """ + self._change_handler.unsubscribe() + + @property + def domain(self): + """ Returns the domain of the entity. """ + return self._domain + + @property + def dtype(self): + """ Returns the data type of the entity (binary or analog). """ + if self._dtype in ['analog', 'binary']: + return self._dtype + return 'binary' if self.unit_of_measurement is None else 'analog' + + @property + def should_poll(self): + """ Tells Home Assistant not to poll this entity. """ + return False + + @property + def value(self): + """ Returns the unclean value from the controller. """ + # pylint: disable=protected-access + return self.node.status._val + + @property + def state_attributes(self): + """ Returns the state attributes for the node. """ + attr = {ATTR_FRIENDLY_NAME: self.name} + for name, prop in self._attrs.items(): + attr[name] = getattr(self, prop) + attr = self._attr_filter(attr) + return attr + + def _attr_filter(self, attr): + """ Placeholder for attribute filters. """ + # pylint: disable=no-self-use + return attr + + @property + def unique_id(self): + """ Returns the id of this ISY sensor. """ + # pylint: disable=protected-access + return self.node._id + + @property + def raw_name(self): + """ Returns the unclean node name. """ + return str(self._name) \ + if self._name is not None else str(self.node.name) + + @property + def name(self): + """ Returns the cleaned name of the node. """ + return self.raw_name.replace(HIDDEN_STRING, '').strip() \ + .replace('_', ' ') + + def update(self): + """ Update state of the sensor. """ + # ISY objects are automatically updated by the ISY's event stream + pass + + def on_update(self, event): + """ Handles the update received event. """ + self.update_ha_state() + + @property + def is_on(self): + """ Returns boolean response if the node is on. """ + return bool(self.value) + + @property + def is_open(self): + """ Returns boolean respons if the node is open. On = Open. """ + return self.is_on + + @property + def state(self): + """ Returns the state of the node. """ + if len(self._states) > 0: + return self._states[0] if self.is_on else self._states[1] + return self.value + + def turn_on(self, **kwargs): + """ Turns the device on. """ + if self.domain is not 'sensor': + attrs = [kwargs.get(name) for name in self._onattrs] + self.node.on(*attrs) + else: + _LOGGER.error('ISY cannot turn on sensors.') + + def turn_off(self, **kwargs): + """ Turns the device off. """ + if self.domain is not 'sensor': + self.node.off() + else: + _LOGGER.error('ISY cannot turn off sensors.') + + @property + def unit_of_measurement(self): + """ Returns the defined units of measurement or None. """ + try: + return self.node.units + except AttributeError: + return None diff --git a/homeassistant/components/keyboard.py b/homeassistant/components/keyboard.py index 8e820856c34..3629fce31bf 100644 --- a/homeassistant/components/keyboard.py +++ b/homeassistant/components/keyboard.py @@ -1,5 +1,5 @@ """ -homeassistant.keyboard +homeassistant.components.keyboard ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to emulate keyboard presses on host machine. @@ -8,12 +8,13 @@ import logging from homeassistant.const import ( SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREV_TRACK, + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_PLAY_PAUSE) DOMAIN = "keyboard" DEPENDENCIES = [] +REQUIREMENTS = ['pyuserinput==0.1.9'] def volume_up(hass): @@ -43,7 +44,7 @@ def media_next_track(hass): def media_prev_track(hass): """ Press the keyboard button for prev track. """ - hass.services.call(DOMAIN, SERVICE_MEDIA_PREV_TRACK) + hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK) def setup(hass, config): @@ -79,7 +80,7 @@ def setup(hass, config): lambda service: keyboard.tap_key(keyboard.media_next_track_key)) - hass.services.register(DOMAIN, SERVICE_MEDIA_PREV_TRACK, + hass.services.register(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, lambda service: keyboard.tap_key(keyboard.media_prev_track_key)) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 16020f1ecb1..d2f8033add7 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1,6 +1,6 @@ """ homeassistant.components.light -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to interact with lights. @@ -53,11 +53,13 @@ import os import csv from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import ToggleEntity import homeassistant.util as util +import homeassistant.util.color as color_util from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID) -from homeassistant.components import group, discovery, wink +from homeassistant.components import group, discovery, wink, isy994 DOMAIN = "light" @@ -87,12 +89,22 @@ ATTR_FLASH = "flash" FLASH_SHORT = "short" FLASH_LONG = "long" +# Apply an effect to the light, can be EFFECT_COLORLOOP +ATTR_EFFECT = "effect" +EFFECT_COLORLOOP = "colorloop" + LIGHT_PROFILES_FILE = "light_profiles.csv" # Maps discovered services to their platforms DISCOVERY_PLATFORMS = { wink.DISCOVER_LIGHTS: 'wink', - discovery.services.PHILIPS_HUE: 'hue', + isy994.DISCOVER_LIGHTS: 'isy994', + discovery.SERVICE_HUE: 'hue', +} + +PROP_TO_ATTR = { + 'brightness': ATTR_BRIGHTNESS, + 'color_xy': ATTR_XY_COLOR, } _LOGGER = logging.getLogger(__name__) @@ -107,7 +119,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): + rgb_color=None, xy_color=None, profile=None, flash=None, + effect=None): """ Turns all or specified light on. """ data = { key: value for key, value in [ @@ -118,6 +131,7 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None, (ATTR_RGB_COLOR, rgb_color), (ATTR_XY_COLOR, xy_color), (ATTR_FLASH, flash), + (ATTR_EFFECT, effect), ] if value is not None } @@ -152,26 +166,25 @@ def setup(hass, config): profiles = {} for profile_path in profile_paths: + if not os.path.isfile(profile_path): + continue + with open(profile_path) as inp: + reader = csv.reader(inp) - if os.path.isfile(profile_path): - with open(profile_path) as inp: - reader = csv.reader(inp) + # Skip the header + next(reader, None) - # Skip the header - next(reader, None) + try: + for profile_id, color_x, color_y, brightness in reader: + profiles[profile_id] = (float(color_x), float(color_y), + int(brightness)) + except ValueError: + # ValueError if not 4 values per row + # ValueError if convert to float/int failed + _LOGGER.error( + "Error parsing light profiles from %s", profile_path) - try: - for profile_id, color_x, color_y, brightness in reader: - profiles[profile_id] = (float(color_x), float(color_y), - int(brightness)) - - except ValueError: - # ValueError if not 4 values per row - # ValueError if convert to float/int failed - _LOGGER.error( - "Error parsing light profiles from %s", profile_path) - - return False + return False def handle_light_service(service): """ Hande a turn light on or off service call. """ @@ -192,65 +205,74 @@ def setup(hass, config): for light in target_lights: light.turn_off(**params) - else: - # Processing extra data for turn light on request - - # We process the profile first so that we get the desired - # behavior that extra service data attributes overwrite - # profile values - profile = profiles.get(dat.get(ATTR_PROFILE)) - - if profile: - *params[ATTR_XY_COLOR], params[ATTR_BRIGHTNESS] = profile - - if ATTR_BRIGHTNESS in dat: - # We pass in the old value as the default parameter if parsing - # of the new one goes wrong. - params[ATTR_BRIGHTNESS] = util.convert( - dat.get(ATTR_BRIGHTNESS), int, params.get(ATTR_BRIGHTNESS)) - - if ATTR_XY_COLOR in dat: - try: - # xy_color should be a list containing 2 floats - xycolor = dat.get(ATTR_XY_COLOR) - - # Without this check, a xycolor with value '99' would work - if not isinstance(xycolor, str): - params[ATTR_XY_COLOR] = [float(val) for val in xycolor] - - except (TypeError, ValueError): - # TypeError if xy_color is not iterable - # ValueError if value could not be converted to float - pass - - if ATTR_RGB_COLOR in dat: - try: - # rgb_color should be a list containing 3 ints - rgb_color = dat.get(ATTR_RGB_COLOR) - - if len(rgb_color) == 3: - params[ATTR_XY_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 - - elif dat[ATTR_FLASH] == FLASH_LONG: - params[ATTR_FLASH] = FLASH_LONG - for light in target_lights: - light.turn_on(**params) + if light.should_poll: + light.update_ha_state(True) + return + + # Processing extra data for turn light on request + + # We process the profile first so that we get the desired + # behavior that extra service data attributes overwrite + # profile values + profile = profiles.get(dat.get(ATTR_PROFILE)) + + if profile: + *params[ATTR_XY_COLOR], params[ATTR_BRIGHTNESS] = profile + + if ATTR_BRIGHTNESS in dat: + # We pass in the old value as the default parameter if parsing + # of the new one goes wrong. + params[ATTR_BRIGHTNESS] = util.convert( + dat.get(ATTR_BRIGHTNESS), int, params.get(ATTR_BRIGHTNESS)) + + if ATTR_XY_COLOR in dat: + try: + # xy_color should be a list containing 2 floats + xycolor = dat.get(ATTR_XY_COLOR) + + # Without this check, a xycolor with value '99' would work + if not isinstance(xycolor, str): + params[ATTR_XY_COLOR] = [float(val) for val in xycolor] + + except (TypeError, ValueError): + # TypeError if xy_color is not iterable + # ValueError if value could not be converted to float + pass + + if ATTR_RGB_COLOR in dat: + try: + # rgb_color should be a list containing 3 ints + rgb_color = dat.get(ATTR_RGB_COLOR) + + if len(rgb_color) == 3: + 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 + + 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 for light in target_lights: - light.update_ha_state(True) + light.turn_on(**params) + + for light in target_lights: + if light.should_poll: + light.update_ha_state(True) # Listen for light on and light off service calls hass.services.register(DOMAIN, SERVICE_TURN_ON, @@ -260,3 +282,41 @@ def setup(hass, config): handle_light_service) return True + + +class Light(ToggleEntity): + """ Represents a light within Home Assistant. """ + # pylint: disable=no-self-use + + @property + def brightness(self): + """ Brightness of this light between 0..255. """ + return None + + @property + def color_xy(self): + """ XY color value [float, float]. """ + return None + + @property + def device_state_attributes(self): + """ Returns device specific state attributes. """ + return None + + @property + def state_attributes(self): + """ Returns optional state attributes. """ + data = {} + + if self.is_on: + for prop, attr in PROP_TO_ATTR.items(): + value = getattr(self, prop) + if value: + data[attr] = value + + device_attr = self.device_state_attributes + + if device_attr is not None: + data.update(device_attr) + + return data diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index 73fb1580e60..40a8cc023c5 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -1,31 +1,35 @@ -""" Provides demo lights. """ +""" +homeassistant.components.light.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Demo platform that implements lights. + +""" import random -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.const import STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME -from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_XY_COLOR +from homeassistant.components.light import ( + Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR) LIGHT_COLORS = [ - [0.861, 0.3259], - [0.6389, 0.3028], - [0.1684, 0.0416] + [0.368, 0.180], + [0.460, 0.470], ] def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return demo lights. """ add_devices_callback([ - DemoLight("Bed Light", STATE_OFF), - DemoLight("Ceiling", STATE_ON), - DemoLight("Kitchen", STATE_ON) + DemoLight("Bed Light", False), + DemoLight("Ceiling Lights", True, LIGHT_COLORS[0]), + DemoLight("Kitchen Lights", True, LIGHT_COLORS[1]) ]) -class DemoLight(ToggleEntity): +class DemoLight(Light): """ Provides a demo switch. """ def __init__(self, name, state, xy=None, brightness=180): - self._name = name or DEVICE_DEFAULT_NAME + self._name = name self._state = state self._xy = xy or random.choice(LIGHT_COLORS) self._brightness = brightness @@ -41,27 +45,23 @@ class DemoLight(ToggleEntity): return self._name @property - def state(self): - """ Returns the name of the device if any. """ - return self._state + def brightness(self): + """ Brightness of this light between 0..255. """ + return self._brightness @property - def state_attributes(self): - """ Returns optional state attributes. """ - if self.is_on: - return { - ATTR_BRIGHTNESS: self._brightness, - ATTR_XY_COLOR: self._xy, - } + def color_xy(self): + """ XY color value. """ + return self._xy @property def is_on(self): """ True if device is on. """ - return self._state == STATE_ON + return self._state def turn_on(self, **kwargs): """ Turn the device on. """ - self._state = STATE_ON + self._state = True if ATTR_XY_COLOR in kwargs: self._xy = kwargs[ATTR_XY_COLOR] @@ -69,6 +69,9 @@ class DemoLight(ToggleEntity): if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] + self.update_ha_state() + def turn_off(self, **kwargs): """ Turn the device off. """ - self._state = STATE_OFF + self._state = False + self.update_ha_state() diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 683d8a1a4c9..b438d7b92b1 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -1,4 +1,8 @@ -""" Support for Hue lights. """ +""" +homeassistant.components.light.hue +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Hue lights. +""" import logging import socket from datetime import timedelta @@ -6,12 +10,13 @@ from urllib.parse import urlparse from homeassistant.loader import get_component import homeassistant.util as util -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_XY_COLOR, ATTR_TRANSITION, - ATTR_FLASH, FLASH_LONG, FLASH_SHORT) + Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR, ATTR_TRANSITION, + ATTR_FLASH, FLASH_LONG, FLASH_SHORT, ATTR_EFFECT, + EFFECT_COLORLOOP) +REQUIREMENTS = ['phue==0.8'] MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) @@ -34,7 +39,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): return if discovery_info is not None: - host = urlparse(discovery_info).hostname + host = urlparse(discovery_info[1]).hostname else: host = config.get(CONF_HOST, None) @@ -131,7 +136,7 @@ def request_configuration(host, hass, add_devices_callback): ) -class HueLight(ToggleEntity): +class HueLight(Light): """ Represents a Hue light """ def __init__(self, light_id, info, bridge, update_lights): @@ -149,19 +154,17 @@ class HueLight(ToggleEntity): @property def name(self): """ Get the mame of the Hue light. """ - return self.info.get('name', 'No name') + return self.info.get('name', DEVICE_DEFAULT_NAME) @property - def state_attributes(self): - """ Returns optional state attributes. """ - attr = {} + def brightness(self): + """ Brightness of this light between 0..255. """ + return self.info['state']['bri'] - if self.is_on: - attr[ATTR_BRIGHTNESS] = self.info['state']['bri'] - if 'xy' in self.info['state']: - attr[ATTR_XY_COLOR] = self.info['state']['xy'] - - return attr + @property + def color_xy(self): + """ XY color value. """ + return self.info['state'].get('xy') @property def is_on(self): @@ -194,6 +197,13 @@ class HueLight(ToggleEntity): else: command['alert'] = 'none' + effect = kwargs.get(ATTR_EFFECT) + + if effect == EFFECT_COLORLOOP: + command['effect'] = 'colorloop' + else: + command['effect'] = 'none' + self.bridge.set_light(self.light_id, command) def turn_off(self, **kwargs): diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py new file mode 100644 index 00000000000..5b62120ee98 --- /dev/null +++ b/homeassistant/components/light/isy994.py @@ -0,0 +1,46 @@ +""" +homeassistant.components.light.isy994 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for ISY994 lights. +""" +import logging + +from homeassistant.components.isy994 import (ISYDeviceABC, ISY, SENSOR_STRING, + HIDDEN_STRING) +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.const import STATE_ON, STATE_OFF + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the ISY994 platform. """ + logger = logging.getLogger(__name__) + devs = [] + # verify connection + if ISY is None or not ISY.connected: + logger.error('A connection has not been made to the ISY controller.') + return False + + # import dimmable nodes + for (path, node) in ISY.nodes: + if node.dimmable and SENSOR_STRING not in node.name: + if HIDDEN_STRING in path: + node.name += HIDDEN_STRING + devs.append(ISYLightDevice(node)) + + add_devices(devs) + + +class ISYLightDevice(ISYDeviceABC): + """ Represents as ISY light. """ + + _domain = 'light' + _dtype = 'analog' + _attrs = {ATTR_BRIGHTNESS: 'value'} + _onattrs = [ATTR_BRIGHTNESS] + _states = [STATE_ON, STATE_OFF] + + def _attr_filter(self, attr): + """ Filter brightness out of entity while off. """ + if ATTR_BRIGHTNESS in attr and not self.is_on: + del attr[ATTR_BRIGHTNESS] + return attr diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py new file mode 100644 index 00000000000..9096bb32a10 --- /dev/null +++ b/homeassistant/components/light/limitlessled.py @@ -0,0 +1,143 @@ +""" +homeassistant.components.light.limitlessled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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 + host: 192.168.1.10 + group_1_name: Living Room + group_2_name: Bedroom + group_3_name: Office + group_4_name: Kitchen +""" +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 + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['ledcontroller==1.0.7'] + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Gets the LimitlessLED lights. """ + import ledcontroller + + led = ledcontroller.LedController(config['host']) + + lights = [] + for i in range(1, 5): + if 'group_%d_name' % (i) in config: + lights.append(LimitlessLED(led, i, config['group_%d_name' % (i)])) + + add_devices_callback(lights) + + +class LimitlessLED(Light): + """ Represents a LimitlessLED light """ + + def __init__(self, led, group, name): + self.led = led + self.group = group + + # LimitlessLEDs don't report state, we have track it ourselves. + self.led.off(self.group) + + self._name = name or DEVICE_DEFAULT_NAME + self._state = False + 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'), + ]] + + @property + def should_poll(self): + """ No polling needed for a demo light. """ + return False + + @property + def name(self): + """ Returns the name of the device if any. """ + return self._name + + @property + def brightness(self): + return self._brightness + + @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] + + @property + def is_on(self): + """ True if device is on. """ + return self._state + + def turn_on(self, **kwargs): + """ Turn the device on. """ + self._state = True + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + if ATTR_XY_COLOR in kwargs: + self._xy_color = kwargs[ATTR_XY_COLOR] + + self.led.set_color(self._xy_to_led_color(self._xy_color), self.group) + self.led.set_brightness(self._brightness / 255.0, self.group) + self.update_ha_state() + + def turn_off(self, **kwargs): + """ Turn the device off. """ + self._state = False + self.led.off(self.group) + self.update_ha_state() diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 6d28c196326..8068d20bb74 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -1,14 +1,19 @@ -""" Support for Tellstick lights. """ +""" +homeassistant.components.light.tellstick +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Tellstick lights. +""" import logging # pylint: disable=no-name-in-module, import-error -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.light import Light, ATTR_BRIGHTNESS from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.helpers.entity import ToggleEntity import tellcore.constants as tellcore_constants +REQUIREMENTS = ['tellcore-py==1.0.4'] + def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """ Find and return tellstick lights. """ + """ Find and return Tellstick lights. """ try: import tellcore.telldus as telldus @@ -27,8 +32,8 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): add_devices_callback(lights) -class TellstickLight(ToggleEntity): - """ Represents a tellstick light """ +class TellstickLight(Light): + """ Represents a Tellstick light. """ last_sent_command_mask = (tellcore_constants.TELLSTICK_TURNON | tellcore_constants.TELLSTICK_TURNOFF | tellcore_constants.TELLSTICK_DIM | @@ -38,7 +43,7 @@ class TellstickLight(ToggleEntity): def __init__(self, tellstick): self.tellstick = tellstick self.state_attr = {ATTR_FRIENDLY_NAME: tellstick.name} - self.brightness = 0 + self._brightness = 0 @property def name(self): @@ -48,34 +53,28 @@ class TellstickLight(ToggleEntity): @property def is_on(self): """ True if switch is on. """ - return self.brightness > 0 + return self._brightness > 0 + + @property + def brightness(self): + """ Brightness of this light between 0..255. """ + return self._brightness def turn_off(self, **kwargs): """ Turns the switch off. """ self.tellstick.turn_off() - self.brightness = 0 + self._brightness = 0 def turn_on(self, **kwargs): """ Turns the switch on. """ brightness = kwargs.get(ATTR_BRIGHTNESS) if brightness is None: - self.brightness = 255 + self._brightness = 255 else: - self.brightness = brightness + self._brightness = brightness - self.tellstick.dim(self.brightness) - - @property - def state_attributes(self): - """ Returns optional state attributes. """ - attr = { - ATTR_FRIENDLY_NAME: self.name - } - - attr[ATTR_BRIGHTNESS] = int(self.brightness) - - return attr + self.tellstick.dim(self._brightness) def update(self): """ Update state of the light. """ @@ -83,12 +82,12 @@ class TellstickLight(ToggleEntity): self.last_sent_command_mask) if last_command == tellcore_constants.TELLSTICK_TURNON: - self.brightness = 255 + self._brightness = 255 elif last_command == tellcore_constants.TELLSTICK_TURNOFF: - self.brightness = 0 + self._brightness = 0 elif (last_command == tellcore_constants.TELLSTICK_DIM or last_command == tellcore_constants.TELLSTICK_UP or last_command == tellcore_constants.TELLSTICK_DOWN): last_sent_value = self.tellstick.last_sent_value() if last_sent_value is not None: - self.brightness = last_sent_value + self._brightness = last_sent_value diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index 46fd3b54bb4..f41bfb56685 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -1,40 +1,38 @@ """ -Support for Vera lights. +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: -This component is useful if you wish for switches connected to your Vera -controller to appear as lights in homeassistant. All switches will be added -as a light unless you exclude them in the config. To use the Vera lights you will need to add something like the following to -your config/configuration.yaml +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 + platform: vera + vera_controller_url: http://YOUR_VERA_IP:3480/ + device_data: + 12: + name: My awesome switch + exclude: true + 13: + name: Another switch -VARIABLES: +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/ - +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 +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 - +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: @@ -42,20 +40,21 @@ 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 - +will be used. exclude *Optional -This parameter allows you to exclude the specified device from homeassistant, -it should be set to "true" if you want this device excluded +This parameter allows you to exclude the specified device from Home Assistant, +it should be set to "true" if you want this device excluded. """ import logging from requests.exceptions import RequestException from homeassistant.components.switch.vera import VeraSwitch -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.vera.vera as veraApi + +REQUIREMENTS = ['https://github.com/balloob/home-assistant-vera-api/archive/' + 'a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip' + '#python-vera==0.1'] _LOGGER = logging.getLogger(__name__) @@ -63,6 +62,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return Vera lights. """ + import pyvera as veraApi base_url = config.get('vera_controller_url') if not base_url: @@ -77,7 +77,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): controller = veraApi.VeraController(base_url) devices = [] try: - devices = controller.get_devices('Switch') + devices = controller.get_devices(['Switch', 'On/Off Switch']) except RequestException: # There was a network related error connecting to the vera controller _LOGGER.exception("Error communicating with Vera API") diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index dc7b7041611..98988c20688 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -1,16 +1,23 @@ -""" Support for Hue lights. """ +""" +homeassistant.components.light.wink +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Wink lights. +""" import logging -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.wink.pywink as pywink - 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'] + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return Wink lights. """ + import pywink + token = config.get(CONF_ACCESS_TOKEN) if not pywink.is_token_set() and token is None: @@ -27,7 +34,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class WinkLight(WinkToggleDevice): - """ Represents a Wink light """ + """ Represents a Wink light. """ # pylint: disable=too-few-public-methods def turn_on(self, **kwargs): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py new file mode 100644 index 00000000000..c7a403f12ec --- /dev/null +++ b/homeassistant/components/logbook.py @@ -0,0 +1,200 @@ +""" +homeassistant.components.logbook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Parses events and generates a human log. +""" +from datetime import timedelta +from itertools import groupby +import re + +from homeassistant.core import State, DOMAIN as HA_DOMAIN +from homeassistant.const import ( + EVENT_STATE_CHANGED, STATE_HOME, STATE_ON, STATE_OFF, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST) +import homeassistant.util.dt as dt_util +import homeassistant.components.recorder as recorder +import homeassistant.components.sun as sun + +DOMAIN = "logbook" +DEPENDENCIES = ['recorder', 'http'] + +URL_LOGBOOK = re.compile(r'/api/logbook(?:/(?P\d{4}-\d{1,2}-\d{1,2})|)') + +QUERY_EVENTS_BETWEEN = """ + SELECT * FROM events WHERE time_fired > ? AND time_fired < ? +""" + +GROUP_BY_MINUTES = 15 + + +def setup(hass, config): + """ Listens for download events to download files. """ + hass.http.register_path('GET', URL_LOGBOOK, _handle_get_logbook) + + return True + + +def _handle_get_logbook(handler, path_match, data): + """ Return logbook entries. """ + date_str = path_match.group('date') + + if date_str: + start_date = dt_util.date_str_to_date(date_str) + + if start_date is None: + handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST) + return + + start_day = dt_util.start_of_local_day(start_date) + else: + start_day = dt_util.start_of_local_day() + + end_day = start_day + timedelta(days=1) + + events = recorder.query_events( + QUERY_EVENTS_BETWEEN, + (dt_util.as_utc(start_day), dt_util.as_utc(end_day))) + + handler.write_json(humanify(events)) + + +class Entry(object): + """ A human readable version of the log. """ + + # pylint: disable=too-many-arguments, too-few-public-methods + + def __init__(self, when=None, name=None, message=None, domain=None, + entity_id=None): + self.when = when + self.name = name + self.message = message + self.domain = domain + self.entity_id = entity_id + + def as_dict(self): + """ Convert Entry to a dict to be used within JSON. """ + return { + 'when': dt_util.datetime_to_str(self.when), + 'name': self.name, + 'message': self.message, + 'domain': self.domain, + 'entity_id': self.entity_id, + } + + +def humanify(events): + """ + Generator that converts a list of events into Entry objects. + + Will try to group events if possible: + - if 2+ sensor updates in GROUP_BY_MINUTES, show last + - if home assistant stop and start happen in same minute call it restarted + """ + # pylint: disable=too-many-branches + + # Group events in batches of GROUP_BY_MINUTES + for _, g_events in groupby( + events, + lambda event: event.time_fired.minute // GROUP_BY_MINUTES): + + events_batch = list(g_events) + + # Keep track of last sensor states + last_sensor_event = {} + + # group HA start/stop events + # Maps minute of event to 1: stop, 2: stop + start + start_stop_events = {} + + # Process events + for event in events_batch: + if event.event_type == EVENT_STATE_CHANGED: + entity_id = event.data['entity_id'] + + if entity_id.startswith('sensor.'): + last_sensor_event[entity_id] = event + + elif event.event_type == EVENT_HOMEASSISTANT_STOP: + if event.time_fired.minute in start_stop_events: + continue + + start_stop_events[event.time_fired.minute] = 1 + + elif event.event_type == EVENT_HOMEASSISTANT_START: + if event.time_fired.minute not in start_stop_events: + continue + + start_stop_events[event.time_fired.minute] = 2 + + # Yield entries + for event in events_batch: + if event.event_type == EVENT_STATE_CHANGED: + + # Do not report on new entities + if 'old_state' not in event.data: + continue + + to_state = State.from_dict(event.data.get('new_state')) + + # if last_changed == last_updated only attributes have changed + # we do not report on that yet. + if not to_state or \ + to_state.last_changed != to_state.last_updated: + continue + + domain = to_state.domain + + # Skip all but the last sensor state + if domain == 'sensor' and \ + event != last_sensor_event[to_state.entity_id]: + continue + + yield Entry( + event.time_fired, + name=to_state.name, + message=_entry_message_from_state(domain, to_state), + domain=domain, + entity_id=to_state.entity_id) + + elif event.event_type == EVENT_HOMEASSISTANT_START: + if start_stop_events.get(event.time_fired.minute) == 2: + continue + + yield Entry( + event.time_fired, "Home Assistant", "started", + domain=HA_DOMAIN) + + elif event.event_type == EVENT_HOMEASSISTANT_STOP: + if start_stop_events.get(event.time_fired.minute) == 2: + action = "restarted" + else: + action = "stopped" + + yield Entry( + event.time_fired, "Home Assistant", action, + domain=HA_DOMAIN) + + +def _entry_message_from_state(domain, state): + """ Convert a state to a message for the logbook. """ + # We pass domain in so we don't have to split entity_id again + + if domain == 'device_tracker': + return '{} home'.format( + 'arrived' if state.state == STATE_HOME else 'left') + + elif domain == 'sun': + if state.state == sun.STATE_ABOVE_HORIZON: + return 'has risen' + else: + return 'has set' + + elif state.state == STATE_ON: + # Future: combine groups and its entity entries ? + return "turned on" + + elif state.state == STATE_OFF: + return "turned off" + + return "changed to {}".format(state.state) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 00fee802397..29bcb731062 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -2,7 +2,7 @@ homeassistant.components.media_player ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Component to interface with various media players +Component to interface with various media players. """ import logging @@ -10,9 +10,12 @@ from homeassistant.components import discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_VOLUME_UP, - SERVICE_VOLUME_DOWN, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, - SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREV_TRACK) + STATE_OFF, STATE_UNKNOWN, STATE_PLAYING, + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, + SERVICE_VOLUME_MUTE, + SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK) DOMAIN = 'media_player' DEPENDENCIES = [] @@ -21,99 +24,47 @@ SCAN_INTERVAL = 30 ENTITY_ID_FORMAT = DOMAIN + '.{}' DISCOVERY_PLATFORMS = { - discovery.services.GOOGLE_CAST: 'cast', + discovery.SERVICE_CAST: 'cast', } SERVICE_YOUTUBE_VIDEO = 'play_youtube_video' -STATE_NO_APP = 'idle' - -ATTR_STATE = 'state' -ATTR_OPTIONS = 'options' -ATTR_MEDIA_STATE = 'media_state' +ATTR_MEDIA_VOLUME_LEVEL = 'volume_level' +ATTR_MEDIA_VOLUME_MUTED = 'is_volume_muted' +ATTR_MEDIA_SEEK_POSITION = 'seek_position' ATTR_MEDIA_CONTENT_ID = 'media_content_id' +ATTR_MEDIA_CONTENT_TYPE = 'media_content_type' +ATTR_MEDIA_DURATION = 'media_duration' ATTR_MEDIA_TITLE = 'media_title' ATTR_MEDIA_ARTIST = 'media_artist' -ATTR_MEDIA_ALBUM = 'media_album' -ATTR_MEDIA_IMAGE_URL = 'media_image_url' -ATTR_MEDIA_VOLUME = 'media_volume' -ATTR_MEDIA_DURATION = 'media_duration' +ATTR_MEDIA_ALBUM_NAME = 'media_album_name' +ATTR_MEDIA_ALBUM_ARTIST = 'media_album_artist' +ATTR_MEDIA_TRACK = 'media_track' +ATTR_MEDIA_SERIES_TITLE = 'media_series_title' +ATTR_MEDIA_SEASON = 'media_season' +ATTR_MEDIA_EPISODE = 'media_episode' +ATTR_APP_ID = 'app_id' +ATTR_APP_NAME = 'app_name' +ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands' -MEDIA_STATE_UNKNOWN = 'unknown' -MEDIA_STATE_PLAYING = 'playing' -MEDIA_STATE_STOPPED = 'stopped' +MEDIA_TYPE_MUSIC = 'music' +MEDIA_TYPE_TVSHOW = 'tvshow' +MEDIA_TYPE_VIDEO = 'movie' +SUPPORT_PAUSE = 1 +SUPPORT_SEEK = 2 +SUPPORT_VOLUME_SET = 4 +SUPPORT_VOLUME_MUTE = 8 +SUPPORT_PREVIOUS_TRACK = 16 +SUPPORT_NEXT_TRACK = 32 +SUPPORT_YOUTUBE = 64 +SUPPORT_TURN_ON = 128 +SUPPORT_TURN_OFF = 256 -YOUTUBE_COVER_URL_FORMAT = 'http://img.youtube.com/vi/{}/1.jpg' - - -def is_on(hass, entity_id=None): - """ Returns true if specified media player entity_id is on. - Will check all media player if no entity_id specified. """ - - entity_ids = [entity_id] if entity_id else hass.states.entity_ids(DOMAIN) - - return any(not hass.states.is_state(entity_id, STATE_NO_APP) - for entity_id in entity_ids) - - -def turn_off(hass, entity_id=None): - """ Will turn off specified media player or all. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) - - -def volume_up(hass, entity_id=None): - """ Send the media player the command for volume up. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_VOLUME_UP, data) - - -def volume_down(hass, entity_id=None): - """ Send the media player the command for volume down. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN, data) - - -def media_play_pause(hass, entity_id=None): - """ Send the media player the command for play/pause. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data) - - -def media_play(hass, entity_id=None): - """ Send the media player the command for play/pause. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY, data) - - -def media_pause(hass, entity_id=None): - """ Send the media player the command for play/pause. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data) - - -def media_next_track(hass, entity_id=None): - """ Send the media player the command for next track. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data) - - -def media_prev_track(hass, entity_id=None): - """ Send the media player the command for prev track. """ - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - - hass.services.call(DOMAIN, SERVICE_MEDIA_PREV_TRACK, data) - +YOUTUBE_COVER_URL_FORMAT = 'https://img.youtube.com/vi/{}/1.jpg' SERVICE_TO_METHOD = { + SERVICE_TURN_ON: 'turn_on', SERVICE_TURN_OFF: 'turn_off', SERVICE_VOLUME_UP: 'volume_up', SERVICE_VOLUME_DOWN: 'volume_down', @@ -121,8 +72,110 @@ SERVICE_TO_METHOD = { SERVICE_MEDIA_PLAY: 'media_play', SERVICE_MEDIA_PAUSE: 'media_pause', SERVICE_MEDIA_NEXT_TRACK: 'media_next_track', + SERVICE_MEDIA_PREVIOUS_TRACK: 'media_previous_track', } +ATTR_TO_PROPERTY = [ + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ALBUM_ARTIST, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_SERIES_TITLE, + ATTR_MEDIA_SEASON, + ATTR_MEDIA_EPISODE, + ATTR_APP_ID, + ATTR_APP_NAME, + ATTR_SUPPORTED_MEDIA_COMMANDS, +] + + +def is_on(hass, entity_id=None): + """ Returns true if specified media player entity_id is on. + Will check all media player if no entity_id specified. """ + entity_ids = [entity_id] if entity_id else hass.states.entity_ids(DOMAIN) + return any(not hass.states.is_state(entity_id, STATE_OFF) + for entity_id in entity_ids) + + +def turn_on(hass, entity_id=None): + """ Will turn on specified media player or all. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + + +def turn_off(hass, entity_id=None): + """ Will turn off specified media player or all. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + + +def volume_up(hass, entity_id=None): + """ Send the media player the command for volume up. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_VOLUME_UP, data) + + +def volume_down(hass, entity_id=None): + """ Send the media player the command for volume down. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN, data) + + +def mute_volume(hass, mute, entity_id=None): + """ Send the media player the command for volume down. """ + data = {ATTR_MEDIA_VOLUME_MUTED: mute} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, data) + + +def set_volume_level(hass, volume, entity_id=None): + """ Send the media player the command for volume down. """ + data = {ATTR_MEDIA_VOLUME_LEVEL: volume} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_VOLUME_SET, data) + + +def media_play_pause(hass, entity_id=None): + """ Send the media player the command for play/pause. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data) + + +def media_play(hass, entity_id=None): + """ Send the media player the command for play/pause. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY, data) + + +def media_pause(hass, entity_id=None): + """ Send the media player the command for play/pause. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data) + + +def media_next_track(hass, entity_id=None): + """ Send the media player the command for next track. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data) + + +def media_previous_track(hass, entity_id=None): + """ Send the media player the command for prev track. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data) + def setup(hass, config): """ Track states and offer events for media_players. """ @@ -147,61 +200,298 @@ def setup(hass, config): for service in SERVICE_TO_METHOD: hass.services.register(DOMAIN, service, media_player_service_handler) - def play_youtube_video_service(service, media_id): - """ Plays specified media_id on the media player. """ + def volume_set_service(service): + """ Set specified volume on the media player. """ target_players = component.extract_from_service(service) - if media_id: - for player in target_players: - player.play_youtube(media_id) + if ATTR_MEDIA_VOLUME_LEVEL not in service.data: + return - hass.services.register(DOMAIN, "start_fireplace", - lambda service: - play_youtube_video_service(service, "eyU3bRy2x44")) + volume = service.data[ATTR_MEDIA_VOLUME_LEVEL] - hass.services.register(DOMAIN, "start_epic_sax", - lambda service: - play_youtube_video_service(service, "kxopViU98Xo")) + for player in target_players: + player.set_volume_level(volume) - hass.services.register(DOMAIN, SERVICE_YOUTUBE_VIDEO, - lambda service: - play_youtube_video_service( - service, service.data.get('video'))) + if player.should_poll: + player.update_ha_state(True) + + hass.services.register(DOMAIN, SERVICE_VOLUME_SET, volume_set_service) + + def volume_mute_service(service): + """ Mute (true) or unmute (false) the media player. """ + target_players = component.extract_from_service(service) + + if ATTR_MEDIA_VOLUME_MUTED not in service.data: + return + + mute = service.data[ATTR_MEDIA_VOLUME_MUTED] + + for player in target_players: + player.mute_volume(mute) + + if player.should_poll: + player.update_ha_state(True) + + hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE, volume_mute_service) + + def media_seek_service(service): + """ Seek to a position. """ + target_players = component.extract_from_service(service) + + if ATTR_MEDIA_SEEK_POSITION not in service.data: + return + + position = service.data[ATTR_MEDIA_SEEK_POSITION] + + for player in target_players: + player.seek(position) + + if player.should_poll: + player.update_ha_state(True) + + hass.services.register(DOMAIN, SERVICE_MEDIA_SEEK, media_seek_service) + + def play_youtube_video_service(service, media_id=None): + """ Plays specified media_id on the media player. """ + if media_id is None: + service.data.get('video') + + if media_id is None: + return + + for player in component.extract_from_service(service): + player.play_youtube(media_id) + + if player.should_poll: + player.update_ha_state(True) + + hass.services.register( + DOMAIN, "start_fireplace", + lambda service: play_youtube_video_service(service, "eyU3bRy2x44")) + + hass.services.register( + DOMAIN, "start_epic_sax", + lambda service: play_youtube_video_service(service, "kxopViU98Xo")) + + hass.services.register( + DOMAIN, SERVICE_YOUTUBE_VIDEO, play_youtube_video_service) return True class MediaPlayerDevice(Entity): """ ABC for media player devices. """ + # pylint: disable=too-many-public-methods,no-self-use + + # Implement these for your media player + + @property + def state(self): + """ State of the player. """ + return STATE_UNKNOWN + + @property + def volume_level(self): + """ Volume level of the media player (0..1). """ + return None + + @property + def is_volume_muted(self): + """ Boolean if volume is currently muted. """ + return None + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return None + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return None + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + return None + + @property + def media_image_url(self): + """ Image url of current playing media. """ + return None + + @property + def media_title(self): + """ Title of current playing media. """ + return None + + @property + def media_artist(self): + """ Artist of current playing media. (Music track only) """ + return None + + @property + def media_album_name(self): + """ Album name of current playing media. (Music track only) """ + return None + + @property + def media_album_artist(self): + """ Album arist of current playing media. (Music track only) """ + return None + + @property + def media_track(self): + """ Track number of current playing media. (Music track only) """ + return None + + @property + def media_series_title(self): + """ Series title of current playing media. (TV Show only)""" + return None + + @property + def media_season(self): + """ Season of current playing media. (TV Show only) """ + return None + + @property + def media_episode(self): + """ Episode of current playing media. (TV Show only) """ + return None + + @property + def app_id(self): + """ ID of the current running app. """ + return None + + @property + def app_name(self): + """ Name of the current running app. """ + return None + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return 0 + + @property + def device_state_attributes(self): + """ Extra attributes a device wants to expose. """ + return None + + def turn_on(self): + """ turn the media player on. """ + raise NotImplementedError() def turn_off(self): - """ turn_off media player. """ - pass + """ turn the media player off. """ + raise NotImplementedError() - def volume_up(self): - """ volume_up media player. """ - pass + def mute_volume(self, mute): + """ mute the volume. """ + raise NotImplementedError() - def volume_down(self): - """ volume_down media player. """ - pass - - def media_play_pause(self): - """ media_play_pause media player. """ - pass + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + raise NotImplementedError() def media_play(self): - """ media_play media player. """ - pass + """ Send play commmand. """ + raise NotImplementedError() def media_pause(self): - """ media_pause media player. """ - pass + """ Send pause command. """ + raise NotImplementedError() + + def media_previous_track(self): + """ Send previous track command. """ + raise NotImplementedError() def media_next_track(self): - """ media_next_track media player. """ - pass + """ Send next track command. """ + raise NotImplementedError() + + def media_seek(self, position): + """ Send seek command. """ + raise NotImplementedError() def play_youtube(self, media_id): """ Plays a YouTube media. """ - pass + raise NotImplementedError() + + # No need to overwrite these. + @property + def support_pause(self): + """ Boolean if pause is supported. """ + return bool(self.supported_media_commands & SUPPORT_PAUSE) + + @property + def support_seek(self): + """ Boolean if seek is supported. """ + return bool(self.supported_media_commands & SUPPORT_SEEK) + + @property + def support_volume_set(self): + """ Boolean if setting volume is supported. """ + return bool(self.supported_media_commands & SUPPORT_VOLUME_SET) + + @property + def support_volume_mute(self): + """ Boolean if muting volume is supported. """ + return bool(self.supported_media_commands & SUPPORT_VOLUME_MUTE) + + @property + def support_previous_track(self): + """ Boolean if previous track command supported. """ + return bool(self.supported_media_commands & SUPPORT_PREVIOUS_TRACK) + + @property + def support_next_track(self): + """ Boolean if next track command supported. """ + return bool(self.supported_media_commands & SUPPORT_NEXT_TRACK) + + @property + def support_youtube(self): + """ Boolean if YouTube is supported. """ + return bool(self.supported_media_commands & SUPPORT_YOUTUBE) + + def volume_up(self): + """ volume_up media player. """ + if self.volume_level < 1: + self.set_volume_level(min(1, self.volume_level + .1)) + + def volume_down(self): + """ volume_down media player. """ + if self.volume_level > 0: + self.set_volume_level(max(0, self.volume_level - .1)) + + def media_play_pause(self): + """ media_play_pause media player. """ + if self.state == STATE_PLAYING: + self.media_pause() + else: + self.media_play() + + @property + def state_attributes(self): + """ Return the state attributes. """ + if self.state == STATE_OFF: + state_attr = { + ATTR_SUPPORTED_MEDIA_COMMANDS: self.supported_media_commands, + } + else: + state_attr = { + attr: getattr(self, attr) for attr + in ATTR_TO_PROPERTY if getattr(self, attr) + } + + if self.media_image_url: + state_attr[ATTR_ENTITY_PICTURE] = self.media_image_url + + device_attr = self.device_state_attributes + + if device_attr: + state_attr.update(device_attr) + + return state_attr diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 32b86e4b90b..61223446e5f 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -1,54 +1,88 @@ """ 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 +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. """ import logging -try: - import pychromecast -except ImportError: - # We will throw error later - pass +from homeassistant.const import ( + STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF, + STATE_UNKNOWN, CONF_HOST) from homeassistant.components.media_player import ( - MediaPlayerDevice, STATE_NO_APP, ATTR_MEDIA_STATE, - ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_TITLE, ATTR_MEDIA_ARTIST, - ATTR_MEDIA_ALBUM, ATTR_MEDIA_IMAGE_URL, ATTR_MEDIA_DURATION, - ATTR_MEDIA_VOLUME, MEDIA_STATE_PLAYING, MEDIA_STATE_STOPPED) + MediaPlayerDevice, + SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE, + SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) + +REQUIREMENTS = ['pychromecast==0.6.12'] +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 +KNOWN_HOSTS = [] + +# pylint: disable=invalid-name +cast = None # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the cast platform. """ + global cast + import pychromecast + cast = pychromecast + logger = logging.getLogger(__name__) - try: - # pylint: disable=redefined-outer-name - import pychromecast - except ImportError: - logger.exception(("Failed to import pychromecast. " - "Did you maybe not install the 'pychromecast' " - "dependency?")) + # import CEC IGNORE attributes + ignore_cec = config.get(CONF_IGNORE_CEC, []) + if isinstance(ignore_cec, list): + cast.IGNORE_CEC += ignore_cec + else: + logger.error('Chromecast conig, %s must be a list.', CONF_IGNORE_CEC) - return + hosts = [] - if discovery_info: + if discovery_info and discovery_info[0] not in KNOWN_HOSTS: hosts = [discovery_info[0]] + elif CONF_HOST in config: + hosts = [config[CONF_HOST]] + else: - hosts = pychromecast.discover_chromecasts() + hosts = (host_port[0] for host_port + in cast.discover_chromecasts() + if host_port[0] not in KNOWN_HOSTS) casts = [] for host in hosts: try: casts.append(CastDevice(host)) - except pychromecast.ChromecastConnectionError: + except cast.ChromecastConnectionError: pass + else: + KNOWN_HOSTS.append(host) add_devices(casts) @@ -56,107 +90,202 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class CastDevice(MediaPlayerDevice): """ Represents a Cast device on the network. """ + # pylint: disable=too-many-public-methods + def __init__(self, host): - self.cast = pychromecast.PyChromecast(host) + import pychromecast.controllers.youtube as youtube + self.cast = cast.Chromecast(host) + self.youtube = youtube.YouTubeController() + self.cast.register_handler(self.youtube) + + self.cast.socket_client.receiver_controller.register_status_listener( + self) + self.cast.socket_client.media_controller.register_status_listener(self) + + self.cast_status = self.cast.status + self.media_status = self.cast.media_controller.status + + # Entity properties and methods + + @property + def should_poll(self): + return False @property def name(self): """ Returns the name of the device. """ return self.cast.device.friendly_name + # MediaPlayerDevice properties and methods + @property def state(self): - """ Returns the state of the device. """ - status = self.cast.app - - if status is None or status.app_id == pychromecast.APP_ID['HOME']: - return STATE_NO_APP + """ State of the player. """ + if self.media_status is None: + return STATE_UNKNOWN + elif self.media_status.player_is_playing: + return STATE_PLAYING + elif self.media_status.player_is_paused: + return STATE_PAUSED + elif self.media_status.player_is_idle: + return STATE_IDLE + elif self.cast.is_idle: + return STATE_OFF else: - return status.description + return STATE_UNKNOWN @property - def state_attributes(self): - """ Returns the state attributes. """ - ramp = self.cast.get_protocol(pychromecast.PROTOCOL_RAMP) + def volume_level(self): + """ Volume level of the media player (0..1). """ + return self.cast_status.volume_level if self.cast_status else None - if ramp and ramp.state != pychromecast.RAMP_STATE_UNKNOWN: - state_attr = {} + @property + def is_volume_muted(self): + """ Boolean if volume is currently muted. """ + return self.cast_status.volume_muted if self.cast_status else None - if ramp.state == pychromecast.RAMP_STATE_PLAYING: - state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_PLAYING - else: - state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_STOPPED + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return self.media_status.content_id if self.media_status else None - if ramp.content_id: - state_attr[ATTR_MEDIA_CONTENT_ID] = ramp.content_id + @property + def media_content_type(self): + """ Content type of current playing media. """ + if self.media_status is None: + return None + elif self.media_status.media_is_tvshow: + return MEDIA_TYPE_TVSHOW + elif self.media_status.media_is_movie: + return MEDIA_TYPE_VIDEO + elif self.media_status.media_is_musictrack: + return MEDIA_TYPE_MUSIC + return None - if ramp.title: - state_attr[ATTR_MEDIA_TITLE] = ramp.title + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + return self.media_status.duration if self.media_status else None - if ramp.artist: - state_attr[ATTR_MEDIA_ARTIST] = ramp.artist + @property + def media_image_url(self): + """ Image url of current playing media. """ + if self.media_status is None: + return None - if ramp.album: - state_attr[ATTR_MEDIA_ALBUM] = ramp.album + images = self.media_status.images - if ramp.image_url: - state_attr[ATTR_MEDIA_IMAGE_URL] = ramp.image_url + return images[0].url if images else None - if ramp.duration: - state_attr[ATTR_MEDIA_DURATION] = ramp.duration + @property + def media_title(self): + """ Title of current playing media. """ + return self.media_status.title if self.media_status else None - state_attr[ATTR_MEDIA_VOLUME] = ramp.volume + @property + def media_artist(self): + """ Artist of current playing media. (Music track only) """ + return self.media_status.artist if self.media_status else None - return state_attr + @property + def media_album(self): + """ Album of current playing media. (Music track only) """ + return self.media_status.album_name if self.media_status else None + + @property + def media_album_artist(self): + """ Album arist of current playing media. (Music track only) """ + return self.media_status.album_artist if self.media_status else None + + @property + def media_track(self): + """ Track number of current playing media. (Music track only) """ + return self.media_status.track if self.media_status else None + + @property + def media_series_title(self): + """ Series title of current playing media. (TV Show only)""" + return self.media_status.series_title if self.media_status else None + + @property + def media_season(self): + """ Season of current playing media. (TV Show only) """ + return self.media_status.season if self.media_status else None + + @property + def media_episode(self): + """ Episode of current playing media. (TV Show only) """ + return self.media_status.episode if self.media_status else None + + @property + def app_id(self): + """ ID of the current running app. """ + return self.cast.app_id + + @property + def app_name(self): + """ Name of the current running app. """ + return self.cast.app_display_name + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_CAST + + def turn_on(self): + """ Turns on the ChromeCast. """ + # The only way we can turn the Chromecast is on is by launching an app + if not self.cast.status or not self.cast.status.is_active_input: + if self.cast.app_id: + self.cast.quit_app() + + self.cast.play_media( + CAST_SPLASH, cast.STREAM_TYPE_BUFFERED) def turn_off(self): - """ Service to exit any running app on the specimedia player ChromeCast and - shows idle screen. Will quit all ChromeCasts if nothing specified. - """ + """ Turns Chromecast off. """ self.cast.quit_app() - def volume_up(self): - """ Service to send the chromecast the command for volume up. """ - ramp = self.cast.get_protocol(pychromecast.PROTOCOL_RAMP) + def mute_volume(self, mute): + """ mute the volume. """ + self.cast.set_volume_muted(mute) - if ramp: - ramp.volume_up() - - def volume_down(self): - """ Service to send the chromecast the command for volume down. """ - ramp = self.cast.get_protocol(pychromecast.PROTOCOL_RAMP) - - if ramp: - ramp.volume_down() - - def media_play_pause(self): - """ Service to send the chromecast the command for play/pause. """ - ramp = self.cast.get_protocol(pychromecast.PROTOCOL_RAMP) - - if ramp: - ramp.playpause() + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + self.cast.set_volume(volume) def media_play(self): - """ Service to send the chromecast the command for play/pause. """ - ramp = self.cast.get_protocol(pychromecast.PROTOCOL_RAMP) - - if ramp and ramp.state == pychromecast.RAMP_STATE_STOPPED: - ramp.playpause() + """ Send play commmand. """ + self.cast.media_controller.play() def media_pause(self): - """ Service to send the chromecast the command for play/pause. """ - ramp = self.cast.get_protocol(pychromecast.PROTOCOL_RAMP) + """ Send pause command. """ + self.cast.media_controller.pause() - if ramp and ramp.state == pychromecast.RAMP_STATE_PLAYING: - ramp.playpause() + def media_previous_track(self): + """ Send previous track command. """ + self.cast.media_controller.rewind() def media_next_track(self): - """ Service to send the chromecast the command for next track. """ - ramp = self.cast.get_protocol(pychromecast.PROTOCOL_RAMP) + """ Send next track command. """ + self.cast.media_controller.skip() - if ramp: - ramp.next() + def media_seek(self, position): + """ Seek the media to a specific location. """ + self.cast.media_controller.seek(position) - def play_youtube_video(self, video_id): - """ Plays specified video_id on the Chromecast's YouTube channel. """ - pychromecast.play_youtube_video(video_id, self.cast.host) + def play_youtube(self, media_id): + """ Plays a YouTube media. """ + self.youtube.play_video(media_id) + + # implementation of chromecast status_listener methods + + def new_cast_status(self, status): + """ Called when a new cast status is received. """ + self.cast_status = status + self.update_ha_state() + + def new_media_status(self, status): + """ Called when a new media status is received. """ + self.media_status = status + self.update_ha_state() diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 9ebdb85a92b..2a7bc5bde1b 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -1,105 +1,336 @@ """ -homeassistant.components.media_player.chromecast -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - +homeassistant.components.media_player.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Demo implementation of the media player. """ +from homeassistant.const import ( + STATE_PLAYING, STATE_PAUSED, STATE_OFF) from homeassistant.components.media_player import ( - MediaPlayerDevice, STATE_NO_APP, ATTR_MEDIA_STATE, - ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_TITLE, ATTR_MEDIA_DURATION, - ATTR_MEDIA_VOLUME, MEDIA_STATE_PLAYING, MEDIA_STATE_STOPPED, - YOUTUBE_COVER_URL_FORMAT) -from homeassistant.const import ATTR_ENTITY_PICTURE + MediaPlayerDevice, YOUTUBE_COVER_URL_FORMAT, + MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, + SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, SUPPORT_YOUTUBE, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the cast platform. """ add_devices([ - DemoMediaPlayer( + DemoYoutubePlayer( 'Living Room', 'eyU3bRy2x44', '♥♥ The Best Fireplace Video (3 hours)'), - DemoMediaPlayer('Bedroom', 'kxopViU98Xo', 'Epic sax guy 10 hours') + DemoYoutubePlayer('Bedroom', 'kxopViU98Xo', 'Epic sax guy 10 hours'), + DemoMusicPlayer(), DemoTVShowPlayer(), ]) -class DemoMediaPlayer(MediaPlayerDevice): - """ A Demo media player that only supports YouTube. """ +YOUTUBE_PLAYER_SUPPORT = \ + SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_YOUTUBE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF - def __init__(self, name, youtube_id=None, media_title=None): +MUSIC_PLAYER_SUPPORT = \ + SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +NETFLIX_PLAYER_SUPPORT = \ + SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF + + +class AbstractDemoPlayer(MediaPlayerDevice): + """ Base class for demo media players. """ + # We only implement the methods that we support + # pylint: disable=abstract-method + + def __init__(self, name): self._name = name - self.is_playing = youtube_id is not None - self.youtube_id = youtube_id - self.media_title = media_title - self.volume = 1.0 + self._player_state = STATE_PLAYING + self._volume_level = 1.0 + self._volume_muted = False @property def should_poll(self): - """ No polling needed for a demo componentn. """ + """ We will push an update after each command. """ return False @property def name(self): - """ Returns the name of the device. """ + """ Name of the media player. """ return self._name @property def state(self): - """ Returns the state of the device. """ - return STATE_NO_APP if self.youtube_id is None else "YouTube" + """ State of the player. """ + return self._player_state @property - def state_attributes(self): - """ Returns the state attributes. """ - if self.youtube_id is None: - return + def volume_level(self): + """ Volume level of the media player (0..1). """ + return self._volume_level - state_attr = { - ATTR_MEDIA_CONTENT_ID: self.youtube_id, - ATTR_MEDIA_TITLE: self.media_title, - ATTR_MEDIA_DURATION: 100, - ATTR_MEDIA_VOLUME: self.volume, - ATTR_ENTITY_PICTURE: - YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id) - } + @property + def is_volume_muted(self): + """ Boolean if volume is currently muted. """ + return self._volume_muted - if self.is_playing: - state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_PLAYING - else: - state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_STOPPED - - return state_attr + def turn_on(self): + """ turn the media player on. """ + self._player_state = STATE_PLAYING + self.update_ha_state() def turn_off(self): - """ turn_off media player. """ - self.youtube_id = None - self.is_playing = False + """ turn the media player off. """ + self._player_state = STATE_OFF + self.update_ha_state() - def volume_up(self): - """ volume_up media player. """ - if self.volume < 1: - self.volume += 0.1 + def mute_volume(self, mute): + """ mute the volume. """ + self._volume_muted = mute + self.update_ha_state() - def volume_down(self): - """ volume_down media player. """ - if self.volume > 0: - self.volume -= 0.1 - - def media_play_pause(self): - """ media_play_pause media player. """ - self.is_playing = not self.is_playing + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + self._volume_level = volume + self.update_ha_state() def media_play(self): - """ media_play media player. """ - self.is_playing = True + """ Send play commmand. """ + self._player_state = STATE_PLAYING + self.update_ha_state() def media_pause(self): - """ media_pause media player. """ - self.is_playing = False + """ Send pause command. """ + self._player_state = STATE_PAUSED + self.update_ha_state() + + +class DemoYoutubePlayer(AbstractDemoPlayer): + """ A Demo media player that only supports YouTube. """ + # We only implement the methods that we support + # pylint: disable=abstract-method + + def __init__(self, name, youtube_id=None, media_title=None): + super().__init__(name) + self.youtube_id = youtube_id + self._media_title = media_title + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return self.youtube_id + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return MEDIA_TYPE_VIDEO + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + return 360 + + @property + def media_image_url(self): + """ Image url of current playing media. """ + return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id) + + @property + def media_title(self): + """ Title of current playing media. """ + return self._media_title + + @property + def app_name(self): + """ Current running app. """ + return "YouTube" + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return YOUTUBE_PLAYER_SUPPORT def play_youtube(self, media_id): """ Plays a YouTube media. """ self.youtube_id = media_id - self.media_title = 'Demo media title' - self.is_playing = True + self._media_title = 'some YouTube video' + self.update_ha_state() + + +class DemoMusicPlayer(AbstractDemoPlayer): + """ A Demo media player that only supports YouTube. """ + # We only implement the methods that we support + # pylint: disable=abstract-method + + tracks = [ + ('Technohead', 'I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)'), + ('Paul Elstak', 'Luv U More'), + ('Dune', 'Hardcore Vibes'), + ('Nakatomi', 'Children Of The Night'), + ('Party Animals', + 'Have You Ever Been Mellow? (Flamman & Abraxas Radio Mix)'), + ('Rob G.*', 'Ecstasy, You Got What I Need'), + ('Lipstick', "I'm A Raver"), + ('4 Tune Fairytales', 'My Little Fantasy (Radio Edit)'), + ('Prophet', "The Big Boys Don't Cry"), + ('Lovechild', 'All Out Of Love (DJ Weirdo & Sim Remix)'), + ('Stingray & Sonic Driver', 'Cold As Ice (El Bruto Remix)'), + ('Highlander', 'Hold Me Now (Bass-D & King Matthew Remix)'), + ('Juggernaut', 'Ruffneck Rules Da Artcore Scene (12" Edit)'), + ('Diss Reaction', 'Jiiieehaaaa '), + ('Flamman And Abraxas', 'Good To Go (Radio Mix)'), + ('Critical Mass', 'Dancing Together'), + ('Charly Lownoise & Mental Theo', + 'Ultimate Sex Track (Bass-D & King Matthew Remix)'), + ] + + def __init__(self): + super().__init__('Walkman') + self._cur_track = 0 + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return 'bounzz-1' + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + return 213 + + @property + def media_image_url(self): + """ Image url of current playing media. """ + return 'https://graph.facebook.com/107771475912710/picture' + + @property + def media_title(self): + """ Title of current playing media. """ + return self.tracks[self._cur_track][1] + + @property + def media_artist(self): + """ Artist of current playing media. (Music track only) """ + return self.tracks[self._cur_track][0] + + @property + def media_album_name(self): + """ Album of current playing media. (Music track only) """ + # pylint: disable=no-self-use + return "Bounzz" + + @property + def media_track(self): + """ Track number of current playing media. (Music track only) """ + return self._cur_track + 1 + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + support = MUSIC_PLAYER_SUPPORT + + if self._cur_track > 1: + support |= SUPPORT_PREVIOUS_TRACK + + if self._cur_track < len(self.tracks)-1: + support |= SUPPORT_NEXT_TRACK + + return support + + def media_previous_track(self): + """ Send previous track command. """ + if self._cur_track > 0: + self._cur_track -= 1 + self.update_ha_state() + + def media_next_track(self): + """ Send next track command. """ + if self._cur_track < len(self.tracks)-1: + self._cur_track += 1 + self.update_ha_state() + + +class DemoTVShowPlayer(AbstractDemoPlayer): + """ A Demo media player that only supports YouTube. """ + # We only implement the methods that we support + # pylint: disable=abstract-method + + def __init__(self): + super().__init__('Lounge room') + self._cur_episode = 1 + self._episode_count = 13 + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return 'house-of-cards-1' + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return MEDIA_TYPE_TVSHOW + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + return 3600 + + @property + def media_image_url(self): + """ Image url of current playing media. """ + return 'https://graph.facebook.com/HouseofCards/picture' + + @property + def media_title(self): + """ Title of current playing media. """ + return 'Chapter {}'.format(self._cur_episode) + + @property + def media_series_title(self): + """ Series title of current playing media. (TV Show only)""" + return 'House of Cards' + + @property + def media_season(self): + """ Season of current playing media. (TV Show only) """ + return 1 + + @property + def media_episode(self): + """ Episode of current playing media. (TV Show only) """ + return self._cur_episode + + @property + def app_name(self): + """ Current running app. """ + return "Netflix" + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + support = NETFLIX_PLAYER_SUPPORT + + if self._cur_episode > 1: + support |= SUPPORT_PREVIOUS_TRACK + + if self._cur_episode < self._episode_count: + support |= SUPPORT_NEXT_TRACK + + return support + + def media_previous_track(self): + """ Send previous track command. """ + if self._cur_episode > 1: + self._cur_episode -= 1 + self.update_ha_state() + + def media_next_track(self): + """ Send next track command. """ + if self._cur_episode < self._episode_count: + self._cur_episode += 1 + self.update_ha_state() diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py new file mode 100644 index 00000000000..19286906f49 --- /dev/null +++ b/homeassistant/components/media_player/denon.py @@ -0,0 +1,191 @@ +""" +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. +""" +import telnetlib +import logging + +from homeassistant.components.media_player import ( + MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + DOMAIN) +from homeassistant.const import ( + CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_DENON = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Denon platform. """ + if not config.get(CONF_HOST): + _LOGGER.error( + "Missing required configuration items in %s: %s", + DOMAIN, + CONF_HOST) + return False + + add_devices([ + DenonDevice( + config.get('name', 'Music station'), + config.get('host')) + ]) + + return True + + +class DenonDevice(MediaPlayerDevice): + """ Represents a Denon device. """ + + # pylint: disable=too-many-public-methods + + def __init__(self, name, host): + self._name = name + self._host = host + self._telnet = telnetlib.Telnet(self._host) + + def query(self, message): + """ Send request and await response from server """ + try: + # unspecified command, should be ignored + self._telnet.write("?".encode('UTF-8') + b'\r') + except (EOFError, BrokenPipeError, ConnectionResetError): + self._telnet.open(self._host) + + self._telnet.read_very_eager() # skip what is not requested + + 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() + + if message == "PW?": + # workaround; PW? sends also SISTATUS + self._telnet.read_until(b'\r', timeout=0.2) + + return resp + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + pwstate = self.query('PW?') + if pwstate == "PWSTANDBY": + return STATE_OFF + if pwstate == "PWON": + return STATE_ON + + return STATE_UNKNOWN + + @property + def volume_level(self): + """ Volume level of the media player (0..1). """ + return int(self.query('MV?')[len('MV'):]) / 60 + + @property + def is_volume_muted(self): + """ Boolean if volume is currently muted. """ + return self.query('MU?') == "MUON" + + @property + def media_title(self): + """ Current media source. """ + return self.query('SI?')[len('SI'):] + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_DENON + + def turn_off(self): + """ turn_off media player. """ + self.query('PWSTANDBY') + + def volume_up(self): + """ volume_up media player. """ + self.query('MVUP') + + def volume_down(self): + """ volume_down media player. """ + self.query('MVDOWN') + + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + # 60dB max + self.query('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')) + + def media_play_pause(self): + """ media_play_pause media player. """ + raise NotImplementedError() + + def media_play(self): + """ media_play media player. """ + self.query('NS9A') + + def media_pause(self): + """ media_pause media player. """ + self.query('NS9B') + + def media_next_track(self): + """ Send next track command. """ + self.query('NS9D') + + def media_previous_track(self): + self.query('NS9E') + + def media_seek(self, position): + raise NotImplementedError() + + def turn_on(self): + """ turn the media player on. """ + self.query('PWON') diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py new file mode 100644 index 00000000000..ab8af7787c3 --- /dev/null +++ b/homeassistant/components/media_player/kodi.py @@ -0,0 +1,306 @@ +""" +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. +""" +import urllib +import logging + +from homeassistant.components.media_player import ( + MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK) +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'] + +SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK + + +# pylint: disable=unused-argument +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'), + config.get('url'), + auth=( + config.get('user', ''), + config.get('password', ''))), + ]) + + +def _get_image_url(kodi_url): + """ Helper function that parses the thumbnail URLs used by Kodi. """ + url_components = urllib.parse.urlparse(kodi_url) + + if url_components.scheme == 'image': + return urllib.parse.unquote(url_components.netloc) + + +class KodiDevice(MediaPlayerDevice): + """ Represents a XBMC/Kodi device. """ + + # pylint: disable=too-many-public-methods + + def __init__(self, name, url, auth=None): + self._name = name + self._url = url + self._server = jsonrpc_requests.Server(url, auth=auth) + self._players = None + self._properties = None + self._item = None + self._app_properties = None + + self.update() + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + def _get_players(self): + """ Returns the active player objects or None """ + try: + return self._server.Player.GetActivePlayers() + except jsonrpc_requests.jsonrpc.TransportError: + return None + + @property + def state(self): + """ Returns the state of the device. """ + if self._players is None: + return STATE_OFF + + if len(self._players) == 0: + return STATE_IDLE + + if self._properties['speed'] == 0: + return STATE_PAUSED + else: + return STATE_PLAYING + + def update(self): + """ Retrieve latest state. """ + self._players = self._get_players() + + if self._players is not None and len(self._players) > 0: + player_id = self._players[0]['playerid'] + + assert isinstance(player_id, int) + + self._properties = self._server.Player.GetProperties( + player_id, + ['time', 'totaltime', 'speed'] + ) + + self._item = self._server.Player.GetItem( + player_id, + ['title', 'file', 'uniqueid', 'thumbnail', 'artist'] + )['item'] + + self._app_properties = self._server.Application.GetProperties( + ['volume', 'muted'] + ) + else: + self._properties = None + self._item = None + self._app_properties = None + + @property + def volume_level(self): + """ Volume level of the media player (0..1). """ + if self._app_properties is not None: + return self._app_properties['volume'] / 100.0 + + @property + def is_volume_muted(self): + """ Boolean if volume is currently muted. """ + if self._app_properties is not None: + return self._app_properties['muted'] + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + if self._item is not None: + return self._item['uniqueid'] + + @property + def media_content_type(self): + """ Content type of current playing media. """ + if self._players is not None and len(self._players) > 0: + return self._players[0]['type'] + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + if self._properties is not None: + total_time = self._properties['totaltime'] + + return ( + total_time['hours'] * 3600 + + total_time['minutes'] * 60 + + total_time['seconds']) + + @property + def media_image_url(self): + """ Image url of current playing media. """ + if self._item is not None: + return _get_image_url(self._item['thumbnail']) + + @property + def media_title(self): + """ Title of current playing media. """ + # find a string we can use as a title + if self._item is not None: + return self._item.get( + 'title', + self._item.get( + 'label', + self._item.get( + 'file', + 'unknown'))) + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_KODI + + def turn_off(self): + """ turn_off media player. """ + self._server.System.Shutdown() + self.update_ha_state() + + def volume_up(self): + """ volume_up media player. """ + assert self._server.Input.ExecuteAction('volumeup') == 'OK' + self.update_ha_state() + + def volume_down(self): + """ volume_down media player. """ + assert self._server.Input.ExecuteAction('volumedown') == 'OK' + self.update_ha_state() + + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + self._server.Application.SetVolume(int(volume * 100)) + self.update_ha_state() + + def mute_volume(self, mute): + """ mute (true) or unmute (false) media player. """ + self._server.Application.SetMute(mute) + self.update_ha_state() + + def _set_play_state(self, state): + """ Helper method for play/pause/toggle. """ + players = self._get_players() + + if len(players) != 0: + self._server.Player.PlayPause(players[0]['playerid'], state) + + self.update_ha_state() + + def media_play_pause(self): + """ media_play_pause media player. """ + self._set_play_state('toggle') + + def media_play(self): + """ media_play media player. """ + self._set_play_state(True) + + def media_pause(self): + """ media_pause media player. """ + self._set_play_state(False) + + def _goto(self, direction): + """ Helper method used for previous/next track. """ + players = self._get_players() + + if len(players) != 0: + self._server.Player.GoTo(players[0]['playerid'], direction) + + self.update_ha_state() + + def media_next_track(self): + """ Send next track command. """ + self._goto('next') + + def media_previous_track(self): + """ Send next track command. """ + # first seek to position 0, Kodi seems to go to the beginning + # of the current track current track is not at the beginning + self.media_seek(0) + self._goto('previous') + + def media_seek(self, position): + """ Send seek command. """ + players = self._get_players() + + time = {} + + time['milliseconds'] = int((position % 1) * 1000) + position = int(position) + + time['seconds'] = int(position % 60) + position /= 60 + + time['minutes'] = int(position % 60) + position /= 60 + + time['hours'] = int(position) + + if len(players) != 0: + self._server.Player.Seek(players[0]['playerid'], time) + + self.update_ha_state() + + def turn_on(self): + """ turn the media player on. """ + raise NotImplementedError() + + def play_youtube(self, media_id): + """ Plays a YouTube media. """ + raise NotImplementedError() diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py new file mode 100644 index 00000000000..8cc22f9b982 --- /dev/null +++ b/homeassistant/components/media_player/mpd.py @@ -0,0 +1,228 @@ +""" +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. +""" +import logging +import socket + +try: + import mpd +except ImportError: + mpd = None + + +from homeassistant.const import ( + STATE_PLAYING, STATE_PAUSED, STATE_OFF) + +from homeassistant.components.media_player import ( + MediaPlayerDevice, + SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_TURN_OFF, + 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 + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the MPD platform. """ + + daemon = config.get('server', None) + port = config.get('port', 6600) + location = config.get('location', 'MPD') + password = config.get('password', None) + + global mpd # pylint: disable=invalid-name + if mpd is None: + import mpd as mpd_ + mpd = mpd_ + + # pylint: disable=no-member + try: + mpd_client = mpd.MPDClient() + mpd_client.connect(daemon, port) + + if password is not None: + mpd_client.password(password) + + mpd_client.close() + mpd_client.disconnect() + except socket.error: + _LOGGER.error( + "Unable to connect to MPD. " + "Please check your settings") + + return False + except mpd.CommandError as error: + + if "incorrect password" in str(error): + _LOGGER.error( + "MPD reported incorrect password. " + "Please check your password.") + + return False + else: + raise + + add_devices([MpdDevice(daemon, port, location, password)]) + + +class MpdDevice(MediaPlayerDevice): + """ Represents a MPD server. """ + + # MPD confuses pylint + # pylint: disable=no-member, abstract-method + + def __init__(self, server, port, location, password): + self.server = server + self.port = port + self._name = location + self.password = password + self.status = None + self.currentsong = None + + self.client = mpd.MPDClient() + self.client.timeout = 10 + self.client.idletimeout = None + self.update() + + def update(self): + try: + self.status = self.client.status() + self.currentsong = self.client.currentsong() + except mpd.ConnectionError: + self.client.connect(self.server, self.port) + + if self.password is not None: + self.client.password(self.password) + + self.status = self.client.status() + self.currentsong = self.client.currentsong() + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the media state. """ + if self.status['state'] == 'play': + return STATE_PLAYING + elif self.status['state'] == 'pause': + return STATE_PAUSED + else: + return STATE_OFF + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return self.currentsong['id'] + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + # Time does not exist for streams + return self.currentsong.get('time') + + @property + def media_title(self): + """ Title of current playing media. """ + return self.currentsong['title'] + + @property + def media_artist(self): + """ Artist of current playing media. (Music track only) """ + return self.currentsong.get('artist') + + @property + def media_album_name(self): + """ Album of current playing media. (Music track only) """ + return self.currentsong.get('album') + + @property + def volume_level(self): + return int(self.status['volume'])/100 + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_MPD + + def turn_off(self): + """ Service to exit the running MPD. """ + self.client.stop() + + def set_volume_level(self, volume): + """ Sets volume """ + self.client.setvol(int(volume * 100)) + + def volume_up(self): + """ Service to send the MPD the command for volume up. """ + current_volume = int(self.status['volume']) + + if current_volume <= 100: + self.client.setvol(current_volume + 5) + + def volume_down(self): + """ Service to send the MPD the command for volume down. """ + current_volume = int(self.status['volume']) + + if current_volume >= 0: + self.client.setvol(current_volume - 5) + + def media_play(self): + """ Service to send the MPD the command for play/pause. """ + self.client.pause(0) + + def media_pause(self): + """ Service to send the MPD the command for play/pause. """ + self.client.pause(1) + + def media_next_track(self): + """ Service to send the MPD the command for next track. """ + self.client.next() + + def media_previous_track(self): + """ Service to send the MPD the command for previous track. """ + self.client.previous() diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py new file mode 100644 index 00000000000..940aa890f3a --- /dev/null +++ b/homeassistant/components/media_player/squeezebox.py @@ -0,0 +1,319 @@ +""" +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. +""" + +import logging +import telnetlib +import urllib.parse + +from homeassistant.components.media_player import ( + MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + MEDIA_TYPE_MUSIC, DOMAIN) + +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, + STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_UNKNOWN) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK |\ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the squeezebox platform. """ + if not config.get(CONF_HOST): + _LOGGER.error( + "Missing required configuration items in %s: %s", + DOMAIN, + CONF_HOST) + return False + + lms = LogitechMediaServer( + config.get(CONF_HOST), + config.get('port', '9090'), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)) + + if not lms.init_success: + return False + + add_devices(lms.create_players()) + + return True + + +class LogitechMediaServer(object): + """ Represents a Logitech media server. """ + + def __init__(self, host, port, username, password): + self.host = host + self.port = port + self._username = username + self._password = password + self.http_port = self._get_http_port() + self.init_success = True if self.http_port else False + + def _get_http_port(self): + """ Get http port from media server, it is used to get cover art. """ + http_port = None + try: + http_port = self.query('pref', 'httpport', '?') + if not http_port: + _LOGGER.error( + "Unable to read data from server %s:%s", + self.host, + self.port) + return + return http_port + except ConnectionError as ex: + _LOGGER.error( + "Failed to connect to server %s:%s - %s", + self.host, + self.port, + ex) + return + + def create_players(self): + """ Create a list of SqueezeBoxDevices connected to the LMS. """ + players = [] + count = self.query('player', 'count', '?') + for index in range(0, int(count)): + player_id = self.query('player', 'id', str(index), '?') + player = SqueezeBoxDevice(self, player_id) + players.append(player) + return players + + def query(self, *parameters): + """ Send request and await response from server. """ + telnet = telnetlib.Telnet(self.host, self.port) + if self._username and self._password: + telnet.write('login {username} {password}\n'.format( + username=self._username, + password=self._password).encode('UTF-8')) + telnet.read_until(b'\n', timeout=3) + message = '{}\n'.format(' '.join(parameters)) + telnet.write(message.encode('UTF-8')) + response = telnet.read_until(b'\n', timeout=3)\ + .decode('UTF-8')\ + .split(' ')[-1]\ + .strip() + telnet.write(b'exit\n') + return urllib.parse.unquote(response) + + def get_player_status(self, player): + """ Get ithe status of a player. """ + # (title) : Song title + # Requested Information + # a (artist): Artist name 'artist' + # d (duration): Song duration in seconds 'duration' + # K (artwork_url): URL to remote artwork + tags = 'adK' + new_status = {} + telnet = telnetlib.Telnet(self.host, self.port) + telnet.write('{player} status - 1 tags:{tags}\n'.format( + player=player, + tags=tags + ).encode('UTF-8')) + response = telnet.read_until(b'\n', timeout=3)\ + .decode('UTF-8')\ + .split(' ') + telnet.write(b'exit\n') + for item in response: + parts = urllib.parse.unquote(item).partition(':') + new_status[parts[0]] = parts[2] + return new_status + + +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-public-methods +class SqueezeBoxDevice(MediaPlayerDevice): + """ Represents a SqueezeBox device. """ + + # pylint: disable=too-many-arguments + def __init__(self, lms, player_id): + super(SqueezeBoxDevice, self).__init__() + self._lms = lms + self._id = player_id + self._name = self._lms.query(self._id, 'name', '?') + self._status = self._lms.get_player_status(self._id) + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + if 'power' in self._status and self._status['power'] == '0': + return STATE_OFF + if 'mode' in self._status: + if self._status['mode'] == 'pause': + return STATE_PAUSED + if self._status['mode'] == 'play': + return STATE_PLAYING + if self._status['mode'] == 'stop': + return STATE_IDLE + return STATE_UNKNOWN + + def update(self): + """ Retrieve latest state. """ + self._status = self._lms.get_player_status(self._id) + + @property + 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 + + @property + def is_volume_muted(self): + if 'mixer volume' in self._status: + return self._status['mixer volume'].startswith('-') + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + if 'current_title' in self._status: + return self._status['current_title'] + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + if 'duration' in self._status: + return int(float(self._status['duration'])) + + @property + def media_image_url(self): + """ Image url of current playing media. """ + if 'artwork_url' in self._status: + return self._status['artwork_url'] + return 'http://{server}:{port}/music/current/cover.jpg?player={player}'\ + .format( + server=self._lms.host, + port=self._lms.http_port, + player=self._id) + + @property + def media_title(self): + """ Title of current playing media. """ + if 'artist' in self._status and 'title' in self._status: + return '{artist} - {title}'.format( + artist=self._status['artist'], + title=self._status['title'] + ) + if 'current_title' in self._status: + return self._status['current_title'] + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_SQUEEZEBOX + + def turn_off(self): + """ turn_off media player. """ + self._lms.query(self._id, 'power', '0') + self.update_ha_state() + + def volume_up(self): + """ volume_up media player. """ + self._lms.query(self._id, 'mixer', 'volume', '+5') + self.update_ha_state() + + def volume_down(self): + """ volume_down media player. """ + self._lms.query(self._id, 'mixer', 'volume', '-5') + self.update_ha_state() + + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + volume_percent = str(int(volume*100)) + self._lms.query(self._id, 'mixer', 'volume', volume_percent) + self.update_ha_state() + + def mute_volume(self, mute): + """ mute (true) or unmute (false) media player. """ + mute_numeric = '1' if mute else '0' + self._lms.query(self._id, 'mixer', 'muting', mute_numeric) + self.update_ha_state() + + def media_play_pause(self): + """ media_play_pause media player. """ + self._lms.query(self._id, 'pause') + self.update_ha_state() + + def media_play(self): + """ media_play media player. """ + self._lms.query(self._id, 'play') + self.update_ha_state() + + def media_pause(self): + """ media_pause media player. """ + self._lms.query(self._id, 'pause', '0') + self.update_ha_state() + + def media_next_track(self): + """ Send next track command. """ + self._lms.query(self._id, 'playlist', 'index', '+1') + self.update_ha_state() + + def media_previous_track(self): + """ Send next track command. """ + self._lms.query(self._id, 'playlist', 'index', '-1') + self.update_ha_state() + + def media_seek(self, position): + """ Send seek command. """ + self._lms.query(self._id, 'time', position) + self.update_ha_state() + + def turn_on(self): + """ turn the media player on. """ + self._lms.query(self._id, 'power', '1') + self.update_ha_state() + + def play_youtube(self, media_id): + """ Plays a YouTube media. """ + raise NotImplementedError() diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py new file mode 100644 index 00000000000..844e59ea189 --- /dev/null +++ b/homeassistant/components/modbus.py @@ -0,0 +1,103 @@ +""" +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 + +""" +import logging + +from homeassistant.const import (EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) + +DOMAIN = "modbus" + +DEPENDENCIES = [] +REQUIREMENTS = ['https://github.com/bashwork/pymodbus/archive/' + 'd7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0'] + +# Type of network +MEDIUM = "type" + +# if MEDIUM == "serial" +METHOD = "method" +SERIAL_PORT = "port" +BAUDRATE = "baudrate" +STOPBITS = "stopbits" +BYTESIZE = "bytesize" +PARITY = "parity" + +# if MEDIUM == "tcp" or "udp" +HOST = "host" +IP_PORT = "port" + +_LOGGER = logging.getLogger(__name__) + +NETWORK = None +TYPE = None + + +def setup(hass, config): + """ Setup Modbus component. """ + + # Modbus connection type + # pylint: disable=global-statement, import-error + global TYPE + TYPE = config[DOMAIN][MEDIUM] + + # Connect to Modbus network + # pylint: disable=global-statement, import-error + global NETWORK + + if TYPE == "serial": + from pymodbus.client.sync import ModbusSerialClient as ModbusClient + NETWORK = ModbusClient(method=config[DOMAIN][METHOD], + port=config[DOMAIN][SERIAL_PORT], + baudrate=config[DOMAIN][BAUDRATE], + stopbits=config[DOMAIN][STOPBITS], + bytesize=config[DOMAIN][BYTESIZE], + parity=config[DOMAIN][PARITY]) + elif TYPE == "tcp": + from pymodbus.client.sync import ModbusTcpClient as ModbusClient + NETWORK = ModbusClient(host=config[DOMAIN][HOST], + port=config[DOMAIN][IP_PORT]) + elif TYPE == "udp": + from pymodbus.client.sync import ModbusUdpClient as ModbusClient + NETWORK = ModbusClient(host=config[DOMAIN][HOST], + port=config[DOMAIN][IP_PORT]) + else: + return False + + def stop_modbus(event): + """ Stop Modbus service. """ + NETWORK.close() + + def start_modbus(event): + """ Start Modbus service. """ + NETWORK.connect() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) + + # Tells the bootstrapper that the component was successfully initialized + return True diff --git a/homeassistant/components/mqtt.py b/homeassistant/components/mqtt.py new file mode 100644 index 00000000000..e157f15f84b --- /dev/null +++ b/homeassistant/components/mqtt.py @@ -0,0 +1,257 @@ +""" +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. + +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 + +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. +""" +import logging +import socket + +from homeassistant.exceptions import HomeAssistantError +import homeassistant.util as util +from homeassistant.helpers import validate_config +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "mqtt" + +MQTT_CLIENT = None + +DEFAULT_PORT = 1883 +DEFAULT_KEEPALIVE = 60 + +SERVICE_PUBLISH = 'publish' +EVENT_MQTT_MESSAGE_RECEIVED = 'MQTT_MESSAGE_RECEIVED' + +DEPENDENCIES = [] +REQUIREMENTS = ['paho-mqtt==1.1'] + +CONF_BROKER = 'broker' +CONF_PORT = 'port' +CONF_CLIENT_ID = 'client_id' +CONF_KEEPALIVE = 'keepalive' +CONF_USERNAME = 'username' +CONF_PASSWORD = 'password' + +ATTR_TOPIC = 'topic' +ATTR_PAYLOAD = 'payload' +ATTR_QOS = 'qos' + + +def publish(hass, topic, payload, qos=0): + """ Send an MQTT message. """ + data = { + ATTR_TOPIC: topic, + ATTR_PAYLOAD: payload, + ATTR_QOS: qos, + } + hass.services.call(DOMAIN, SERVICE_PUBLISH, data) + + +def subscribe(hass, topic, callback, qos=0): + """ Subscribe to a topic. """ + def mqtt_topic_subscriber(event): + """ Match subscribed MQTT topic. """ + if _match_topic(topic, event.data[ATTR_TOPIC]): + callback(event.data[ATTR_TOPIC], event.data[ATTR_PAYLOAD], + 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) + + +def setup(hass, config): + """ Get the MQTT protocol service. """ + + if not validate_config(config, {DOMAIN: ['broker']}, _LOGGER): + return False + + conf = config[DOMAIN] + + broker = conf[CONF_BROKER] + port = util.convert(conf.get(CONF_PORT), int, DEFAULT_PORT) + client_id = util.convert(conf.get(CONF_CLIENT_ID), str) + keepalive = util.convert(conf.get(CONF_KEEPALIVE), int, DEFAULT_KEEPALIVE) + username = util.convert(conf.get(CONF_USERNAME), str) + password = util.convert(conf.get(CONF_PASSWORD), str) + + global MQTT_CLIENT + try: + MQTT_CLIENT = MQTT(hass, broker, port, client_id, keepalive, username, + password) + except socket.error: + _LOGGER.exception("Can't connect to the broker. " + "Please check your settings and the broker " + "itself.") + return False + + def stop_mqtt(event): + """ Stop MQTT component. """ + MQTT_CLIENT.stop() + + def start_mqtt(event): + """ Launch MQTT component when Home Assistant starts up. """ + MQTT_CLIENT.start() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_mqtt) + + def publish_service(call): + """ Handle MQTT publish service calls. """ + msg_topic = call.data.get(ATTR_TOPIC) + payload = call.data.get(ATTR_PAYLOAD) + qos = call.data.get(ATTR_QOS) + if msg_topic is None or payload is None: + return + MQTT_CLIENT.publish(msg_topic, payload, qos) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_mqtt) + + hass.services.register(DOMAIN, SERVICE_PUBLISH, publish_service) + + 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 + """ Implements messaging service for MQTT. """ + def __init__(self, hass, broker, port, client_id, keepalive, username, + password): + import paho.mqtt.client as mqtt + + self.hass = hass + self._progress = {} + self.topics = {} + + if client_id is None: + self._mqttc = mqtt.Client() + else: + self._mqttc = mqtt.Client(client_id) + if username is not None: + self._mqttc.username_pw_set(username, password) + 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.connect(broker, port, keepalive) + + def publish(self, topic, payload, qos): + """ 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 + + def start(self): + """ Run the MQTT client. """ + self._mqttc.loop_start() + + def stop(self): + """ Stop the MQTT client. """ + self._mqttc.loop_stop() + + def subscribe(self, topic, qos): + """ Subscribe to a topic. """ + if topic in self.topics: + return + result, mid = self._mqttc.subscribe(topic, qos) + _raise_on_error(result) + self._progress[mid] = topic + self.topics[topic] = None + + def _mqtt_on_connect(self, mqttc, obj, flags, result_code): + """ On connect, resubscribe to all topics we were subscribed to. """ + 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 _raise_on_error(result): # pragma: no cover + """ Raise error if error result. """ + if result != 0: + raise HomeAssistantError('Error talking to MQTT: {}'.format(result)) + + +def _match_topic(subscription, topic): + """ Returns if topic matches subscription. """ + if subscription.endswith('#'): + return (subscription[:-2] == topic or + topic.startswith(subscription[:-1])) + + sub_parts = subscription.split('/') + topic_parts = topic.split('/') + + return (len(sub_parts) == len(topic_parts) and + all(a == b for a, b in zip(sub_parts, topic_parts) if a != '+')) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 0728a979588..ee53159d5e6 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -1,15 +1,16 @@ """ homeassistant.components.notify -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to notify people. """ +from functools import partial import logging from homeassistant.loader import get_component -from homeassistant.helpers import validate_config +from homeassistant.helpers import config_per_platform -from homeassistant.const import CONF_PLATFORM +from homeassistant.const import CONF_NAME DOMAIN = "notify" DEPENDENCIES = [] @@ -33,47 +34,50 @@ def send_message(hass, message): def setup(hass, config): """ Sets up notify services. """ + success = False - if not validate_config(config, {DOMAIN: [CONF_PLATFORM]}, _LOGGER): - return False + for platform, p_config in config_per_platform(config, DOMAIN, _LOGGER): + # get platform + notify_implementation = get_component( + 'notify.{}'.format(platform)) - platform = config[DOMAIN].get(CONF_PLATFORM) + if notify_implementation is None: + _LOGGER.error("Unknown notification service specified.") + continue - notify_implementation = get_component( - 'notify.{}'.format(platform)) + # create platform service + notify_service = notify_implementation.get_service( + hass, {DOMAIN: p_config}) - if notify_implementation is None: - _LOGGER.error("Unknown notification service specified.") + if notify_service is None: + _LOGGER.error("Failed to initialize notification service %s", + platform) + continue - return False + # create service handler + def notify_message(notify_service, call): + """ Handle sending notification message service calls. """ + message = call.data.get(ATTR_MESSAGE) - notify_service = notify_implementation.get_service(hass, config) + if message is None: + return - if notify_service is None: - _LOGGER.error("Failed to initialize notification service %s", - platform) + title = call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - return False + notify_service.send_message(message, title=title) - def notify_message(call): - """ Handle sending notification message service calls. """ - message = call.data.get(ATTR_MESSAGE) + # register service + service_call_handler = partial(notify_message, notify_service) + service_notify = p_config.get(CONF_NAME, SERVICE_NOTIFY) + hass.services.register(DOMAIN, service_notify, service_call_handler) + success = True - if message is None: - return - - title = call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - - notify_service.send_message(message, title=title) - - hass.services.register(DOMAIN, SERVICE_NOTIFY, notify_message) - - return True + return success # pylint: disable=too-few-public-methods class BaseNotificationService(object): - """ Provides an ABC for notifcation services. """ + """ Provides an ABC for notification services. """ def send_message(self, message, **kwargs): """ diff --git a/homeassistant/components/notify/file.py b/homeassistant/components/notify/file.py new file mode 100644 index 00000000000..9c0beca14ac --- /dev/null +++ b/homeassistant/components/notify/file.py @@ -0,0 +1,77 @@ +""" +homeassistant.components.notify.file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +File notification service. + +Configuration: + +To use the File notifier you will need to add something like the following +to your configuration.yaml file. + +notify: + platform: file + filename: FILENAME + timestamp: 1 or 0 + +Variables: + +filename +*Required +Name of the file to use. The file will be created if it doesn't exist and saved +in your config/ folder. + +timestamp +*Required +Add a timestamp to the entry, valid entries are 1 or 0. +""" +import logging +import os + +import homeassistant.util.dt as dt_util +from homeassistant.helpers import validate_config +from homeassistant.components.notify import ( + DOMAIN, ATTR_TITLE, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config): + """ Get the file notification service. """ + + if not validate_config(config, + {DOMAIN: ['filename', + 'timestamp']}, + _LOGGER): + return None + + filename = config[DOMAIN]['filename'] + timestamp = config[DOMAIN]['timestamp'] + + return FileNotificationService(hass, filename, timestamp) + + +# pylint: disable=too-few-public-methods +class FileNotificationService(BaseNotificationService): + """ Implements notification service for the File service. """ + + def __init__(self, hass, filename, add_timestamp): + self.filepath = os.path.join(hass.config.config_dir, filename) + self.add_timestamp = add_timestamp + + def send_message(self, message="", **kwargs): + """ Send a message to a file. """ + + with open(self.filepath, 'a') as file: + if os.stat(self.filepath).st_size == 0: + title = '{} notifications (Log started: {})\n{}\n'.format( + kwargs.get(ATTR_TITLE), + dt_util.strip_microseconds(dt_util.utcnow()), + '-'*80) + file.write(title) + + if self.add_timestamp == 1: + text = '{} {}\n'.format(dt_util.utcnow(), message) + file.write(text) + else: + text = '{}\n'.format(message) + file.write(text) diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py new file mode 100644 index 00000000000..95ff0d41435 --- /dev/null +++ b/homeassistant/components/notify/instapush.py @@ -0,0 +1,159 @@ +""" +homeassistant.components.notify.instapush +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Instapush notification service. + +Configuration: + +To use the Instapush notifier you will need to add something like the following +to your configuration.yaml file. + +notify: + platform: instapush + api_key: YOUR_APP_KEY + app_secret: YOUR_APP_SECRET + event: YOUR_EVENT + tracker: YOUR_TRACKER + +Variables: + +api_key +*Required +To retrieve this value log into your account at https://instapush.im and go +to 'APPS', choose an app, and check 'Basic Info'. + +app_secret +*Required +To get this value log into your account at https://instapush.im and go to +'APPS'. The 'Application ID' can be found under 'Basic Info'. + +event +*Required +To retrieve a valid event log into your account at https://instapush.im and go +to 'APPS'. If you have no events to use with Home Assistant, create one event +for your app. + +tracker +*Required +To retrieve the tracker value log into your account at https://instapush.im and +go to 'APPS', choose the app, and check the event entries. + +Example usage of Instapush if you have an event 'notification' and a tracker +'home-assistant'. + +curl -X POST \ + -H "x-instapush-appid: YOUR_APP_KEY" \ + -H "x-instapush-appsecret: YOUR_APP_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"event":"notification","trackers":{"home-assistant":"Switch 1"}}' \ + https://api.instapush.im/v1/post + +Details for the API : https://instapush.im/developer/rest +""" +import logging +import json + +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__) +_RESOURCE = 'https://api.instapush.im/v1/' + + +def get_service(hass, config): + """ Get the instapush notification service. """ + + if not validate_config(config, + {DOMAIN: [CONF_API_KEY, + 'app_secret', + 'event', + 'tracker']}, + _LOGGER): + return None + + try: + import requests + + except ImportError: + _LOGGER.exception( + "Unable to import requests. " + "Did you maybe not install the 'Requests' package?") + + return None + + # pylint: disable=unused-variable + try: + response = requests.get(_RESOURCE) + + except requests.ConnectionError: + _LOGGER.error( + "Connection error " + "Please check if https://instapush.im is available.") + + 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'] + ) + + +# pylint: disable=too-few-public-methods +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 + self._tracker = tracker + self._headers = { + 'x-instapush-appid': self._api_key, + '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. """ + + title = kwargs.get(ATTR_TITLE) + + data = {"event": self._event, + "trackers": {self._tracker: title + " : " + message}} + + response = self.instapush.post( + _RESOURCE + 'post', + data=json.dumps(data), + headers=self._headers) + + if response.json()['status'] == 401: + _LOGGER.error( + response.json()['msg'], + "Please check your details at https://instapush.im/") diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py new file mode 100644 index 00000000000..bf8fb2162a8 --- /dev/null +++ b/homeassistant/components/notify/nma.py @@ -0,0 +1,95 @@ +""" +homeassistant.components.notify.nma +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +NMA (Notify My Android) notification service. + +Configuration: + +To use the NMA notifier you will need to add something like the following +to your configuration.yaml file. + +notify: + platform: nma + api_key: YOUR_API_KEY + +Variables: + +api_key +*Required +Enter the API key for NMA. Go to https://www.notifymyandroid.com and create a +new API key to use with Home Assistant. + +Details for the API : https://www.notifymyandroid.com/api.jsp +""" +import logging +import xml.etree.ElementTree as ET + +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__) +_RESOURCE = 'https://www.notifymyandroid.com/publicapi/' + + +def get_service(hass, config): + """ Get the NMA notification service. """ + + if not validate_config(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]}) + 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]) + + +# pylint: disable=too-few-public-methods +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) + + 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) + tree = ET.fromstring(response.content) + + if tree[0].tag == 'error': + _LOGGER.exception( + "Unable to perform request. Error: %s", tree[0].text) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index cea1b216e8b..76eaf5c0c37 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -1,5 +1,23 @@ """ +homeassistant.components.notify.pushbullet +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ PushBullet platform for notify component. + +Configuration: + +To use the PushBullet notifier you will need to add something like the +following to your configuration.yaml file. + +notify: + platform: pushbullet + api_key: YOUR_API_KEY + +Variables: + +api_key +*Required +Enter the API key for PushBullet. Go to https://www.pushbullet.com/ to retrieve +your API key. """ import logging @@ -9,10 +27,11 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_API_KEY _LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['pushbullet.py==0.7.1'] def get_service(hass, config): - """ Get the pushbullet notification service. """ + """ Get the PushBullet notification service. """ if not validate_config(config, {DOMAIN: [CONF_API_KEY]}, diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index 466cfce8ea0..c52e430ac9f 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -1,17 +1,19 @@ """ +homeassistant.components.notify.pushover +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pushover platform for notify component. Configuration: To use the Pushover notifier you will need to add something like the following -to your config/configuration.yaml +to your configuration.yaml file. notify: - platform: pushover - api_key: ABCDEFGHJKLMNOPQRSTUVXYZ - user_key: ABCDEFGHJKLMNOPQRSTUVXYZ + platform: pushover + api_key: ABCDEFGHJKLMNOPQRSTUVXYZ + user_key: ABCDEFGHJKLMNOPQRSTUVXYZ -VARIABLES: +Variables: api_key *Required @@ -30,7 +32,6 @@ https://home-assistant.io/images/favicon-192x192.png user_key *Required To retrieve this value log into your account at https://pushover.net - """ import logging @@ -39,6 +40,7 @@ from homeassistant.components.notify import ( DOMAIN, ATTR_TITLE, BaseNotificationService) from homeassistant.const import CONF_API_KEY +REQUIREMENTS = ['python-pushover==0.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py new file mode 100644 index 00000000000..bd3a2b71c0c --- /dev/null +++ b/homeassistant/components/notify/slack.py @@ -0,0 +1,92 @@ +""" +homeassistant.components.notify.slack +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Slack platform for notify component. + +Configuration: + +To use the Slack notifier you will need to add something like the following +to your configuration.yaml file. + +notify: + platform: slack + api_key: ABCDEFGHJKLMNOPQRSTUVXYZ + default_channel: '#general' + +Variables: + +api_key +*Required +The slack API token to use for sending slack messages. +You can get your slack API token here https://api.slack.com/web?sudo=1 + +default_channel +*Required +The default channel to post to if no channel is explicitly specified when +sending the notification message. +""" +import logging + +from homeassistant.helpers import validate_config +from homeassistant.components.notify import ( + DOMAIN, BaseNotificationService) +from homeassistant.const import CONF_API_KEY + +REQUIREMENTS = ['slacker==0.6.8'] +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-variable +def get_service(hass, config): + """ Get the slack notification service. """ + + if not validate_config(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) + + except SlackError as ex: + _LOGGER.error( + "Slack authentication failed") + _LOGGER.exception(ex) + + +# pylint: disable=too-few-public-methods +class SlackNotificationService(BaseNotificationService): + """ Implements notification service for Slack. """ + + 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) + self.slack.auth.test() + + def send_message(self, message="", **kwargs): + """ Send a message to a user. """ + + 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: + _LOGGER.exception("Could not send slack notification") + _LOGGER.exception(ex) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py new file mode 100644 index 00000000000..0530ac4072d --- /dev/null +++ b/homeassistant/components/notify/smtp.py @@ -0,0 +1,163 @@ +""" +homeassistant.components.notify.mail +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Mail (SMTP) notification service. + +Configuration: + +To use the Mail notifier you will need to add something like the following +to your configuration.yaml file. + +notify: + platform: mail + server: MAIL_SERVER + port: YOUR_SMTP_PORT + sender: SENDER_EMAIL_ADDRESS + starttls: 1 or 0 + username: YOUR_SMTP_USERNAME + password: YOUR_SMTP_PASSWORD + recipient: YOUR_RECIPIENT + +Variables: + +server +*Required +SMTP server which is used to end the notifications. For Google Mail, eg. +smtp.gmail.com. Keep in mind that Google has some extra layers of protection +which need special attention (Hint: 'Less secure apps'). + +port +*Required +The port that the SMTP server is using, eg. 587 for Google Mail and STARTTLS +or 465/993 depending on your SMTP servers. + +sender +*Required +E-Mail address of the sender. + +starttls +*Optional +Enables STARTTLS, eg. 1 or 0. + +username +*Required +Username for the SMTP account. + +password +*Required +Password for the SMTP server that belongs to the given username. If the +password contains a colon it need to be wrapped in apostrophes. + +recipient +*Required +Recipient of the notification. +""" +import logging +import smtplib +from email.mime.text import MIMEText + +from homeassistant.helpers import validate_config +from homeassistant.components.notify import ( + DOMAIN, ATTR_TITLE, BaseNotificationService) + +_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']}, + _LOGGER): + return None + + smtp_server = config[DOMAIN]['server'] + port = int(config[DOMAIN]['port']) + username = config[DOMAIN]['username'] + password = config[DOMAIN]['password'] + + server = None + try: + server = smtplib.SMTP(smtp_server, port) + server.ehlo() + if int(config[DOMAIN]['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.") + + return None + + except smtplib.socket.gaierror: + _LOGGER.exception( + "SMTP server not found. " + "Please check the IP address or hostname of your SMTP server.") + + return None + + except smtplib.SMTPAuthenticationError: + _LOGGER.exception( + "Login not possible. " + "Please check your setting and/or your credentials.") + + return None + + 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'] + ) + + +# pylint: disable=too-few-public-methods, too-many-instance-attributes +class MailNotificationService(BaseNotificationService): + """ Implements notification service for E-Mail messages. """ + + # pylint: disable=too-many-arguments + def __init__(self, server, port, sender, starttls, username, + password, recipient): + self._server = server + self._port = port + self._sender = sender + self.starttls = int(starttls) + self.username = username + self.password = password + self.recipient = recipient + + self.mail = smtplib.SMTP(self._server, self._port) + self.mail.ehlo_or_helo_if_needed() + if self.starttls == 1: + self.mail.starttls() + self.mail.ehlo() + + self.mail.login(self.username, self.password) + + def send_message(self, message="", **kwargs): + """ Send a message to a user. """ + + subject = kwargs.get(ATTR_TITLE) + + msg = MIMEText(message) + msg['Subject'] = subject + msg['To'] = self.recipient + msg['From'] = self._sender + msg['X-Mailer'] = 'HomeAssistant' + + self.mail.sendmail(self._sender, self.recipient, msg.as_string()) diff --git a/homeassistant/components/notify/syslog.py b/homeassistant/components/notify/syslog.py new file mode 100644 index 00000000000..5d246f2fd0d --- /dev/null +++ b/homeassistant/components/notify/syslog.py @@ -0,0 +1,109 @@ +""" +homeassistant.components.notify.syslog +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Syslog notification service. + +Configuration: + +To use the Syslog notifier you will need to add something like the following +to your configuration.yaml file. + +notify: + platform: syslog + facility: SYSLOG_FACILITY + option: SYSLOG_LOG_OPTION + priority: SYSLOG_PRIORITY + +Variables: + +facility +*Optional +Facility according to RFC 3164 (http://tools.ietf.org/html/rfc3164). Default +is 'syslog' if no value is given. + +option +*Option +Log option. Default is 'pid' if no value is given. + +priority +*Optional +Priority of the messages. Default is 'info' if no value is given. +""" +import logging +import syslog + +from homeassistant.helpers import validate_config +from homeassistant.components.notify import ( + DOMAIN, ATTR_TITLE, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +FACILITIES = {'kernel': syslog.LOG_KERN, + 'user': syslog.LOG_USER, + 'mail': syslog.LOG_MAIL, + 'daemon': syslog.LOG_DAEMON, + 'auth': syslog.LOG_KERN, + 'LPR': syslog.LOG_LPR, + 'news': syslog.LOG_NEWS, + 'uucp': syslog.LOG_UUCP, + 'cron': syslog.LOG_CRON, + 'syslog': syslog.LOG_SYSLOG, + 'local0': syslog.LOG_LOCAL0, + 'local1': syslog.LOG_LOCAL1, + 'local2': syslog.LOG_LOCAL2, + 'local3': syslog.LOG_LOCAL3, + 'local4': syslog.LOG_LOCAL4, + 'local5': syslog.LOG_LOCAL5, + 'local6': syslog.LOG_LOCAL6, + 'local7': syslog.LOG_LOCAL7} + +OPTIONS = {'pid': syslog.LOG_PID, + 'cons': syslog.LOG_CONS, + 'ndelay': syslog.LOG_NDELAY, + 'nowait': syslog.LOG_NOWAIT, + 'perror': syslog.LOG_PERROR} + +PRIORITIES = {5: syslog.LOG_EMERG, + 4: syslog.LOG_ALERT, + 3: syslog.LOG_CRIT, + 2: syslog.LOG_ERR, + 1: syslog.LOG_WARNING, + 0: syslog.LOG_NOTICE, + -1: syslog.LOG_INFO, + -2: syslog.LOG_DEBUG} + + +def get_service(hass, config): + """ Get the mail notification service. """ + + if not validate_config(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) + + return SyslogNotificationService(_facility, _option, _priority) + + +# pylint: disable=too-few-public-methods +class SyslogNotificationService(BaseNotificationService): + """ Implements syslog notification service. """ + + # pylint: disable=too-many-arguments + def __init__(self, facility, option, priority): + self._facility = facility + self._option = option + self._priority = priority + + def send_message(self, message="", **kwargs): + """ Send a message to a user. """ + + title = kwargs.get(ATTR_TITLE) + + syslog.openlog(title, self._option, self._facility) + syslog.syslog(self._priority, message) + syslog.closelog() diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py new file mode 100644 index 00000000000..1d72f6a262b --- /dev/null +++ b/homeassistant/components/notify/xmpp.py @@ -0,0 +1,127 @@ +""" +homeassistant.components.notify.xmpp +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Jabber (XMPP) notification service. + +Configuration: + +To use the Jabber notifier you will need to add something like the following +to your configuration.yaml file. + +notify: + platform: xmpp + sender: YOUR_JID + password: YOUR_JABBER_ACCOUNT_PASSWORD + recipient: YOUR_RECIPIENT + +Variables: + +sender +*Required +The Jabber ID (JID) that will act as origin of the messages. Add your JID +including the domain, e.g. your_name@jabber.org. + +password +*Required +The password for your given Jabber account. + +recipient +*Required +The Jabber ID (JID) that will receive the messages. +""" +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'] + + +def get_service(hass, config): + """ Get the Jabber (XMPP) notification service. """ + + if not validate_config(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']) + + +# pylint: disable=too-few-public-methods +class XmppNotificationService(BaseNotificationService): + """ Implements notification service for Jabber (XMPP). """ + + def __init__(self, sender, password, recipient): + self._sender = sender + self._password = password + self._recipient = recipient + + def send_message(self, message="", **kwargs): + """ Send a message to a user. """ + + title = kwargs.get(ATTR_TITLE) + data = title + ": " + message + + SendNotificationBot(self._sender + '/home-assistant', + self._password, + self._recipient, + data) + + +class SendNotificationBot(sleekxmpp.ClientXMPP): + """ Service for sending Jabber (XMPP) messages. """ + + def __init__(self, jid, password, recipient, msg): + + super(SendNotificationBot, self).__init__(jid, password) + + 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(block=False) + + 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() diff --git a/homeassistant/components/process.py b/homeassistant/components/process.py deleted file mode 100644 index 21343aa977b..00000000000 --- a/homeassistant/components/process.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -homeassistant.components.process -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides functionality to watch for specific processes running -on the host machine. - -Author: Markus Stenberg -""" -import logging -import os - -from homeassistant.const import STATE_ON, STATE_OFF -import homeassistant.util as util - -DOMAIN = 'process' -DEPENDENCIES = [] -ENTITY_ID_FORMAT = DOMAIN + '.{}' - -PS_STRING = 'ps awx' - - -def setup(hass, config): - """ Sets up a check if specified processes are running. - - processes: dict mapping entity id to substring to search for - in process list. - """ - - # Deprecated as of 3/7/2015 - logging.getLogger(__name__).warning( - "This component has been deprecated and will be removed in the future." - " Please use sensor.systemmonitor with the process type") - - entities = {ENTITY_ID_FORMAT.format(util.slugify(pname)): pstring - for pname, pstring in config[DOMAIN].items()} - - def update_process_states(time): - """ Check ps for currently running processes and update states. """ - with os.popen(PS_STRING, 'r') as psfile: - lines = list(psfile) - - for entity_id, pstring in entities.items(): - state = STATE_ON if any(pstring in l for l in lines) else STATE_OFF - - hass.states.set(entity_id, state) - - update_process_states(None) - - hass.track_time_change(update_process_states, second=[0, 30]) - - return True diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index 367af7d2d73..73487163425 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -9,12 +9,12 @@ import logging import threading import queue import sqlite3 -from datetime import datetime -import time +from datetime import datetime, date import json import atexit -from homeassistant import Event, EventOrigin, State +from homeassistant.core import Event, EventOrigin, State +import homeassistant.util.dt as date_util from homeassistant.remote import JSONEncoder from homeassistant.const import ( MATCH_ALL, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, @@ -60,7 +60,9 @@ def row_to_state(row): """ Convert a databsae row to a state. """ try: return State( - row[1], row[2], json.loads(row[3]), datetime.fromtimestamp(row[4])) + row[1], row[2], json.loads(row[3]), + date_util.utc_from_timestamp(row[4]), + date_util.utc_from_timestamp(row[5])) except ValueError: # When json.loads fails _LOGGER.exception("Error converting row to state: %s", row) @@ -70,9 +72,10 @@ 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()]) + return Event(row[1], json.loads(row[2]), EventOrigin[row[3].lower()], + date_util.utc_from_timestamp(row[5])) except ValueError: - # When json.oads fails + # When json.loads fails _LOGGER.exception("Error converting row to event: %s", row) return None @@ -111,10 +114,10 @@ class RecorderRun(object): self.start = _INSTANCE.recording_start self.closed_incorrect = False else: - self.start = datetime.fromtimestamp(row[1]) + self.start = date_util.utc_from_timestamp(row[1]) if row[2] is not None: - self.end = datetime.fromtimestamp(row[2]) + self.end = date_util.utc_from_timestamp(row[2]) self.closed_incorrect = bool(row[3]) @@ -164,7 +167,8 @@ class Recorder(threading.Thread): self.queue = queue.Queue() self.quit_object = object() self.lock = threading.Lock() - self.recording_start = datetime.now() + self.recording_start = date_util.utcnow() + self.utc_offset = date_util.now().utcoffset().total_seconds() def start_recording(event): """ Start recording. """ @@ -185,16 +189,21 @@ class Recorder(threading.Thread): if event == self.quit_object: self._close_run() self._close_connection() + self.queue.task_done() return elif event.event_type == EVENT_TIME_CHANGED: + self.queue.task_done() continue - elif event.event_type == EVENT_STATE_CHANGED: - self.record_state( - event.data['entity_id'], event.data.get('new_state')) + event_id = self.record_event(event) - self.record_event(event) + if event.event_type == EVENT_STATE_CHANGED: + self.record_state( + event.data['entity_id'], event.data.get('new_state'), + event_id) + + self.queue.task_done() def event_listener(self, event): """ Listens for new events on the EventBus and puts them @@ -205,33 +214,43 @@ class Recorder(threading.Thread): """ Tells the recorder to shut down. """ self.queue.put(self.quit_object) - def record_state(self, entity_id, state): + def record_state(self, entity_id, state, event_id): """ Save a state to the database. """ - now = datetime.now() + now = date_util.utcnow() + # State got deleted if state is None: - info = (entity_id, '', "{}", now, now, now) + state_state = '' + state_attr = '{}' + last_changed = last_updated = now else: - info = ( - entity_id.lower(), state.state, json.dumps(state.attributes), - state.last_changed, state.last_updated, now) + state_state = state.state + state_attr = json.dumps(state.attributes) + last_changed = state.last_changed + last_updated = state.last_updated + + info = ( + entity_id, state_state, state_attr, last_changed, last_updated, + now, self.utc_offset, event_id) self.query( "INSERT INTO states (" "entity_id, state, attributes, last_changed, last_updated," - "created) VALUES (?, ?, ?, ?, ?, ?)", info) + "created, utc_offset, event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + info) def record_event(self, event): """ Save an event to the database. """ info = ( event.event_type, json.dumps(event.data, cls=JSONEncoder), - str(event.origin), datetime.now() + str(event.origin), date_util.utcnow(), event.time_fired, + self.utc_offset ) - self.query( + return self.query( "INSERT INTO events (" - "event_type, event_data, origin, created" - ") VALUES (?, ?, ?, ?)", info) + "event_type, event_data, origin, created, time_fired, utc_offset" + ") VALUES (?, ?, ?, ?, ?, ?)", info, RETURN_LASTROWID) def query(self, sql_query, data=None, return_value=None): """ Query the database. """ @@ -260,6 +279,10 @@ class Recorder(threading.Thread): "Error querying the database using: %s", sql_query) return [] + def block_till_done(self): + """ Blocks till all events processed. """ + self.queue.join() + def _setup_connection(self): """ Ensure database is ready to fly. """ db_path = self.hass.config.path(DB_FILE) @@ -271,6 +294,7 @@ class Recorder(threading.Thread): atexit.register(self._close_connection) # Have datetime objects be saved as integers + sqlite3.register_adapter(date, _adapt_datetime) sqlite3.register_adapter(datetime, _adapt_datetime) # Validate we are on the correct schema or that we have to migrate @@ -279,7 +303,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, datetime.now())) + (migration_id, date_util.utcnow())) self.conn.commit() _LOGGER.info("Database migrated to version %d", migration_id) @@ -294,7 +318,7 @@ class Recorder(threading.Thread): migration_id = 0 if migration_id < 1: - cur.execute(""" + self.query(""" CREATE TABLE recorder_runs ( run_id integer primary key, start integer, @@ -303,7 +327,7 @@ class Recorder(threading.Thread): created integer) """) - cur.execute(""" + self.query(""" CREATE TABLE events ( event_id integer primary key, event_type text, @@ -311,10 +335,10 @@ class Recorder(threading.Thread): origin text, created integer) """) - cur.execute( + self.query( 'CREATE INDEX events__event_type ON events(event_type)') - cur.execute(""" + self.query(""" CREATE TABLE states ( state_id integer primary key, entity_id text, @@ -324,10 +348,57 @@ class Recorder(threading.Thread): last_updated integer, created integer) """) - cur.execute('CREATE INDEX states__entity_id ON states(entity_id)') + self.query('CREATE INDEX states__entity_id ON states(entity_id)') save_migration(1) + if migration_id < 2: + self.query(""" + ALTER TABLE events + ADD COLUMN time_fired integer + """) + + self.query('UPDATE events SET time_fired=created') + + save_migration(2) + + if migration_id < 3: + utc_offset = self.utc_offset + + self.query(""" + ALTER TABLE recorder_runs + ADD COLUMN utc_offset integer + """) + + self.query(""" + ALTER TABLE events + ADD COLUMN utc_offset integer + """) + + self.query(""" + ALTER TABLE states + ADD COLUMN utc_offset integer + """) + + self.query("UPDATE recorder_runs SET utc_offset=?", [utc_offset]) + self.query("UPDATE events SET utc_offset=?", [utc_offset]) + self.query("UPDATE states SET utc_offset=?", [utc_offset]) + + save_migration(3) + + if migration_id < 4: + # We had a bug where we did not save utc offset for recorder runs + self.query( + """UPDATE recorder_runs SET utc_offset=? + WHERE utc_offset IS NULL""", [self.utc_offset]) + + self.query(""" + ALTER TABLE states + ADD COLUMN event_id integer + """) + + save_migration(4) + def _close_connection(self): """ Close connection to the database. """ _LOGGER.info("Closing database") @@ -343,19 +414,20 @@ class Recorder(threading.Thread): _LOGGER.warning("Found unfinished sessions") self.query( - "INSERT INTO recorder_runs (start, created) VALUES (?, ?)", - (self.recording_start, datetime.now())) + """INSERT INTO recorder_runs (start, created, utc_offset) + VALUES (?, ?, ?)""", + (self.recording_start, date_util.utcnow(), self.utc_offset)) def _close_run(self): """ Save end time for current run. """ self.query( "UPDATE recorder_runs SET end=? WHERE start=?", - (datetime.now(), self.recording_start)) + (date_util.utcnow(), self.recording_start)) def _adapt_datetime(datetimestamp): """ Turn a datetime into an integer for in the DB. """ - return time.mktime(datetimestamp.timetuple()) + return date_util.as_utc(datetimestamp.replace(microsecond=0)).timestamp() def _verify_instance(): diff --git a/homeassistant/components/scene.py b/homeassistant/components/scene.py index 979b3281300..579ce1f20fb 100644 --- a/homeassistant/components/scene.py +++ b/homeassistant/components/scene.py @@ -18,7 +18,8 @@ old state will not be restored when it is being deactivated. import logging from collections import namedtuple -from homeassistant import State +from homeassistant.core import State +from homeassistant.helpers.event import track_state_change from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.state import reproduce_state @@ -104,8 +105,8 @@ class Scene(ToggleEntity): self.prev_states = None self.ignore_updates = False - self.hass.states.track_change( - self.entity_ids, self.entity_state_changed) + track_state_change( + self.hass, self.entity_ids, self.entity_state_changed) self.update() diff --git a/homeassistant/components/scheduler/__init__.py b/homeassistant/components/scheduler/__init__.py index f84dafd5ec3..1a67636da3d 100644 --- a/homeassistant/components/scheduler/__init__.py +++ b/homeassistant/components/scheduler/__init__.py @@ -1,19 +1,19 @@ """ homeassistant.components.scheduler -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -A component that will act as a scheduler and performe actions based +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +A component that will act as a scheduler and perform actions based on the events in the schedule. It will read a json object from schedule.json in the config dir and create a schedule based on it. Each schedule is a JSON with the keys id, name, description, entity_ids, and events. -- days is an array with the weekday number (monday=0) that the schdule +- days is an array with the weekday number (monday=0) that the schedule is active - entity_ids an array with entity ids that the events in the schedule should effect (can also be groups) - events is an array of objects that describe the different events that is - supported. Read in the events descriptions for more information + supported. Read in the events descriptions for more information. """ import logging import json @@ -22,7 +22,6 @@ from homeassistant import bootstrap from homeassistant.loader import get_component from homeassistant.const import ATTR_ENTITY_ID -# The domain of your component. Should be equal to the name of your component DOMAIN = 'scheduler' DEPENDENCIES = [] @@ -33,10 +32,10 @@ _SCHEDULE_FILE = 'schedule.json' def setup(hass, config): - """ Create the schedules """ + """ Create the schedules. """ def setup_listener(schedule, event_data): - """ Creates the event listener based on event_data """ + """ Creates the event listener based on event_data. """ event_type = event_data['type'] component = event_type @@ -52,7 +51,7 @@ def setup(hass, config): event_data) def setup_schedule(schedule_data): - """ setup a schedule based on the description """ + """ Setup a schedule based on the description. """ schedule = Schedule(schedule_data['id'], name=schedule_data['name'], @@ -97,17 +96,17 @@ class Schedule(object): self.__event_listeners = [] def add_event_listener(self, event_listener): - """ Add a event to the schedule """ + """ Add a event to the schedule. """ self.__event_listeners.append(event_listener) def schedule(self, hass): - """ Schedule all the events in the schdule """ + """ Schedule all the events in the schedule. """ for event in self.__event_listeners: event.schedule(hass) class EventListener(object): - """ The base EventListner class that the schedule uses """ + """ The base EventListener class that the schedule uses. """ def __init__(self, schedule): self.my_schedule = schedule @@ -122,7 +121,7 @@ class EventListener(object): # pylint: disable=too-few-public-methods class ServiceEventListener(EventListener): - """ A EventListner that calls a service when executed """ + """ A EventListener that calls a service when executed. """ def __init__(self, schdule, service): EventListener.__init__(self, schdule) @@ -130,9 +129,9 @@ class ServiceEventListener(EventListener): (self.domain, self.service) = service.split('.') def execute(self, hass): - """ Call the service """ + """ Call the service. """ data = {ATTR_ENTITY_ID: self.my_schedule.entity_ids} - hass.call_service(self.domain, self.service, data) + hass.services.call(self.domain, self.service, data) # Reschedule for next day self.schedule(hass) diff --git a/homeassistant/components/scheduler/time.py b/homeassistant/components/scheduler/time.py index 90fa495cee2..9fec19fbe57 100644 --- a/homeassistant/components/scheduler/time.py +++ b/homeassistant/components/scheduler/time.py @@ -1,4 +1,6 @@ """ +homeassistant.components.scheduler.time +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ An event in the scheduler component that will call the service every specified day at the time specified. A time event need to have the type 'time', which service to call and at @@ -11,17 +13,18 @@ which time. } """ - -from datetime import datetime, timedelta +from datetime import timedelta import logging +import homeassistant.util.dt as dt_util +from homeassistant.helpers.event import track_point_in_time from homeassistant.components.scheduler import ServiceEventListener _LOGGER = logging.getLogger(__name__) def create_event_listener(schedule, event_listener_data): - """ Create a TimeEvent based on the description """ + """ Create a TimeEvent based on the description. """ service = event_listener_data['service'] (hour, minute, second) = [int(x) for x in @@ -32,7 +35,7 @@ def create_event_listener(schedule, event_listener_data): # pylint: disable=too-few-public-methods class TimeEventListener(ServiceEventListener): - """ The time event that the scheduler uses """ + """ The time event that the scheduler uses. """ # pylint: disable=too-many-arguments def __init__(self, schedule, service, hour, minute, second): @@ -43,16 +46,14 @@ class TimeEventListener(ServiceEventListener): self.second = second def schedule(self, hass): - """ Schedule this event so that it will be called """ + """ Schedule this event so that it will be called. """ - next_time = datetime.now().replace(hour=self.hour, - minute=self.minute, - second=self.second, - microsecond=0) + next_time = dt_util.now().replace( + hour=self.hour, minute=self.minute, second=self.second) # Calculate the next time the event should be executed. # That is the next day that the schedule is configured to run - while next_time < datetime.now() or \ + while next_time < dt_util.now() or \ next_time.weekday() not in self.my_schedule.days: next_time = next_time + timedelta(days=1) @@ -62,7 +63,7 @@ class TimeEventListener(ServiceEventListener): """ Call the execute method """ self.execute(hass) - hass.track_point_in_time(execute, next_time) + track_point_in_time(hass, execute, next_time) _LOGGER.info( 'TimeEventListener scheduled for %s, will call service %s.%s', diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 265fdd8c1cc..788eb8af96d 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -6,9 +6,11 @@ Scripts are a sequence of actions that can be triggered manually by the user or automatically based upon automation events, etc. """ import logging -from datetime import datetime, timedelta +from datetime import timedelta +import homeassistant.util.dt as date_util import threading +from homeassistant.helpers.event import track_point_in_time from homeassistant.util import split_entity_id from homeassistant.const import ( STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, EVENT_TIME_CHANGED) @@ -109,9 +111,9 @@ class Script(object): self._call_service(action) elif CONF_DELAY in action: delay = timedelta(**action[CONF_DELAY]) - point_in_time = datetime.now() + delay - self.listener = self.hass.track_point_in_time( - self, point_in_time) + point_in_time = date_util.now() + delay + self.listener = track_point_in_time( + self.hass, self, point_in_time) return False return True diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 8248651710a..90317cdf90a 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -6,7 +6,7 @@ Component to interface with various sensors that can be monitored. import logging from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components import wink, zwave +from homeassistant.components import wink, zwave, isy994, verisure DOMAIN = 'sensor' DEPENDENCIES = [] @@ -18,6 +18,8 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' DISCOVERY_PLATFORMS = { wink.DISCOVER_SENSORS: 'wink', zwave.DISCOVER_SENSORS: 'zwave', + isy994.DISCOVER_SENSORS: 'isy994', + verisure.DISCOVER_SENSORS: 'verisure' } diff --git a/homeassistant/components/sensor/arduino.py b/homeassistant/components/sensor/arduino.py new file mode 100644 index 00000000000..f6c44d3f60e --- /dev/null +++ b/homeassistant/components/sensor/arduino.py @@ -0,0 +1,90 @@ +""" +homeassistant.components.sensor.arduino +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for getting information from Arduino pins. Only analog pins are +supported. + +Configuration: + +To use the arduino sensor you will need to add something like the following +to your configuration.yaml file. + +sensor: + platform: arduino + pins: + 7: + name: Door switch + type: analog + 0: + name: Brightness + type: analog + +Variables: + +pins +*Required +An array specifying the digital pins to use on the Arduino board. + +These are the variables for the pins array: + +name +*Required +The name for the pin that will be used in the frontend. + +type +*Required +The type of the pin: 'analog'. +""" +import logging + +import homeassistant.components.arduino as arduino +from homeassistant.helpers.entity import Entity +from homeassistant.const import DEVICE_DEFAULT_NAME + +DEPENDENCIES = ['arduino'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Arduino platform. """ + + # Verify that the Arduino board is present + if arduino.BOARD is None: + _LOGGER.error('A connection has not been made to the Arduino board.') + return False + + sensors = [] + pins = config.get('pins') + for pinnum, pin in pins.items(): + if pin.get('name'): + sensors.append(ArduinoSensor(pin.get('name'), + pinnum, + 'analog')) + add_devices(sensors) + + +class ArduinoSensor(Entity): + """ Represents an Arduino Sensor. """ + def __init__(self, name, pin, pin_type): + self._pin = pin + self._name = name or DEVICE_DEFAULT_NAME + self.pin_type = pin_type + self.direction = 'in' + self._value = None + + arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + + @property + def state(self): + """ Returns the state of the sensor. """ + return self._value + + @property + def name(self): + """ Get the name of the sensor. """ + return self._name + + def update(self): + """ Get the latest value from the pin. """ + self._value = arduino.BOARD.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/sensor/arest.py b/homeassistant/components/sensor/arest.py new file mode 100644 index 00000000000..78d173ef283 --- /dev/null +++ b/homeassistant/components/sensor/arest.py @@ -0,0 +1,150 @@ +""" +homeassistant.components.sensor.arest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The arest sensor will consume an exposed aREST API of a device. + +Configuration: + +To use the arest sensor you will need to add something like the following +to your configuration.yaml file. + +sensor: + platform: arest + resource: http://IP_ADDRESS + monitored_variables: + - name: temperature + unit: '°C' + - name: humidity + unit: '%' + +Variables: + +resource: +*Required +IP address of the device that is exposing an aREST API. + +These are the variables for the monitored_variables array: + +name +*Required +The name of the variable you wish to monitor. + +unit +*Optional +Defines the units of measurement of the sensor, if any. + +Details for the API: http://arest.io + +Format of a default JSON response by aREST: +{ + "variables":{ + "temperature":21, + "humidity":89 + }, + "id":"device008", + "name":"Bedroom", + "connected":true +} +""" +import logging +from requests import get, exceptions +from datetime import timedelta + +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the aREST sensor. """ + + resource = config.get('resource', None) + + try: + response = get(resource) + except exceptions.MissingSchema: + _LOGGER.error("Missing resource or schema in configuration. " + "Add http:// to your URL.") + return False + except exceptions.ConnectionError: + _LOGGER.error("No route to device. " + "Please check the IP address in the configuration file.") + return False + + rest = ArestData(resource) + + dev = [] + for variable in config['monitored_variables']: + if 'unit' not in variable: + variable['unit'] = ' ' + if variable['name'] not in response.json()['variables']: + _LOGGER.error('Variable: "%s" does not exist', variable['name']) + else: + dev.append(ArestSensor(rest, + response.json()['name'], + variable['name'], + variable['unit'])) + + add_devices(dev) + + +class ArestSensor(Entity): + """ Implements an aREST sensor. """ + + def __init__(self, rest, location, variable, unit_of_measurement): + self.rest = rest + self._name = '{} {}'.format(location.title(), variable.title()) + self._variable = variable + self._state = 'n/a' + self._unit_of_measurement = unit_of_measurement + self.update() + + @property + def name(self): + """ The name of the sensor. """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit the value is expressed in. """ + return self._unit_of_measurement + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + def update(self): + """ Gets the latest data from aREST API and updates the state. """ + self.rest.update() + values = self.rest.data + + if 'error' in values: + self._state = values['error'] + else: + self._state = values[self._variable] + + +# pylint: disable=too-few-public-methods +class ArestData(object): + """ Class for handling the data retrieval. """ + + def __init__(self, resource): + self.resource = resource + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from aREST device. """ + try: + response = get(self.resource) + if 'error' in self.data: + del self.data['error'] + self.data = response.json()['variables'] + except exceptions.ConnectionError: + _LOGGER.error("No route to device. Is device offline?") + self.data['error'] = 'n/a' diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py new file mode 100644 index 00000000000..60a8a9172b7 --- /dev/null +++ b/homeassistant/components/sensor/bitcoin.py @@ -0,0 +1,250 @@ +""" +homeassistant.components.sensor.bitcoin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Bitcoin information service that uses blockchain.info and its online wallet. + +Configuration: + +You need to enable the API access for your online wallet to get the balance. +To do that log in and move to 'Account Setting', choose 'IP Restrictions', and +check 'Enable Api Access'. You will get an email message from blockchain.info +where you must authorize the API access. + +To use the Bitcoin sensor you will need to add something like the following +to your configuration.yaml file. + +sensor: + platform: bitcoin + wallet: 'YOUR WALLET_ID' + password: YOUR_ACCOUNT_PASSWORD + currency: YOUR CURRENCY + display_options: + - exchangerate + - trade_volume_btc + - miners_revenue_usd + - btc_mined + - trade_volume_usd + - difficulty + - minutes_between_blocks + - number_of_transactions + - hash_rate + - timestamp + - mined_blocks + - blocks_size + - total_fees_btc + - total_btc_sent + - estimated_btc_sent + - total_btc + - total_blocks + - next_retarget + - estimated_transaction_volume_usd + - miners_revenue_btc + - market_price_usd + +Variables: + +wallet +*Optional +This is your wallet identifier from https://blockchain.info to access the +online wallet. + +password +*Optional +Password your your online wallet. + +currency +*Optional +The currency to exchange to, eg. CHF, USD, EUR,etc. Default is USD. + +display_options +*Optional +An array specifying the variables to display. + +These are the variables for the display_options array. See the configuration +example above for a list of all available variables. +""" +import logging +from datetime import timedelta + +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity + + +REQUIREMENTS = ['blockchain==1.1.2'] +_LOGGER = logging.getLogger(__name__) +OPTION_TYPES = { + 'wallet': ['Wallet balance', 'BTC'], + 'exchangerate': ['Exchange rate (1 BTC)', ''], + 'trade_volume_btc': ['Trade volume', 'BTC'], + 'miners_revenue_usd': ['Miners revenue', 'USD'], + 'btc_mined': ['Mined', 'BTC'], + 'trade_volume_usd': ['Trade volume', 'USD'], + 'difficulty': ['Difficulty', ''], + 'minutes_between_blocks': ['Time between Blocks', 'min'], + 'number_of_transactions': ['No. of Transactions', ''], + 'hash_rate': ['Hash rate', 'PH/s'], + 'timestamp': ['Timestamp', ''], + 'mined_blocks': ['Minded Blocks', ''], + 'blocks_size': ['Block size', ''], + 'total_fees_btc': ['Total fees', 'BTC'], + 'total_btc_sent': ['Total sent', 'BTC'], + 'estimated_btc_sent': ['Estimated sent', 'BTC'], + 'total_btc': ['Total', 'BTC'], + 'total_blocks': ['Total Blocks', ''], + 'next_retarget': ['Next retarget', ''], + 'estimated_transaction_volume_usd': ['Est. Transaction volume', 'USD'], + 'miners_revenue_btc': ['Miners revenue', 'BTC'], + 'market_price_usd': ['Market price', 'USD'] +} + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the Bitcoin sensor. """ + + try: + from blockchain.wallet import Wallet + from blockchain import exchangerates, exceptions + + except ImportError: + _LOGGER.exception( + "Unable to import blockchain. " + "Did you maybe not install the 'blockchain' package?") + + return False + + wallet_id = config.get('wallet', None) + password = config.get('password', None) + currency = config.get('currency', 'USD') + + if currency not in exchangerates.get_ticker(): + _LOGGER.error('Currency "%s" is not available. Using "USD".', currency) + currency = 'USD' + + wallet = Wallet(wallet_id, password) + + try: + wallet.get_balance() + except exceptions.APIException as error: + _LOGGER.error(error) + wallet = None + + data = BitcoinData() + dev = [] + if wallet is not None and password is not None: + dev.append(BitcoinSensor(data, 'wallet', currency, wallet)) + + for variable in config['display_options']: + if variable not in OPTION_TYPES: + _LOGGER.error('Option type: "%s" does not exist', variable) + else: + dev.append(BitcoinSensor(data, variable, currency)) + + add_devices(dev) + + +# pylint: disable=too-few-public-methods +class BitcoinSensor(Entity): + """ Implements a Bitcoin sensor. """ + + def __init__(self, data, option_type, currency, wallet=''): + self.data = data + self._name = OPTION_TYPES[option_type][0] + self._unit_of_measurement = OPTION_TYPES[option_type][1] + self._currency = currency + self._wallet = wallet + self.type = option_type + self._state = None + self.update() + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + return self._unit_of_measurement + + # pylint: disable=too-many-branches + def update(self): + """ Gets the latest data and updates the states. """ + + self.data.update() + stats = self.data.stats + ticker = self.data.ticker + + # pylint: disable=no-member + if self.type == 'wallet' and self._wallet is not None: + self._state = '{0:.8f}'.format(self._wallet.get_balance() * + 0.00000001) + elif self.type == 'exchangerate': + self._state = ticker[self._currency].p15min + self._unit_of_measurement = self._currency + elif self.type == 'trade_volume_btc': + self._state = '{0:.1f}'.format(stats.trade_volume_btc) + elif self.type == 'miners_revenue_usd': + self._state = '{0:.0f}'.format(stats.miners_revenue_usd) + elif self.type == 'btc_mined': + self._state = '{}'.format(stats.btc_mined * 0.00000001) + elif self.type == 'trade_volume_usd': + self._state = '{0:.1f}'.format(stats.trade_volume_usd) + elif self.type == 'difficulty': + self._state = '{0:.0f}'.format(stats.difficulty) + elif self.type == 'minutes_between_blocks': + self._state = '{0:.2f}'.format(stats.minutes_between_blocks) + elif self.type == 'number_of_transactions': + self._state = '{}'.format(stats.number_of_transactions) + elif self.type == 'hash_rate': + self._state = '{0:.1f}'.format(stats.hash_rate * 0.000001) + elif self.type == 'timestamp': + self._state = stats.timestamp + elif self.type == 'mined_blocks': + self._state = '{}'.format(stats.mined_blocks) + elif self.type == 'blocks_size': + self._state = '{0:.1f}'.format(stats.blocks_size) + elif self.type == 'total_fees_btc': + self._state = '{0:.2f}'.format(stats.total_fees_btc * 0.00000001) + elif self.type == 'total_btc_sent': + self._state = '{0:.2f}'.format(stats.total_btc_sent * 0.00000001) + elif self.type == 'estimated_btc_sent': + self._state = '{0:.2f}'.format(stats.estimated_btc_sent * + 0.00000001) + elif self.type == 'total_btc': + self._state = '{0:.2f}'.format(stats.total_btc * 0.00000001) + elif self.type == 'total_blocks': + self._state = '{0:.2f}'.format(stats.total_blocks) + elif self.type == 'next_retarget': + self._state = '{0:.2f}'.format(stats.next_retarget) + elif self.type == 'estimated_transaction_volume_usd': + self._state = '{0:.2f}'.format( + stats.estimated_transaction_volume_usd) + elif self.type == 'miners_revenue_btc': + self._state = '{0:.1f}'.format(stats.miners_revenue_btc * + 0.00000001) + elif self.type == 'market_price_usd': + self._state = '{0:.2f}'.format(stats.market_price_usd) + + +class BitcoinData(object): + """ Gets the latest data and updates the states. """ + + def __init__(self): + self.stats = None + self.ticker = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from blockchain.info. """ + + from blockchain import statistics, exchangerates + + self.stats = statistics.get() + self.ticker = exchangerates.get_ticker() diff --git a/homeassistant/components/sensor/demo.py b/homeassistant/components/sensor/demo.py index 71b6cf2b8fe..333a0564dfb 100644 --- a/homeassistant/components/sensor/demo.py +++ b/homeassistant/components/sensor/demo.py @@ -1,4 +1,8 @@ -""" Support for Wink sensors. """ +""" +homeassistant.components.sensor.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Demo platform that has a couple of fake sensors. +""" from homeassistant.helpers.entity import Entity from homeassistant.const import TEMP_CELCIUS, ATTR_BATTERY_LEVEL @@ -9,6 +13,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ DemoSensor('Outside Temperature', 15.6, TEMP_CELCIUS, 12), DemoSensor('Outside Humidity', 54, '%', None), + DemoSensor('Alarm back', 'Armed', None, None), ]) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py new file mode 100644 index 00000000000..2ce0b12be38 --- /dev/null +++ b/homeassistant/components/sensor/dht.py @@ -0,0 +1,164 @@ +""" +homeassistant.components.sensor.dht +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Adafruit DHT temperature and humidity sensor. +You need a Python3 compatible version of the Adafruit_Python_DHT library +(e.g. https://github.com/mala-zaba/Adafruit_Python_DHT, +also see requirements.txt). +As this requires access to the GPIO, you will need to run home-assistant +as root. + +Configuration: + +To use the Adafruit DHT sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: dht + sensor: DHT22 + pin: 23 + monitored_conditions: + - temperature + - humidity + +Variables: + +sensor +*Required +The sensor type, DHT11, DHT22 or AM2302 + +pin +*Required +The pin the sensor is connected to, something like +'P8_11' for Beaglebone, '23' for Raspberry Pi + +monitored_conditions +*Optional +Conditions to monitor. Available conditions are temperature and humidity. +""" +import logging +from datetime import timedelta + +from homeassistant.util import Throttle +from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.helpers.entity import Entity + +# update this requirement to upstream as soon as it supports python3 +REQUIREMENTS = ['http://github.com/mala-zaba/Adafruit_Python_DHT/archive/' + '4101340de8d2457dd194bca1e8d11cbfc237e919.zip' + '#Adafruit_DHT==1.1.0'] + +_LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = { + 'temperature': ['Temperature', ''], + 'humidity': ['Humidity', '%'] +} +# Return cached results if last scan was less then this time ago +# DHT11 is able to deliver data once per second, DHT22 once every two +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the DHT sensor. """ + + try: + import Adafruit_DHT + + except ImportError: + _LOGGER.exception( + "Unable to import Adafruit_DHT. " + "Did you maybe not install the 'Adafruit_DHT' package?") + + return False + + SENSOR_TYPES['temperature'][1] = hass.config.temperature_unit + unit = hass.config.temperature_unit + available_sensors = { + "DHT11": Adafruit_DHT.DHT11, + "DHT22": Adafruit_DHT.DHT22, + "AM2302": Adafruit_DHT.AM2302 + } + sensor = available_sensors[config['sensor']] + + pin = config['pin'] + + if not sensor or not pin: + _LOGGER.error( + "Config error " + "Please check your settings for DHT, sensor not supported.") + return None + + data = DHTClient(Adafruit_DHT, sensor, pin) + dev = [] + try: + for variable in config['monitored_conditions']: + if variable not in SENSOR_TYPES: + _LOGGER.error('Sensor type: "%s" does not exist', variable) + else: + dev.append(DHTSensor(data, variable, unit)) + except KeyError: + pass + + add_devices(dev) + + +# pylint: disable=too-few-public-methods +class DHTSensor(Entity): + """ Implements an DHT sensor. """ + + def __init__(self, dht_client, sensor_type, temp_unit): + self.client_name = 'DHT sensor' + self._name = SENSOR_TYPES[sensor_type][0] + self.dht_client = dht_client + self.temp_unit = temp_unit + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.update() + + @property + def name(self): + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + return self._unit_of_measurement + + def update(self): + """ Gets the latest data from the DHT and updates the states. """ + + self.dht_client.update() + data = self.dht_client.data + + if self.type == 'temperature': + self._state = round(data['temperature'], 1) + if self.temp_unit == TEMP_FAHRENHEIT: + self._state = round(data['temperature'] * 1.8 + 32, 1) + elif self.type == 'humidity': + self._state = round(data['humidity'], 1) + + +class DHTClient(object): + """ Gets the latest data from the DHT sensor. """ + + def __init__(self, adafruit_dht, sensor, pin): + self.adafruit_dht = adafruit_dht + self.sensor = sensor + self.pin = pin + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data the DHT sensor. """ + humidity, temperature = self.adafruit_dht.read_retry(self.sensor, + self.pin) + if temperature: + self.data['temperature'] = temperature + if humidity: + self.data['humidity'] = humidity diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py new file mode 100644 index 00000000000..aed685dce67 --- /dev/null +++ b/homeassistant/components/sensor/efergy.py @@ -0,0 +1,137 @@ +""" +homeassistant.components.sensor.efergy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Monitors home energy use as measured by an efergy engage hub using its +(unofficial, undocumented) API. + +Configuration: + +To use the efergy sensor you will need to add something like the following +to your configuration.yaml file. + +sensor: + platform: efergy + app_token: APP_TOKEN + utc_offset: UTC_OFFSET + monitored_variables: + - type: instant_readings + - type: budget + - type: cost + period: day + currency: $ + +Variables: + +api_key +*Required +To get a new App Token, log in to your efergy account, go +to the Settings page, click on App tokens, and click "Add token". + +utc_offset +*Required for some variables +Some variables (currently only the daily_cost) require that the +negative number of minutes your timezone is ahead/behind UTC time. + +monitored_variables +*Required +An array specifying the variables to monitor. + +period +*Optional +Some variables take a period argument. Valid options are "day", "week", +"month", and "year". + +currency +*Optional +This is used to display the cost/period as the unit when monitoring the +cost. It should correspond to the actual currency used in your dashboard. +""" +import logging +from requests import get + +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://engage.efergy.com/mobile_proxy/' +SENSOR_TYPES = { + 'instant_readings': ['Energy Usage', 'kW'], + 'budget': ['Energy Budget', ''], + 'cost': ['Energy Cost', ''], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Efergy sensor. """ + app_token = config.get("app_token") + if not app_token: + _LOGGER.error( + "Configuration Error" + "Please make sure you have configured your app token") + return None + utc_offset = str(config.get("utc_offset")) + dev = [] + for variable in config['monitored_variables']: + if 'period' not in variable: + variable['period'] = '' + if 'currency' not in variable: + variable['currency'] = '' + if variable['type'] not in SENSOR_TYPES: + _LOGGER.error('Sensor type: "%s" does not exist', variable) + else: + dev.append(EfergySensor(variable['type'], app_token, utc_offset, + variable['period'], variable['currency'])) + + add_devices(dev) + + +# pylint: disable=too-many-instance-attributes +class EfergySensor(Entity): + """ Implements an Efergy sensor. """ + + # pylint: disable=too-many-arguments + def __init__(self, sensor_type, app_token, utc_offset, period, currency): + self._name = SENSOR_TYPES[sensor_type][0] + self.type = sensor_type + self.app_token = app_token + self.utc_offset = utc_offset + self._state = None + self.period = period + self.currency = currency + if self.type == 'cost': + self._unit_of_measurement = self.currency + '/' + self.period + else: + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """ Returns the name. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + return self._unit_of_measurement + + def update(self): + """ Gets the Efergy monitor data from the web service. """ + if self.type == 'instant_readings': + url_string = _RESOURCE + 'getInstant?token=' + self.app_token + response = get(url_string) + self._state = response.json()['reading'] / 1000 + elif self.type == 'budget': + url_string = _RESOURCE + 'getBudget?token=' + self.app_token + response = get(url_string) + self._state = response.json()['status'] + elif self.type == 'cost': + url_string = _RESOURCE + 'getCost?token=' + self.app_token \ + + '&offset=' + self.utc_offset + '&period=' \ + + self.period + response = get(url_string) + self._state = response.json()['sum'] + else: + self._state = 'Unknown' diff --git a/homeassistant/components/sensor/forecast.py b/homeassistant/components/sensor/forecast.py new file mode 100644 index 00000000000..2c966530967 --- /dev/null +++ b/homeassistant/components/sensor/forecast.py @@ -0,0 +1,220 @@ +""" +homeassistant.components.sensor.forecast +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Forecast.io weather service. + +Configuration: + +To use the Forecast sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: forecast + api_key: YOUR_APP_KEY + monitored_conditions: + - summary + - precip_type + - precip_intensity + - temperature + - dew_point + - wind_speed + - wind_bearing + - cloud_cover + - humidity + - pressure + - visibility + - ozone + +Variables: + +api_key +*Required +To retrieve this value log into your account at http://forecast.io/. You can +make 1000 requests per day. This means that you could create every 1.4 minute +one. + +monitored_conditions +*Required +An array specifying the conditions to monitor. + +monitored_conditions +*Required +Conditions to monitor. See the configuration example above for a +list of all available conditions to monitor. + +Details for the API : https://developer.forecast.io/docs/v2 +""" +import logging +from datetime import timedelta + +REQUIREMENTS = ['python-forecastio==1.3.3'] + +try: + import forecastio +except ImportError: + forecastio = None + +from homeassistant.util import Throttle +from homeassistant.const import (CONF_API_KEY, TEMP_CELCIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = { + 'summary': ['Summary', ''], + 'precip_type': ['Precip', ''], + 'precip_intensity': ['Precip intensity', 'mm'], + 'temperature': ['Temperature', ''], + 'dew_point': ['Dew point', '°C'], + 'wind_speed': ['Wind Speed', 'm/s'], + 'wind_bearing': ['Wind Bearing', '°'], + 'cloud_cover': ['Cloud coverage', '%'], + 'humidity': ['Humidity', '%'], + 'pressure': ['Pressure', 'mBar'], + 'visibility': ['Visibility', 'km'], + 'ozone': ['Ozone', 'DU'], +} + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the Forecast.io sensor. """ + + global forecastio # pylint: disable=invalid-name + if forecastio is None: + import forecastio as forecastio_ + forecastio = forecastio_ + + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + SENSOR_TYPES['temperature'][1] = hass.config.temperature_unit + unit = hass.config.temperature_unit + + try: + forecast = forecastio.load_forecast(config.get(CONF_API_KEY, None), + hass.config.latitude, + hass.config.longitude) + forecast.currently() + except ValueError: + _LOGGER.error( + "Connection error " + "Please check your settings for Forecast.io.") + return False + + data = ForeCastData(config.get(CONF_API_KEY, None), + hass.config.latitude, + hass.config.longitude) + + dev = [] + for variable in config['monitored_conditions']: + if variable not in SENSOR_TYPES: + _LOGGER.error('Sensor type: "%s" does not exist', variable) + else: + dev.append(ForeCastSensor(data, variable, unit)) + + add_devices(dev) + + +# pylint: disable=too-few-public-methods +class ForeCastSensor(Entity): + """ Implements an Forecast.io sensor. """ + + def __init__(self, weather_data, sensor_type, unit): + self.client_name = 'Weather' + self._name = SENSOR_TYPES[sensor_type][0] + self.forecast_client = weather_data + self._unit = unit + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.update() + + @property + def name(self): + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + return self._unit_of_measurement + + # pylint: disable=too-many-branches + def update(self): + """ Gets the latest data from Forecast.io and updates the states. """ + + self.forecast_client.update() + data = self.forecast_client.data + + try: + if self.type == 'summary': + self._state = data.summary + elif self.type == 'precip_intensity': + if data.precipIntensity == 0: + self._state = 'None' + self._unit_of_measurement = '' + else: + self._state = data.precipIntensity + elif self.type == 'precip_type': + if data.precipType is None: + self._state = 'None' + self._unit_of_measurement = '' + else: + self._state = data.precipType + elif self.type == 'dew_point': + if self._unit == TEMP_CELCIUS: + self._state = round(data.dewPoint, 1) + elif self._unit == TEMP_FAHRENHEIT: + self._state = round(data.dewPoint * 1.8 + 32.0, 1) + else: + self._state = round(data.dewPoint, 1) + elif self.type == 'temperature': + if self._unit == TEMP_CELCIUS: + self._state = round(data.temperature, 1) + elif self._unit == TEMP_FAHRENHEIT: + self._state = round(data.temperature * 1.8 + 32.0, 1) + else: + self._state = round(data.temperature, 1) + elif self.type == 'wind_speed': + self._state = data.windSpeed + elif self.type == 'wind_bearing': + self._state = data.windBearing + elif self.type == 'cloud_cover': + self._state = round(data.cloudCover * 100, 1) + elif self.type == 'humidity': + self._state = round(data.humidity * 100, 1) + elif self.type == 'pressure': + self._state = round(data.pressure, 1) + elif self.type == 'visibility': + self._state = data.visibility + elif self.type == 'ozone': + self._state = round(data.ozone, 1) + except forecastio.utils.PropertyUnavailable: + pass + + +class ForeCastData(object): + """ Gets the latest data from Forecast.io. """ + + def __init__(self, api_key, latitude, longitude): + self._api_key = api_key + self.latitude = latitude + self.longitude = longitude + self.data = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from Forecast.io. """ + + forecast = forecastio.load_forecast(self._api_key, + self.latitude, + self.longitude, + units='si') + self.data = forecast.currently() diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py new file mode 100644 index 00000000000..c30f618f715 --- /dev/null +++ b/homeassistant/components/sensor/isy994.py @@ -0,0 +1,92 @@ +""" +homeassistant.components.sensor.isy994 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for ISY994 sensors. +""" +import logging + +from homeassistant.components.isy994 import (ISY, ISYDeviceABC, SENSOR_STRING, + HIDDEN_STRING) +from homeassistant.const import (STATE_OPEN, STATE_CLOSED, STATE_HOME, + STATE_NOT_HOME, STATE_ON, STATE_OFF) + +DEFAULT_HIDDEN_WEATHER = ['Temperature_High', 'Temperature_Low', 'Feels_Like', + 'Temperature_Average', 'Pressure', 'Dew_Point', + 'Gust_Speed', 'Evapotranspiration', + 'Irrigation_Requirement', 'Water_Deficit_Yesterday', + 'Elevation', 'Average_Temperature_Tomorrow', + 'High_Temperature_Tomorrow', + 'Low_Temperature_Tomorrow', 'Humidity_Tomorrow', + 'Wind_Speed_Tomorrow', 'Gust_Speed_Tomorrow', + 'Rain_Tomorrow', 'Snow_Tomorrow', + 'Forecast_Average_Temperature', + 'Forecast_High_Temperature', + 'Forecast_Low_Temperature', 'Forecast_Humidity', + 'Forecast_Rain', 'Forecast_Snow'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the ISY994 platform. """ + # pylint: disable=protected-access + logger = logging.getLogger(__name__) + devs = [] + # verify connection + if ISY is None or not ISY.connected: + logger.error('A connection has not been made to the ISY controller.') + return False + + # import weather + if ISY.climate is not None: + for prop in ISY.climate._id2name: + if prop is not None: + prefix = HIDDEN_STRING \ + if prop in DEFAULT_HIDDEN_WEATHER else '' + node = WeatherPseudoNode('ISY.weather.' + prop, prefix + prop, + getattr(ISY.climate, prop), + getattr(ISY.climate, prop + '_units')) + devs.append(ISYSensorDevice(node)) + + # import sensor nodes + for (path, node) in ISY.nodes: + if SENSOR_STRING in node.name: + if HIDDEN_STRING in path: + node.name += HIDDEN_STRING + devs.append(ISYSensorDevice(node, [STATE_ON, STATE_OFF])) + + # import sensor programs + for (folder_name, states) in ( + ('HA.locations', [STATE_HOME, STATE_NOT_HOME]), + ('HA.sensors', [STATE_OPEN, STATE_CLOSED]), + ('HA.states', [STATE_ON, STATE_OFF])): + try: + folder = ISY.programs['My Programs'][folder_name] + except KeyError: + # folder does not exist + pass + else: + for _, _, node_id in folder.children: + node = folder[node_id].leaf + devs.append(ISYSensorDevice(node, states)) + + add_devices(devs) + + +class WeatherPseudoNode(object): + """ This class allows weather variable to act as regular nodes. """ + # pylint: disable=too-few-public-methods + + def __init__(self, device_id, name, status, units=None): + self._id = device_id + self.name = name + self.status = status + self.units = units + + +class ISYSensorDevice(ISYDeviceABC): + """ Represents a ISY sensor. """ + + _domain = 'sensor' + + def __init__(self, node, states=None): + super().__init__(node) + self._states = states or [] diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py new file mode 100644 index 00000000000..ac2a5e444d1 --- /dev/null +++ b/homeassistant/components/sensor/modbus.py @@ -0,0 +1,167 @@ +""" +homeassistant.components.modbus +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Modbus sensors. + +Configuration: + +To use the Modbus sensors you will need to add something like the following to +your configuration.yaml file. + +sensor: + platform: modbus + slave: 1 + registers: + 16: + name: My integer sensor + unit: C + 24: + bits: + 0: + name: My boolean sensor + 2: + name: My other boolean sensor + coils: + 0: + name: My coil switch + +Variables: + +slave +*Required +Slave number (ignored and can be omitted if not serial Modbus). + +unit +*Required +Unit to attach to value (optional, ignored for boolean sensors). + +registers +*Required +Contains a list of relevant registers to read from. It can contain a +"bits" section, listing relevant bits. + +coils +*Optional +Contains a list of relevant coils to read from. + +Note: +- Each named register will create an integer sensor. +- Each named bit will create a boolean sensor. +""" +import logging + +import homeassistant.components.modbus as modbus +from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + TEMP_CELCIUS, TEMP_FAHRENHEIT, + STATE_ON, STATE_OFF) + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['modbus'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Read config and create Modbus devices. """ + sensors = [] + slave = config.get("slave", None) + if modbus.TYPE == "serial" and not slave: + _LOGGER.error("No slave number provided for serial Modbus") + return False + registers = config.get("registers") + if registers: + for regnum, register in registers.items(): + if register.get("name"): + sensors.append(ModbusSensor(register.get("name"), + slave, + regnum, + None, + register.get("unit"))) + if register.get("bits"): + bits = register.get("bits") + for bitnum, bit in bits.items(): + if bit.get("name"): + sensors.append(ModbusSensor(bit.get("name"), + slave, + regnum, + bitnum)) + coils = config.get("coils") + if coils: + for coilnum, coil in coils.items(): + sensors.append(ModbusSensor(coil.get("name"), + slave, + coilnum, + coil=True)) + + add_devices(sensors) + + +class ModbusSensor(Entity): + # pylint: disable=too-many-arguments + """ Represents a Modbus Sensor. """ + + def __init__(self, name, slave, register, bit=None, unit=None, coil=False): + self._name = name + self.slave = int(slave) if slave else 1 + self.register = int(register) + self.bit = int(bit) if bit else None + self._value = None + self._unit = unit + self._coil = coil + + def __str__(self): + return "%s: %s" % (self.name, self.state) + + @property + def should_poll(self): + """ + We should poll, because slaves are not allowed to + initiate communication on Modbus networks. + """ + return True + + @property + def unique_id(self): + """ Returns a unique id. """ + return "MODBUS-SENSOR-{}-{}-{}".format(self.slave, + self.register, + self.bit) + + @property + def state(self): + """ Returns the state of the sensor. """ + if self.bit: + return STATE_ON if self._value else STATE_OFF + else: + return self._value + + @property + def name(self): + """ Get the name of the sensor. """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + if self._unit == "C": + return TEMP_CELCIUS + elif self._unit == "F": + return TEMP_FAHRENHEIT + else: + return self._unit + + def update(self): + """ Update the state of the sensor. """ + if self._coil: + result = modbus.NETWORK.read_coils(self.register, 1) + self._value = result.bits[0] + else: + result = modbus.NETWORK.read_holding_registers( + unit=self.slave, address=self.register, + count=1) + val = 0 + for i, res in enumerate(result.registers): + val += res * (2**(i*16)) + if self.bit: + self._value = val & (0x0001 << self.bit) + else: + self._value = val diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py new file mode 100644 index 00000000000..37540820fcc --- /dev/null +++ b/homeassistant/components/sensor/mqtt.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +""" +homeassistant.components.sensor.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to configure a MQTT sensor. + +This generic sensor implementation uses the MQTT message payload +as the sensor value. If messages in this state_topic are published +with RETAIN flag, the sensor will receive an instant update with +last known value. Otherwise, the initial state will be undefined. + +sensor: + platform: mqtt + name: "MQTT Sensor" + state_topic: "home/bedroom/temperature" + qos: 0 + unit_of_measurement: "ºC" + +Variables: + +name +*Optional +The name of the sensor. Default is 'MQTT Sensor'. + +state_topic +*Required +The MQTT topic subscribed to receive sensor values. + +qos +*Optional +The maximum QoS level of the state topic. Default is 0. + +unit_of_measurement +*Optional +Defines the units of measurement of the sensor, if any. + +""" + +import logging +from homeassistant.helpers.entity import Entity +import homeassistant.components.mqtt as mqtt + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Sensor" +DEFAULT_QOS = 0 + +DEPENDENCIES = ['mqtt'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Add MQTT Sensor """ + + if config.get('state_topic') is None: + _LOGGER.error("Missing required variable: state_topic") + return False + + add_devices_callback([MqttSensor( + hass, + config.get('name', DEFAULT_NAME), + config.get('state_topic'), + config.get('qos', DEFAULT_QOS), + config.get('unit_of_measurement'))]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MqttSensor(Entity): + """ Represents a sensor that can be updated using MQTT """ + def __init__(self, hass, name, state_topic, qos, unit_of_measurement): + self._state = "-" + self._hass = hass + self._name = name + self._state_topic = state_topic + self._qos = qos + self._unit_of_measurement = unit_of_measurement + + def message_received(topic, payload, qos): + """ A new MQTT message has been received. """ + self._state = payload + 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 sensor """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit this state is expressed in. """ + return self._unit_of_measurement + + @property + def state(self): + """ Returns the state of the entity. """ + return self._state diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py new file mode 100644 index 00000000000..60e84059cad --- /dev/null +++ b/homeassistant/components/sensor/mysensors.py @@ -0,0 +1,161 @@ +""" +homeassistant.components.sensor.mysensors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for MySensors sensors. + +Configuration: + +To use the MySensors sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: mysensors + port: '/dev/ttyACM0' + +Variables: + +port +*Required +Port of your connection to your MySensors device. +""" +import logging + +from homeassistant.helpers.entity import Entity + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, + TEMP_CELCIUS, TEMP_FAHRENHEIT, + STATE_ON, STATE_OFF) + +CONF_PORT = "port" +CONF_DEBUG = "debug" +CONF_PERSISTENCE = "persistence" + +ATTR_NODE_ID = "node_id" +ATTR_CHILD_ID = "child_id" + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['https://github.com/theolind/pymysensors/archive/' + '35b87d880147a34107da0d40cb815d75e6cb4af7.zip' + '#pymysensors==0.2'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Setup the mysensors platform. """ + + import mysensors.mysensors as mysensors + import mysensors.const_14 as const + + devices = {} # keep track of devices added to HA + # Just assume celcius means that the user wants metric for now. + # It may make more sense to make this a global config option in the future. + is_metric = (hass.config.temperature_unit == TEMP_CELCIUS) + + def sensor_update(update_type, nid): + """ Callback for sensor updates from the MySensors gateway. """ + _LOGGER.info("sensor_update %s: node %s", update_type, nid) + sensor = gateway.sensors[nid] + if sensor.sketch_name is None: + return + if nid not in devices: + devices[nid] = {} + + node = devices[nid] + new_devices = [] + for child_id, child in sensor.children.items(): + if child_id not in node: + node[child_id] = {} + for value_type, value in child.values.items(): + if value_type not in node[child_id]: + name = '{} {}.{}'.format(sensor.sketch_name, nid, child.id) + node[child_id][value_type] = \ + MySensorsNodeValue( + nid, child_id, name, value_type, is_metric, const) + new_devices.append(node[child_id][value_type]) + else: + node[child_id][value_type].update_sensor( + value, sensor.battery_level) + + if new_devices: + _LOGGER.info("adding new devices: %s", new_devices) + add_devices(new_devices) + + port = config.get(CONF_PORT) + if port is None: + _LOGGER.error("Missing required key 'port'") + return False + + persistence = config.get(CONF_PERSISTENCE, True) + + gateway = mysensors.SerialGateway(port, sensor_update, + persistence=persistence) + gateway.metric = is_metric + gateway.debug = config.get(CONF_DEBUG, False) + gateway.start() + + if persistence: + for nid in gateway.sensors: + sensor_update('sensor_update', nid) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: gateway.stop()) + + +class MySensorsNodeValue(Entity): + """ Represents the value of a MySensors child node. """ + # pylint: disable=too-many-arguments, too-many-instance-attributes + def __init__(self, node_id, child_id, name, value_type, metric, const): + self._name = name + self.node_id = node_id + self.child_id = child_id + self.battery_level = 0 + self.value_type = value_type + self.metric = metric + self._value = '' + self.const = const + + @property + def should_poll(self): + """ MySensor gateway pushes its state to HA. """ + return False + + @property + def name(self): + """ The name of this sensor. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._value + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity. """ + if self.value_type == self.const.SetReq.V_TEMP: + return TEMP_CELCIUS if self.metric else TEMP_FAHRENHEIT + elif self.value_type == self.const.SetReq.V_HUM or \ + self.value_type == self.const.SetReq.V_DIMMER or \ + self.value_type == self.const.SetReq.V_LIGHT_LEVEL: + return '%' + return None + + @property + def state_attributes(self): + """ Returns the state attributes. """ + return { + ATTR_NODE_ID: self.node_id, + ATTR_CHILD_ID: self.child_id, + ATTR_BATTERY_LEVEL: self.battery_level, + } + + def update_sensor(self, value, battery_level): + """ Update a sensor with the latest value from the controller. """ + _LOGGER.info("%s value = %s", self._name, value) + if self.value_type == self.const.SetReq.V_TRIPPED or \ + self.value_type == self.const.SetReq.V_ARMED: + self._value = STATE_ON if int(value) == 1 else STATE_OFF + else: + self._value = value + self.battery_level = battery_level + self.update_ha_state() diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py new file mode 100644 index 00000000000..5ca292a599f --- /dev/null +++ b/homeassistant/components/sensor/openweathermap.py @@ -0,0 +1,208 @@ +""" +homeassistant.components.sensor.openweathermap +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +OpenWeatherMap (OWM) service. + +Configuration: + +To use the OpenWeatherMap sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: openweathermap + api_key: YOUR_APP_KEY + forecast: 0 or 1 + monitored_conditions: + - weather + - temperature + - wind_speed + - humidity + - pressure + - clouds + - rain + - snow + +Variables: + +api_key +*Required +To retrieve this value log into your account at http://openweathermap.org/ + +forecast +*Optional +Enables the forecast. The default is to display the current conditions. + +monitored_conditions +*Required +Conditions to monitor. See the configuration example above for a +list of all available conditions to monitor. + +Details for the API : http://bugs.openweathermap.org/projects/api/wiki + +Only metric measurements are supported at the moment. +""" +import logging +from datetime import timedelta + +from homeassistant.util import Throttle +from homeassistant.const import (CONF_API_KEY, TEMP_CELCIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['pyowm==2.2.1'] +_LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = { + 'weather': ['Condition', ''], + 'temperature': ['Temperature', ''], + 'wind_speed': ['Wind speed', 'm/s'], + 'humidity': ['Humidity', '%'], + 'pressure': ['Pressure', 'hPa'], + 'clouds': ['Cloud coverage', '%'], + 'rain': ['Rain', 'mm'], + 'snow': ['Snow', 'mm'] +} + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the OpenWeatherMap sensor. """ + + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + try: + from pyowm import OWM + + except ImportError: + _LOGGER.exception( + "Unable to import pyowm. " + "Did you maybe not install the 'PyOWM' package?") + + return False + + SENSOR_TYPES['temperature'][1] = hass.config.temperature_unit + unit = hass.config.temperature_unit + forecast = config.get('forecast', 0) + owm = OWM(config.get(CONF_API_KEY, None)) + + if not owm: + _LOGGER.error( + "Connection error " + "Please check your settings for OpenWeatherMap.") + return None + + data = WeatherData(owm, forecast, hass.config.latitude, + hass.config.longitude) + dev = [] + try: + for variable in config['monitored_conditions']: + if variable not in SENSOR_TYPES: + _LOGGER.error('Sensor type: "%s" does not exist', variable) + else: + dev.append(OpenWeatherMapSensor(data, variable, unit)) + except KeyError: + pass + + if forecast == 1: + SENSOR_TYPES['forecast'] = ['Forecast', ''] + dev.append(OpenWeatherMapSensor(data, 'forecast', unit)) + + add_devices(dev) + + +# pylint: disable=too-few-public-methods +class OpenWeatherMapSensor(Entity): + """ Implements an OpenWeatherMap sensor. """ + + def __init__(self, weather_data, sensor_type, temp_unit): + self.client_name = 'Weather' + self._name = SENSOR_TYPES[sensor_type][0] + self.owa_client = weather_data + self.temp_unit = temp_unit + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.update() + + @property + def name(self): + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + return self._unit_of_measurement + + # pylint: disable=too-many-branches + def update(self): + """ Gets the latest data from OWM and updates the states. """ + + self.owa_client.update() + data = self.owa_client.data + fc_data = self.owa_client.fc_data + + if self.type == 'weather': + self._state = data.get_detailed_status() + elif self.type == 'temperature': + if self.temp_unit == TEMP_CELCIUS: + self._state = round(data.get_temperature('celsius')['temp'], + 1) + elif self.temp_unit == TEMP_FAHRENHEIT: + self._state = round(data.get_temperature('fahrenheit')['temp'], + 1) + else: + self._state = round(data.get_temperature()['temp'], 1) + elif self.type == 'wind_speed': + self._state = data.get_wind()['speed'] + elif self.type == 'humidity': + self._state = data.get_humidity() + elif self.type == 'pressure': + self._state = round(data.get_pressure()['press'], 0) + elif self.type == 'clouds': + self._state = data.get_clouds() + elif self.type == 'rain': + if data.get_rain(): + self._state = round(data.get_rain()['3h'], 0) + self._unit_of_measurement = 'mm' + else: + self._state = 'not raining' + self._unit_of_measurement = '' + elif self.type == 'snow': + if data.get_snow(): + self._state = round(data.get_snow(), 0) + self._unit_of_measurement = 'mm' + else: + self._state = 'not snowing' + self._unit_of_measurement = '' + elif self.type == 'forecast': + self._state = fc_data.get_weathers()[0].get_status() + + +class WeatherData(object): + """ Gets the latest data from OpenWeatherMap. """ + + def __init__(self, owm, forecast, latitude, longitude): + self.owm = owm + self.forecast = forecast + self.latitude = latitude + self.longitude = longitude + self.data = None + self.fc_data = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from OpenWeatherMap. """ + obs = self.owm.weather_at_coords(self.latitude, self.longitude) + self.data = obs.get_weather() + + if self.forecast == 1: + obs = self.owm.three_hours_forecast_at_coords(self.latitude, + self.longitude) + self.fc_data = obs.get_forecast() diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py new file mode 100644 index 00000000000..4cb8a939d5e --- /dev/null +++ b/homeassistant/components/sensor/rfxtrx.py @@ -0,0 +1,105 @@ +""" +homeassistant.components.sensor.rfxtrx +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Shows sensor values from RFXtrx sensors. + +Configuration: + +To use the rfxtrx sensors you will need to add something like the following to +your configuration.yaml file. + +sensor: + platform: rfxtrx + device: PATH_TO_DEVICE + +Variables: + +device +*Required +Path to your RFXtrx device. +E.g. /dev/serial/by-id/usb-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0 +""" +import logging +from collections import OrderedDict + +from homeassistant.const import (TEMP_CELCIUS) +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['https://github.com/Danielhiversen/pyRFXtrx/archive/' + + 'ec7a1aaddf8270db6e5da1c13d58c1547effd7cf.zip#RFXtrx==0.15'] + +DATA_TYPES = OrderedDict([ + ('Temperature', TEMP_CELCIUS), + ('Humidity', '%'), + ('Barometer', ''), + ('Wind direction', ''), + ('Rain rate', '')]) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Setup the RFXtrx platform. """ + logger = logging.getLogger(__name__) + + sensors = {} # keep track of sensors added to HA + + def sensor_update(event): + """ Callback for sensor updates from the RFXtrx gateway. """ + if event.device.id_string in sensors: + sensors[event.device.id_string].event = event + else: + logger.info("adding new sensor: %s", event.device.type_string) + new_sensor = RfxtrxSensor(event) + sensors[event.device.id_string] = new_sensor + add_devices([new_sensor]) + try: + import RFXtrx as rfxtrx + except ImportError: + logger.exception( + "Failed to import rfxtrx") + return False + + device = config.get("device", "") + rfxtrx.Core(device, sensor_update) + + +class RfxtrxSensor(Entity): + """ Represents a RFXtrx sensor. """ + + def __init__(self, event): + self.event = event + + self._unit_of_measurement = None + self._data_type = None + for data_type in DATA_TYPES: + if data_type in self.event.values: + self._unit_of_measurement = DATA_TYPES[data_type] + self._data_type = data_type + break + + id_string = int(event.device.id_string.replace(":", ""), 16) + self._name = "{} {} ({})".format(self._data_type, + self.event.device.type_string, + id_string) + + def __str__(self): + return self._name + + @property + def state(self): + if self._data_type: + return self.event.values[self._data_type] + return None + + @property + def name(self): + """ Get the mame of the sensor. """ + return self._name + + @property + def state_attributes(self): + return self.event.values + + @property + def unit_of_measurement(self): + """ Unit this state is expressed in. """ + return self._unit_of_measurement diff --git a/homeassistant/components/sensor/rpi_gpio.py b/homeassistant/components/sensor/rpi_gpio.py new file mode 100644 index 00000000000..e8482ea56ac --- /dev/null +++ b/homeassistant/components/sensor/rpi_gpio.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +""" +homeassistant.components.sensor.rpi_gpio +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to configure a binary state sensor using RPi GPIO. +Note: To use RPi GPIO, Home Assistant must be run as root. + +sensor: + platform: rpi_gpio + pull_mode: "UP" + value_high: "Active" + value_low: "Inactive" + ports: + 11: PIR Office + 12: PIR Bedroom + +Variables: + +pull_mode +*Optional +The internal pull to use (UP or DOWN). Default is UP. + +value_high +*Optional +The value of the sensor when the port is HIGH. Default is "HIGH". + +value_low +*Optional +The value of the sensor when the port is LOW. Default is "LOW". + +bouncetime +*Optional +The time in milliseconds for port debouncing. Default is 50ms. + +ports +*Required +An array specifying the GPIO ports to use and the name to use in the frontend. +""" +import logging +from homeassistant.helpers.entity import Entity + +try: + import RPi.GPIO as GPIO +except ImportError: + GPIO = None +from homeassistant.const import (DEVICE_DEFAULT_NAME, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) + +DEFAULT_PULL_MODE = "UP" +DEFAULT_VALUE_HIGH = "HIGH" +DEFAULT_VALUE_LOW = "LOW" +DEFAULT_BOUNCETIME = 50 + +REQUIREMENTS = ['RPi.GPIO==0.5.11'] +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Raspberry PI GPIO ports. """ + if GPIO is None: + _LOGGER.error('RPi.GPIO not available. rpi_gpio ports ignored.') + return + # pylint: disable=no-member + GPIO.setmode(GPIO.BCM) + + sensors = [] + pull_mode = config.get('pull_mode', DEFAULT_PULL_MODE) + value_high = config.get('value_high', DEFAULT_VALUE_HIGH) + value_low = config.get('value_low', DEFAULT_VALUE_LOW) + bouncetime = config.get('bouncetime', DEFAULT_BOUNCETIME) + ports = config.get('ports') + for port_num, port_name in ports.items(): + sensors.append(RPiGPIOSensor( + port_name, port_num, pull_mode, + value_high, value_low, bouncetime)) + add_devices(sensors) + + def cleanup_gpio(event): + """ Stuff to do before stop home assistant. """ + # pylint: disable=no-member + GPIO.cleanup() + + def prepare_gpio(event): + """ Stuff to do when home assistant starts. """ + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class RPiGPIOSensor(Entity): + """ Sets up the Raspberry PI GPIO ports. """ + def __init__(self, port_name, port_num, pull_mode, + value_high, value_low, bouncetime): + # pylint: disable=no-member + self._name = port_name or DEVICE_DEFAULT_NAME + self._port = port_num + self._pull = GPIO.PUD_DOWN if pull_mode == "DOWN" else GPIO.PUD_UP + self._vhigh = value_high + self._vlow = value_low + self._bouncetime = bouncetime + GPIO.setup(self._port, GPIO.IN, pull_up_down=self._pull) + self._state = self._vhigh if GPIO.input(self._port) else self._vlow + + def edge_callback(channel): + """ port changed state """ + # pylint: disable=no-member + self._state = self._vhigh if GPIO.input(channel) else self._vlow + self.update_ha_state() + + GPIO.add_event_detect( + self._port, + GPIO.BOTH, + callback=edge_callback, + bouncetime=self._bouncetime) + + @property + def should_poll(self): + """ No polling needed. """ + return False + + @property + def name(self): + """ The name of the sensor. """ + return self._name + + @property + def state(self): + """ Returns the state of the entity. """ + return self._state diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index a1230def614..b372e777478 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -1,13 +1,12 @@ """ homeassistant.components.sensor.sabnzbd -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Monitors SABnzbd NZB client API Configuration: To use the SABnzbd sensor you will need to add something like the following to -your config/configuration.yaml +your configuration.yaml file. sensor: platform: sabnzbd @@ -22,18 +21,16 @@ sensor: - type: 'disk_size' - type: 'disk_free' -VARIABLES: +Variables: base_url *Required This is the base URL of your SABnzbd instance including the port number if not -running on 80 -Example: http://192.168.1.32:8124/ - +running on 80, e.g. http://192.168.1.32:8124/ name *Optional -The name to use when displaying this SABnzbd instance +The name to use when displaying this SABnzbd instance. monitored_variables *Required @@ -44,21 +41,19 @@ These are the variables for the monitored_variables array: type *Required The variable you wish to monitor, see the configuration example above for a -list of all available variables - - +list of all available variables. """ - from homeassistant.util import Throttle from datetime import timedelta from homeassistant.helpers.entity import Entity -# pylint: disable=no-name-in-module, import-error -from homeassistant.external.nzbclients.sabnzbd import SabnzbdApi -from homeassistant.external.nzbclients.sabnzbd import SabnzbdApiException import logging +REQUIREMENTS = ['https://github.com/jamespcole/home-assistant-nzb-clients/' + 'archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip' + '#python-sabnzbd==0.1'] + SENSOR_TYPES = { 'current_status': ['Status', ''], 'speed': ['Speed', 'MB/s'], @@ -75,7 +70,9 @@ _THROTTLED_REFRESH = None # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """ Sets up the sensors """ + """ Sets up the SABnzbd sensors. """ + from pysabnzbd import SabnzbdApi, SabnzbdApiException + api_key = config.get("api_key") base_url = config.get("base_url") name = config.get("name", "SABnzbd") @@ -109,7 +106,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class SabnzbdSensor(Entity): - """ A Sabnzbd sensor """ + """ Represents an SABnzbd sensor. """ def __init__(self, sensor_type, sabnzb_client, client_name): self._name = SENSOR_TYPES[sensor_type][0] @@ -136,6 +133,7 @@ class SabnzbdSensor(Entity): def refresh_sabnzbd_data(self): """ Calls the throttled SABnzbd refresh method. """ if _THROTTLED_REFRESH is not None: + from pysabnzbd import SabnzbdApiException try: _THROTTLED_REFRESH() except SabnzbdApiException: diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py new file mode 100644 index 00000000000..440c7f8ad28 --- /dev/null +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -0,0 +1,131 @@ +""" +homeassistant.components.sensor.swiss_public_transport +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The Swiss public transport sensor will give you the next two departure times +from a given location to another one. This sensor is limited to Switzerland. + +Configuration: + +To use the Swiss public transport sensor you will need to add something like +the following to your configuration.yaml file. + +sensor: + platform: swiss_public_transport + from: STATION_ID + to: STATION_ID + +Variables: + +from +*Required +Start station/stop of your trip. To search for the ID of the station, use the +an URL like this: http://transport.opendata.ch/v1/locations?query=Wankdorf +to query for the station. If the score is 100 ("score":"100" in the response), +it is a perfect match. + +to +*Required +Destination station/stop of the trip. Same procedure as for the start station. + +Details for the API : http://transport.opendata.ch +""" +import logging +from datetime import timedelta +from requests import get + +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'http://transport.opendata.ch/v1/' + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the Swiss public transport sensor. """ + + # journal contains [0] Station ID start, [1] Station ID destination + # [2] Station name start, and [3] Station name destination + journey = [config.get('from'), config.get('to')] + try: + for location in [config.get('from', None), config.get('to', None)]: + # transport.opendata.ch doesn't play nice with requests.Session + result = get(_RESOURCE + 'locations?query=%s' % location) + journey.append(result.json()['stations'][0]['name']) + except KeyError: + _LOGGER.exception( + "Unable to determine stations. " + "Check your settings and/or the availability of opendata.ch") + + return None + + dev = [] + data = PublicTransportData(journey) + dev.append(SwissPublicTransportSensor(data, journey)) + add_devices(dev) + + +# pylint: disable=too-few-public-methods +class SwissPublicTransportSensor(Entity): + """ Implements an Swiss public transport sensor. """ + + def __init__(self, data, journey): + self.data = data + self._name = '{}-{}'.format(journey[2], journey[3]) + self.update() + + @property + def name(self): + """ Returns the name. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + # pylint: disable=too-many-branches + def update(self): + """ Gets the latest data from opendata.ch and updates the states. """ + times = self.data.update() + try: + self._state = ', '.join(times) + except TypeError: + pass + + +# pylint: disable=too-few-public-methods +class PublicTransportData(object): + """ Class for handling the data retrieval. """ + + def __init__(self, journey): + self.start = journey[0] + self.destination = journey[1] + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from opendata.ch. """ + + response = get( + _RESOURCE + + 'connections?' + + 'from=' + self.start + '&' + + 'to=' + self.destination + '&' + + 'fields[]=connections/from/departureTimestamp/&' + + 'fields[]=connections/') + + connections = response.json()['connections'][:2] + + try: + return [ + dt_util.datetime_to_time_str( + dt_util.as_local(dt_util.utc_from_timestamp( + item['from']['departureTimestamp'])) + ) + for item in connections + ] + except KeyError: + return ['n/a'] diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index e03e987802b..ff7e908ccf1 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -1,17 +1,71 @@ """ homeassistant.components.sensor.systemmonitor -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Shows system monitor values such as: disk, memory, and processor use. -Shows system monitor values such as: disk, memory and processor use +Configuration: +To use the System monitor sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: systemmonitor + resources: + - type: 'disk_use_percent' + arg: '/' + - type: 'disk_use' + arg: '/home' + - type: 'disk_free' + arg: '/' + - type: 'memory_use_percent' + - type: 'memory_use' + - type: 'memory_free' + - type: 'swap_use_percent' + - type: 'swap_use' + - type: 'swap_free' + - type: 'network_in' + arg: 'eth0' + - type: 'network_out' + arg: 'eth0' + - type: 'packets_in' + arg: 'eth0' + - type: 'packets_out' + arg: 'eth0' + - type: 'ipv4_address' + arg: 'eth0' + - type: 'ipv6_address' + arg: 'eth0' + - type: 'processor_use' + - type: 'process' + arg: 'octave-cli' + - type: 'last_boot' + - type: 'since_last_boot' + +Variables: + +resources +*Required +An array specifying the variables to monitor. + +These are the variables for the resources array: + +type +*Required +The variable you wish to monitor, see the configuration example above for a +sample list of variables. + +arg +*Optional +Additional details for the type, eg. path, binary name, etc. """ +import logging +import psutil +import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity from homeassistant.const import STATE_ON, STATE_OFF -import psutil -import logging - +REQUIREMENTS = ['psutil==3.0.0'] SENSOR_TYPES = { 'disk_use_percent': ['Disk Use', '%'], 'disk_use': ['Disk Use', 'GiB'], @@ -21,6 +75,17 @@ SENSOR_TYPES = { 'memory_free': ['RAM Free', 'MiB'], 'processor_use': ['CPU Use', '%'], 'process': ['Process', ''], + 'swap_use_percent': ['Swap Use', '%'], + 'swap_use': ['Swap Use', 'GiB'], + 'swap_free': ['Swap Free', 'GiB'], + 'network_out': ['Sent', 'MiB'], + 'network_in': ['Recieved', 'MiB'], + 'packets_out': ['Packets sent', ''], + 'packets_in': ['Packets recieved', ''], + 'ipv4_address': ['IPv4 address', ''], + 'ipv6_address': ['IPv6 address', ''], + 'last_boot': ['Last Boot', ''], + 'since_last_boot': ['Since Last Boot', ''] } _LOGGER = logging.getLogger(__name__) @@ -28,7 +93,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """ Sets up the sensors """ + """ Sets up the sensors. """ dev = [] for resource in config['resources']: @@ -43,7 +108,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class SystemMonitorSensor(Entity): - """ A system monitor sensor """ + """ A system monitor sensor. """ def __init__(self, sensor_type, argument=''): self._name = SENSOR_TYPES[sensor_type][0] + ' ' + argument @@ -66,6 +131,7 @@ class SystemMonitorSensor(Entity): def unit_of_measurement(self): return self._unit_of_measurement + # pylint: disable=too-many-branches def update(self): if self.type == 'disk_use_percent': self._state = psutil.disk_usage(self.argument).percent @@ -83,6 +149,12 @@ class SystemMonitorSensor(Entity): 1024**2, 1) elif self.type == 'memory_free': self._state = round(psutil.virtual_memory().available / 1024**2, 1) + elif self.type == 'swap_use_percent': + self._state = psutil.swap_memory().percent + elif self.type == 'swap_use': + self._state = round(psutil.swap_memory().used / 1024**3, 1) + elif self.type == 'swap_free': + self._state = round(psutil.swap_memory().free / 1024**3, 1) elif self.type == 'processor_use': self._state = round(psutil.cpu_percent(interval=None)) elif self.type == 'process': @@ -90,3 +162,24 @@ class SystemMonitorSensor(Entity): self._state = STATE_ON else: self._state = STATE_OFF + elif self.type == 'network_out': + self._state = round(psutil.net_io_counters(pernic=True) + [self.argument][0] / 1024**2, 1) + elif self.type == 'network_in': + self._state = round(psutil.net_io_counters(pernic=True) + [self.argument][1] / 1024**2, 1) + elif self.type == 'packets_out': + self._state = psutil.net_io_counters(pernic=True)[self.argument][2] + elif self.type == 'packets_in': + self._state = psutil.net_io_counters(pernic=True)[self.argument][3] + elif self.type == 'ipv4_address': + self._state = psutil.net_if_addrs()[self.argument][0][1] + elif self.type == 'ipv6_address': + self._state = psutil.net_if_addrs()[self.argument][1][1] + elif self.type == 'last_boot': + self._state = dt_util.datetime_to_date_str( + dt_util.as_local( + dt_util.utc_from_timestamp(psutil.boot_time()))) + elif self.type == 'since_last_boot': + self._state = dt_util.utcnow() - dt_util.utc_from_timestamp( + psutil.boot_time()) diff --git a/homeassistant/components/sensor/tellstick.py b/homeassistant/components/sensor/tellstick.py index 5720b65a669..7ee0fc19a99 100644 --- a/homeassistant/components/sensor/tellstick.py +++ b/homeassistant/components/sensor/tellstick.py @@ -1,8 +1,7 @@ """ homeassistant.components.sensor.tellstick ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Shows sensor values from tellstick sensors. +Shows sensor values from Tellstick sensors. Possible config keys: @@ -35,6 +34,8 @@ import homeassistant.util as util DatatypeDescription = namedtuple("DatatypeDescription", ['name', 'unit']) +REQUIREMENTS = ['tellcore-py==1.0.4'] + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -77,7 +78,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: sensor_name = config[ts_sensor.id] except KeyError: - if 'only_named' in config: + if util.convert(config.get('only_named'), bool, False): continue sensor_name = str(ts_sensor.id) diff --git a/homeassistant/components/sensor/temper.py b/homeassistant/components/sensor/temper.py new file mode 100644 index 00000000000..c7943f0cc06 --- /dev/null +++ b/homeassistant/components/sensor/temper.py @@ -0,0 +1,71 @@ +""" +homeassistant.components.sensor.temper +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for getting temperature from TEMPer devices. + +Configuration: + +To use the temper sensors you will need to add something like the following to +your configuration.yaml file. + +sensor: + platform: temper +""" +import logging +from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['https://github.com/rkabadi/temper-python/archive/' + '3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip' + '#temperusb==1.2.3'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Find and return Temper sensors. """ + try: + # pylint: disable=no-name-in-module, import-error + from temperusb.temper import TemperHandler + except ImportError: + _LOGGER.error('Failed to import temperusb') + return False + + temp_unit = hass.config.temperature_unit + name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + temper_devices = TemperHandler().get_devices() + add_devices_callback([TemperSensor(dev, temp_unit, name + '_' + str(idx)) + for idx, dev in enumerate(temper_devices)]) + + +class TemperSensor(Entity): + """ Represents an Temper temperature sensor. """ + def __init__(self, temper_device, temp_unit, name): + self.temper_device = temper_device + self.temp_unit = temp_unit + self.current_value = None + self._name = name + + @property + def name(self): + """ Returns the name of the temperature sensor. """ + return self._name + + @property + def state(self): + """ Returns the state of the entity. """ + return self.current_value + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + return self.temp_unit + + def update(self): + """ Retrieve latest state. """ + try: + self.current_value = self.temper_device.get_temperature() + except IOError: + _LOGGER.error('Failed to get temperature due to insufficient ' + 'permissions. Try running with "sudo"') diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py new file mode 100644 index 00000000000..77011c3afad --- /dev/null +++ b/homeassistant/components/sensor/time_date.py @@ -0,0 +1,105 @@ +""" +homeassistant.components.sensor.time_date +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Date and Time service. + +Configuration: + +To use the Date and Time sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: time_date + display_options: + - 'time' + - 'date' + - 'date_time' + - 'time_date' + - 'time_utc' + - 'beat' + +Variables: + +display_options +*Required +The variable you wish to display. See the configuration example above for a +list of all available variables. +""" +import logging + +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +OPTION_TYPES = { + 'time': 'Time', + 'date': 'Date', + 'date_time': 'Date & Time', + 'time_date': 'Time & Date', + 'beat': 'Time (beat)', + 'time_utc': 'Time (UTC)', +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the Time and Date sensor. """ + + if hass.config.time_zone is None: + _LOGGER.error("Timezone is not set in Home Assistant config") + return False + + dev = [] + for variable in config['display_options']: + if variable not in OPTION_TYPES: + _LOGGER.error('Option type: "%s" does not exist', variable) + else: + dev.append(TimeDateSensor(variable)) + + add_devices(dev) + + +# pylint: disable=too-few-public-methods +class TimeDateSensor(Entity): + """ Implements a Time and Date sensor. """ + + def __init__(self, option_type): + self._name = OPTION_TYPES[option_type] + self.type = option_type + self._state = None + self.update() + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + def update(self): + """ Gets the latest data and updates the states. """ + + time_date = dt_util.utcnow() + time = dt_util.datetime_to_time_str(dt_util.as_local(time_date)) + time_utc = dt_util.datetime_to_time_str(time_date) + date = dt_util.datetime_to_date_str(dt_util.as_local(time_date)) + + # Calculate the beat (Swatch Internet Time) time without date. + hours, minutes, seconds = time_date.strftime('%H:%M:%S').split(':') + beat = ((int(seconds) + (int(minutes) * 60) + ((int(hours) + 1) * + 3600)) / 86.4) + + if self.type == 'time': + self._state = time + elif self.type == 'date': + self._state = date + elif self.type == 'date_time': + self._state = date + ', ' + time + elif self.type == 'time_date': + self._state = time + ', ' + date + elif self.type == 'time_utc': + self._state = time_utc + elif self.type == 'beat': + self._state = '{0:.2f}'.format(beat) diff --git a/homeassistant/components/sensor/transmission.py b/homeassistant/components/sensor/transmission.py new file mode 100644 index 00000000000..992a8838f3f --- /dev/null +++ b/homeassistant/components/sensor/transmission.py @@ -0,0 +1,175 @@ +""" +homeassistant.components.sensor.transmission +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Monitors Transmission BitTorrent client API. + +Configuration: + +To use the Transmission sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: transmission + name: Transmission + host: 192.168.1.26 + port: 9091 + username: YOUR_USERNAME + password: YOUR_PASSWORD + monitored_variables: + - 'current_status' + - 'download_speed' + - 'upload_speed' + +Variables: + +host +*Required +This is the IP address of your Transmission daemon, e.g. 192.168.1.32 + +port +*Optional +The port your Transmission daemon uses, defaults to 9091. Example: 8080 + +username +*Required +Your Transmission username. + +password +*Required +Your Transmission password. + +name +*Optional +The name to use when displaying this Transmission instance. + +monitored_variables +*Required +Variables to monitor. See the configuration example above for a +list of all available variables to monitor. +""" +from homeassistant.util import Throttle +from datetime import timedelta +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD + +from homeassistant.helpers.entity import Entity +# pylint: disable=no-name-in-module, import-error +import transmissionrpc + +from transmissionrpc.error import TransmissionError + +import logging + +REQUIREMENTS = ['transmissionrpc==0.11'] +SENSOR_TYPES = { + 'current_status': ['Status', ''], + 'download_speed': ['Down Speed', 'MB/s'], + 'upload_speed': ['Up Speed', 'MB/s'] +} + +_LOGGER = logging.getLogger(__name__) + +_THROTTLED_REFRESH = None + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Transmission sensors. """ + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME, None) + password = config.get(CONF_PASSWORD, None) + port = config.get('port', 9091) + + name = config.get("name", "Transmission") + if not host: + _LOGGER.error('Missing config variable %s', CONF_HOST) + return False + + # import logging + # logging.getLogger('transmissionrpc').setLevel(logging.DEBUG) + + transmission_api = transmissionrpc.Client( + host, port=port, user=username, password=password) + try: + transmission_api.session_stats() + except TransmissionError: + _LOGGER.exception("Connection to Transmission API failed.") + return False + + # pylint: disable=global-statement + global _THROTTLED_REFRESH + _THROTTLED_REFRESH = Throttle(timedelta(seconds=1))( + transmission_api.session_stats) + + dev = [] + for variable in config['monitored_variables']: + if variable not in SENSOR_TYPES: + _LOGGER.error('Sensor type: "%s" does not exist', variable) + else: + dev.append(TransmissionSensor( + variable, transmission_api, name)) + + add_devices(dev) + + +class TransmissionSensor(Entity): + """ A Transmission sensor. """ + + def __init__(self, sensor_type, transmission_client, client_name): + self._name = SENSOR_TYPES[sensor_type][0] + self.transmission_client = transmission_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + return self.client_name + ' ' + self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + return self._unit_of_measurement + + def refresh_transmission_data(self): + """ Calls the throttled Transmission refresh method. """ + if _THROTTLED_REFRESH is not None: + try: + _THROTTLED_REFRESH() + except TransmissionError: + _LOGGER.exception( + self.name + " Connection to Transmission API failed." + ) + + def update(self): + """ Gets the latest data from Transmission and updates the state. """ + self.refresh_transmission_data() + if self.type == 'current_status': + if self.transmission_client.session: + upload = self.transmission_client.session.uploadSpeed + download = self.transmission_client.session.downloadSpeed + if upload > 0 and download > 0: + self._state = 'Up/Down' + elif upload > 0 and download == 0: + self._state = 'Seeding' + elif upload == 0 and download > 0: + self._state = 'Downloading' + else: + self._state = 'Idle' + else: + self._state = 'Unknown' + + if self.transmission_client.session: + if self.type == 'download_speed': + mb_spd = float(self.transmission_client.session.downloadSpeed) + mb_spd = mb_spd / 1024 / 1024 + self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) + elif self.type == 'upload_speed': + mb_spd = float(self.transmission_client.session.uploadSpeed) + mb_spd = mb_spd / 1024 / 1024 + self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index daa2acf5004..b02e3acdea0 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -1,9 +1,12 @@ """ +homeassistant.components.sensor.vera +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Support for Vera sensors. Configuration: + To use the Vera sensors you will need to add something like the following to -your config/configuration.yaml +your configuration.yaml file. sensor: platform: vera @@ -15,13 +18,12 @@ sensor: 13: name: Another sensor -VARIABLES: +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/ +running on 80, e.g. http://192.168.1.21:3480/ device_data @@ -29,7 +31,7 @@ device_data This contains an array additional device info for your Vera devices. It is not required and if not specified all sensors 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 +as the key for the device within device_data. These are the variables for the device_data array: @@ -37,24 +39,25 @@ 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 - +will be used. exclude *Optional -This parameter allows you to exclude the specified device from homeassistant, -it should be set to "true" if you want this device excluded - +This parameter allows you to exclude the specified device from Home Assistant, +it should be set to "true" if you want this device excluded. """ import logging -import time from requests.exceptions import RequestException +import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_TRIPPED, ATTR_ARMED, ATTR_LAST_TRIP_TIME) -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.vera.vera as veraApi + ATTR_BATTERY_LEVEL, ATTR_TRIPPED, ATTR_ARMED, ATTR_LAST_TRIP_TIME, + TEMP_CELCIUS, TEMP_FAHRENHEIT) + +REQUIREMENTS = ['https://github.com/balloob/home-assistant-vera-api/archive/' + 'a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip' + '#python-vera==0.1'] _LOGGER = logging.getLogger(__name__) @@ -62,6 +65,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument def get_devices(hass, config): """ Find and return Vera Sensors. """ + import pyvera as veraApi base_url = config.get('vera_controller_url') if not base_url: @@ -95,12 +99,12 @@ def get_devices(hass, config): def setup_platform(hass, config, add_devices, discovery_info=None): - """ Performs setup for Vera controller devices """ + """ Performs setup for Vera controller devices. """ add_devices(get_devices(hass, config)) class VeraSensor(Entity): - """ Represents a Vera Sensor """ + """ Represents a Vera Sensor. """ def __init__(self, vera_device, extra_data=None): self.vera_device = vera_device @@ -110,6 +114,7 @@ class VeraSensor(Entity): else: self._name = self.vera_device.name self.current_value = '' + self._temperature_units = None def __str__(self): return "%s %s %s" % (self.name, self.vera_device.deviceId, self.state) @@ -123,6 +128,11 @@ class VeraSensor(Entity): """ Get the mame of the sensor. """ return self._name + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity, if any. """ + return self._temperature_units + @property def state_attributes(self): attr = super().state_attributes @@ -135,11 +145,12 @@ class VeraSensor(Entity): if self.vera_device.is_trippable: last_tripped = self.vera_device.refresh_value('LastTrip') - trip_time_str = time.strftime( - "%Y-%m-%d %H:%M", - time.localtime(int(last_tripped)) - ) - attr[ATTR_LAST_TRIP_TIME] = trip_time_str + if last_tripped is not None: + utc_time = dt_util.utc_from_timestamp(int(last_tripped)) + attr[ATTR_LAST_TRIP_TIME] = dt_util.datetime_to_str( + utc_time) + else: + attr[ATTR_LAST_TRIP_TIME] = None tripped = self.vera_device.refresh_value('Tripped') attr[ATTR_TRIPPED] = 'True' if tripped == '1' else 'False' @@ -151,7 +162,20 @@ class VeraSensor(Entity): self.vera_device.refresh_value('CurrentTemperature') current_temp = self.vera_device.get_value('CurrentTemperature') vera_temp_units = self.vera_device.veraController.temperature_units - self.current_value = current_temp + '°' + vera_temp_units + + if vera_temp_units == 'F': + self._temperature_units = TEMP_FAHRENHEIT + else: + self._temperature_units = TEMP_CELCIUS + + if self.hass: + temp = self.hass.config.temperature( + current_temp, + self._temperature_units) + + current_temp, self._temperature_units = temp + + self.current_value = current_temp elif self.vera_device.category == "Light Sensor": self.vera_device.refresh_value('CurrentLevel') self.current_value = self.vera_device.get_value('CurrentLevel') diff --git a/homeassistant/components/sensor/verisure.py b/homeassistant/components/sensor/verisure.py new file mode 100644 index 00000000000..61af1089775 --- /dev/null +++ b/homeassistant/components/sensor/verisure.py @@ -0,0 +1,127 @@ +""" +homeassistant.components.sensor.verisure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Interfaces with Verisure sensors. +""" +import logging + +import homeassistant.components.verisure as verisure + +from homeassistant.helpers.entity import Entity +from homeassistant.const import TEMP_CELCIUS + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Verisure platform. """ + + if not verisure.MY_PAGES: + _LOGGER.error('A connection has not been made to Verisure mypages.') + return False + + sensors = [] + + sensors.extend([ + VerisureThermometer(value) + for value in verisure.get_climate_status().values() + if verisure.SHOW_THERMOMETERS and + hasattr(value, 'temperature') and value.temperature + ]) + + sensors.extend([ + VerisureHygrometer(value) + for value in verisure.get_climate_status().values() + if verisure.SHOW_HYGROMETERS and + hasattr(value, 'humidity') and value.humidity + ]) + + sensors.extend([ + VerisureAlarm(value) + for value in verisure.get_alarm_status().values() + if verisure.SHOW_ALARM + ]) + + add_devices(sensors) + + +class VerisureThermometer(Entity): + """ represents a Verisure thermometer within home assistant. """ + + def __init__(self, climate_status): + self._id = climate_status.id + self._device = verisure.MY_PAGES.DEVICE_CLIMATE + + @property + def name(self): + """ Returns the name of the device. """ + return '{} {}'.format( + verisure.STATUS[self._device][self._id].location, + "Temperature") + + @property + def state(self): + """ Returns the state of the device. """ + # remove ° character + return verisure.STATUS[self._device][self._id].temperature[:-1] + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity """ + return TEMP_CELCIUS # can verisure report in fahrenheit? + + def update(self): + ''' update sensor ''' + verisure.update() + + +class VerisureHygrometer(Entity): + """ represents a Verisure hygrometer within home assistant. """ + + def __init__(self, climate_status): + self._id = climate_status.id + self._device = verisure.MY_PAGES.DEVICE_CLIMATE + + @property + def name(self): + """ Returns the name of the device. """ + return '{} {}'.format( + verisure.STATUS[self._device][self._id].location, + "Humidity") + + @property + def state(self): + """ Returns the state of the device. """ + # remove % character + return verisure.STATUS[self._device][self._id].humidity[:-1] + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity """ + return "%" + + def update(self): + ''' update sensor ''' + verisure.update() + + +class VerisureAlarm(Entity): + """ represents a Verisure alarm status within home assistant. """ + + def __init__(self, alarm_status): + self._id = alarm_status.id + self._device = verisure.MY_PAGES.DEVICE_ALARM + + @property + def name(self): + """ Returns the name of the device. """ + return 'Alarm {}'.format(self._id) + + @property + def state(self): + """ Returns the state of the device. """ + return verisure.STATUS[self._device][self._id].label + + def update(self): + ''' update sensor ''' + verisure.update() diff --git a/homeassistant/components/sensor/wink.py b/homeassistant/components/sensor/wink.py index ff61f02d041..39c8ce4671f 100644 --- a/homeassistant/components/sensor/wink.py +++ b/homeassistant/components/sensor/wink.py @@ -1,15 +1,22 @@ -""" Support for Wink sensors. """ +""" +homeassistant.components.sensor.wink +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Wink sensors. +""" import logging -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.wink.pywink as pywink - from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_ACCESS_TOKEN, STATE_OPEN, STATE_CLOSED +REQUIREMENTS = ['https://github.com/balloob/python-wink/archive/' + 'c2b700e8ca866159566ecf5e644d9c297f69f257.zip' + '#python-wink==0.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) @@ -25,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class WinkSensorDevice(Entity): - """ represents a wink sensor within home assistant. """ + """ Represents a wink sensor. """ def __init__(self, wink): self.wink = wink diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index d057635d9f6..b63e64156a3 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -1,7 +1,6 @@ """ homeassistant.components.sensor.zwave ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Interfaces with Z-Wave sensors. """ # pylint: disable=import-error diff --git a/homeassistant/components/simple_alarm.py b/homeassistant/components/simple_alarm.py index e4ae8e6e9e9..46cb52fa950 100644 --- a/homeassistant/components/simple_alarm.py +++ b/homeassistant/components/simple_alarm.py @@ -9,6 +9,7 @@ Provides a simple alarm feature: import logging import homeassistant.loader as loader +from homeassistant.helpers.event import track_state_change from homeassistant.const import STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME DOMAIN = "simple_alarm" @@ -20,7 +21,7 @@ DEPENDENCIES = ['group', 'device_tracker', 'light'] CONF_KNOWN_LIGHT = "known_light" # Attribute to tell which light has to flash whem an unknown person comes home -# If ommitted will flash all. +# If omitted will flash all. CONF_UNKNOWN_LIGHT = "unknown_light" # Services to test the alarms @@ -83,8 +84,8 @@ def setup(hass, config): if not device_tracker.is_on(hass): unknown_alarm() - hass.states.track_change( - light.ENTITY_ID_ALL_LIGHTS, + track_state_change( + hass, light.ENTITY_ID_ALL_LIGHTS, unknown_alarm_if_lights_on, STATE_OFF, STATE_ON) def ring_known_alarm(entity_id, old_state, new_state): @@ -93,8 +94,8 @@ def setup(hass, config): known_alarm() # Track home coming of each device - hass.states.track_change( - hass.states.entity_ids(device_tracker.DOMAIN), + track_state_change( + hass, hass.states.entity_ids(device_tracker.DOMAIN), ring_known_alarm, STATE_NOT_HOME, STATE_HOME) return True diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 52baf430579..802eddb4a3a 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -4,13 +4,13 @@ homeassistant.components.sun Provides functionality to keep track of the sun. - Event listener -------------- -The suns event listener will call the service -when the sun rises or sets with an offset. -The sun evnt need to have the type 'sun', which service to call, -which event (sunset or sunrise) and the offset. +The suns event listener will call the service when the sun rises or sets with +an offset. + +The sun event need to have the type 'sun', which service to call, which event +(sunset or sunrise) and the offset. { "type": "sun", @@ -18,20 +18,25 @@ which event (sunset or sunrise) and the offset. "event": "sunset", "offset": "-01:00:00" } - - """ import logging -from datetime import datetime, timedelta - -from homeassistant.util import str_to_datetime, datetime_to_str +from datetime import timedelta +import urllib +import homeassistant.util as util +import homeassistant.util.dt as dt_util +from homeassistant.helpers.event import ( + track_point_in_utc_time, track_point_in_time) +from homeassistant.helpers.entity import Entity from homeassistant.components.scheduler import ServiceEventListener DEPENDENCIES = [] +REQUIREMENTS = ['astral==0.8.1'] DOMAIN = "sun" ENTITY_ID = "sun.sun" +CONF_ELEVATION = 'elevation' + STATE_ABOVE_HORIZON = "above_horizon" STATE_BELOW_HORIZON = "below_horizon" @@ -49,13 +54,21 @@ def is_on(hass, entity_id=None): def next_setting(hass, entity_id=None): - """ Returns the datetime object representing the next sun setting. """ + """ Returns the local datetime object of the next sun setting. """ + utc_next = next_setting_utc(hass, entity_id) + + return dt_util.as_local(utc_next) if utc_next else None + + +def next_setting_utc(hass, entity_id=None): + """ Returns the UTC datetime object of the next sun setting. """ entity_id = entity_id or ENTITY_ID state = hass.states.get(ENTITY_ID) try: - return str_to_datetime(state.attributes[STATE_ATTR_NEXT_SETTING]) + return dt_util.str_to_datetime( + state.attributes[STATE_ATTR_NEXT_SETTING]) except (AttributeError, KeyError): # AttributeError if state is None # KeyError if STATE_ATTR_NEXT_SETTING does not exist @@ -63,13 +76,21 @@ def next_setting(hass, entity_id=None): def next_rising(hass, entity_id=None): - """ Returns the datetime object representing the next sun rising. """ + """ Returns the local datetime object of the next sun rising. """ + utc_next = next_rising_utc(hass, entity_id) + + return dt_util.as_local(utc_next) if utc_next else None + + +def next_rising_utc(hass, entity_id=None): + """ Returns the UTC datetime object of the next sun rising. """ entity_id = entity_id or ENTITY_ID state = hass.states.get(ENTITY_ID) try: - return str_to_datetime(state.attributes[STATE_ATTR_NEXT_RISING]) + return dt_util.str_to_datetime( + state.attributes[STATE_ATTR_NEXT_RISING]) except (AttributeError, KeyError): # AttributeError if state is None # KeyError if STATE_ATTR_NEXT_RISING does not exist @@ -78,80 +99,123 @@ def next_rising(hass, entity_id=None): def setup(hass, config): """ Tracks the state of the sun. """ - logger = logging.getLogger(__name__) - - try: - import ephem - except ImportError: - logger.exception("Error while importing dependency ephem.") + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False - sun = ephem.Sun() # pylint: disable=no-member - - latitude = str(hass.config.latitude) - longitude = str(hass.config.longitude) - - # Validate latitude and longitude - observer = ephem.Observer() - + latitude = util.convert(hass.config.latitude, float) + longitude = util.convert(hass.config.longitude, float) errors = [] - try: - observer.lat = latitude # pylint: disable=assigning-non-slot - except ValueError: - errors.append("invalid value for latitude given: {}".format(latitude)) + if latitude is None: + errors.append('Latitude needs to be a decimal value') + elif -90 > latitude < 90: + errors.append('Latitude needs to be -90 .. 90') - try: - observer.long = longitude # pylint: disable=assigning-non-slot - except ValueError: - errors.append("invalid value for latitude given: {}".format(latitude)) + if longitude is None: + errors.append('Longitude needs to be a decimal value') + elif -180 > longitude < 180: + errors.append('Longitude needs to be -180 .. 180') if errors: - logger.error("Error setting up: %s", ", ".join(errors)) + _LOGGER.error('Invalid configuration received: %s', ", ".join(errors)) return False - def update_sun_state(now): - """ Method to update the current state of the sun and - set time of next setting and rising. """ - utc_offset = datetime.utcnow() - datetime.now() - utc_now = now + utc_offset + platform_config = config.get(DOMAIN, {}) - observer = ephem.Observer() - observer.lat = latitude # pylint: disable=assigning-non-slot - observer.long = longitude # pylint: disable=assigning-non-slot + elevation = platform_config.get(CONF_ELEVATION) - next_rising_dt = ephem.localtime( - observer.next_rising(sun, start=utc_now)) - next_setting_dt = ephem.localtime( - observer.next_setting(sun, start=utc_now)) + from astral import Location, GoogleGeocoder - if next_rising_dt > next_setting_dt: - new_state = STATE_ABOVE_HORIZON - next_change = next_setting_dt + location = Location(('', '', latitude, longitude, hass.config.time_zone, + elevation or 0)) - else: - new_state = STATE_BELOW_HORIZON - next_change = next_rising_dt + if elevation is None: + google = GoogleGeocoder() + try: + google._get_elevation(location) # pylint: disable=protected-access + _LOGGER.info( + 'Retrieved elevation from Google: %s', location.elevation) + except urllib.error.URLError: + # If no internet connection available etc. + pass - logger.info("%s. Next change: %s", - new_state, next_change.strftime("%H:%M")) - - state_attributes = { - STATE_ATTR_NEXT_RISING: datetime_to_str(next_rising_dt), - STATE_ATTR_NEXT_SETTING: datetime_to_str(next_setting_dt) - } - - hass.states.set(ENTITY_ID, new_state, state_attributes) - - # +1 second so Ephem will report it has set - hass.track_point_in_time(update_sun_state, - next_change + timedelta(seconds=1)) - - update_sun_state(datetime.now()) + sun = Sun(hass, location) + sun.point_in_time_listener(dt_util.utcnow()) return True +class Sun(Entity): + """ Represents the Sun. """ + + entity_id = ENTITY_ID + + def __init__(self, hass, location): + self.hass = hass + self.location = location + self._state = self.next_rising = self.next_setting = None + + @property + def should_poll(self): + """ We trigger updates ourselves after sunset/sunrise """ + return False + + @property + def name(self): + return "Sun" + + @property + def state(self): + if self.next_rising > self.next_setting: + return STATE_ABOVE_HORIZON + + return STATE_BELOW_HORIZON + + @property + def state_attributes(self): + return { + STATE_ATTR_NEXT_RISING: dt_util.datetime_to_str(self.next_rising), + STATE_ATTR_NEXT_SETTING: dt_util.datetime_to_str(self.next_setting) + } + + @property + def next_change(self): + """ Returns the datetime when the next change to the state is. """ + return min(self.next_rising, self.next_setting) + + def update_as_of(self, utc_point_in_time): + """ Calculate sun state at a point in UTC time. """ + mod = -1 + while True: + next_rising_dt = self.location.sunrise( + utc_point_in_time + timedelta(days=mod), local=False) + if next_rising_dt > utc_point_in_time: + break + mod += 1 + + mod = -1 + while True: + next_setting_dt = (self.location.sunset( + utc_point_in_time + timedelta(days=mod), local=False)) + if next_setting_dt > utc_point_in_time: + break + mod += 1 + + self.next_rising = next_rising_dt + self.next_setting = next_setting_dt + + def point_in_time_listener(self, now): + """ Called when the state of the sun has changed. """ + self.update_as_of(now) + self.update_ha_state() + + # Schedule next update at next_change+1 second so sun state has changed + track_point_in_utc_time( + self.hass, self.point_in_time_listener, + self.next_change + timedelta(seconds=1)) + + def create_event_listener(schedule, event_listener_data): """ Create a sun event listener based on the description. """ @@ -195,22 +259,22 @@ class SunEventListener(ServiceEventListener): else: next_time = next_event + self.offset - while next_time < datetime.now() or \ + while next_time < dt_util.now() or \ next_time.weekday() not in self.my_schedule.days: next_time = next_time + timedelta(days=1) return next_time def schedule_next_event(self, hass, next_event): - """ Schedule the event """ + """ Schedule the event. """ next_time = self.__get_next_time(next_event) # pylint: disable=unused-argument def execute(now): - """ Call the execute method """ + """ Call the execute method. """ self.execute(hass) - hass.track_point_in_time(execute, next_time) + track_point_in_time(hass, execute, next_time) return next_time @@ -234,7 +298,7 @@ class SunriseEventListener(SunEventListener): """ This class is used the call a service when the sun rises. """ def schedule(self, hass): - """ Schedule the event """ + """ Schedule the event. """ next_rising_dt = next_rising(hass) next_time_dt = self.schedule_next_event(hass, next_rising_dt) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index f246692560d..b6dd31b48c2 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -7,10 +7,11 @@ import logging from datetime import timedelta from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import ToggleEntity from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID) -from homeassistant.components import group, discovery, wink +from homeassistant.components import group, discovery, wink, isy994, verisure DOMAIN = 'switch' DEPENDENCIES = [] @@ -23,13 +24,22 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' ATTR_TODAY_MWH = "today_mwh" ATTR_CURRENT_POWER_MWH = "current_power_mwh" +ATTR_SENSOR_STATE = "sensor_state" MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) # Maps discovered services to their platforms DISCOVERY_PLATFORMS = { - discovery.services.BELKIN_WEMO: 'wemo', + discovery.SERVICE_WEMO: 'wemo', wink.DISCOVER_SWITCHES: 'wink', + isy994.DISCOVER_SWITCHES: 'isy994', + verisure.DISCOVER_SWITCHES: 'verisure' +} + +PROP_TO_ATTR = { + 'current_power_mwh': ATTR_CURRENT_POWER_MWH, + 'today_power_mw': ATTR_TODAY_MWH, + 'sensor_state': ATTR_SENSOR_STATE } _LOGGER = logging.getLogger(__name__) @@ -38,21 +48,18 @@ _LOGGER = logging.getLogger(__name__) def is_on(hass, entity_id=None): """ Returns if the switch is on based on the statemachine. """ entity_id = entity_id or ENTITY_ID_ALL_SWITCHES - return hass.states.is_state(entity_id, STATE_ON) def turn_on(hass, entity_id=None): """ Turns all or specified switch on. """ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_TURN_ON, data) def turn_off(hass, entity_id=None): """ Turns all or specified switch off. """ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) @@ -73,10 +80,57 @@ def setup(hass, config): else: switch.turn_off() - switch.update_ha_state(True) + if switch.should_poll: + switch.update_ha_state(True) hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_switch_service) - hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_switch_service) return True + + +class SwitchDevice(ToggleEntity): + """ Represents a switch within Home Assistant. """ + # pylint: disable=no-self-use + + @property + def current_power_mwh(self): + """ Current power usage in mwh. """ + return None + + @property + def today_power_mw(self): + """ Today total power usage in mw. """ + return None + + @property + def is_standby(self): + """ Is the device in standby. """ + return None + + @property + def sensor_state(self): + """ Is the sensor on or off. """ + return None + + @property + def device_state_attributes(self): + """ Returns device specific state attributes. """ + return None + + @property + def state_attributes(self): + """ Returns optional state attributes. """ + data = {} + + for prop, attr in PROP_TO_ATTR.items(): + value = getattr(self, prop) + if value: + data[attr] = value + + device_attr = self.device_state_attributes + + if device_attr is not None: + data.update(device_attr) + + return data diff --git a/homeassistant/components/switch/arduino.py b/homeassistant/components/switch/arduino.py new file mode 100644 index 00000000000..bb962c08f72 --- /dev/null +++ b/homeassistant/components/switch/arduino.py @@ -0,0 +1,96 @@ +""" +homeassistant.components.switch.arduino +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for switching Arduino pins on and off. So fare only digital pins are +supported. + +Configuration: + +To use the arduino switch you will need to add something like the following +to your configuration.yaml file. + +switch: + platform: arduino + pins: + 11: + name: Fan Office + type: digital + 12: + name: Light Desk + type: digital + +Variables: + +pins +*Required +An array specifying the digital pins to use on the Arduino board. + +These are the variables for the pins array: + +name +*Required +The name for the pin that will be used in the frontend. + +type +*Required +The type of the pin: 'digital'. +""" +import logging + +import homeassistant.components.arduino as arduino +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import DEVICE_DEFAULT_NAME + +DEPENDENCIES = ['arduino'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Arduino platform. """ + + # Verify that Arduino board is present + if arduino.BOARD is None: + _LOGGER.error('A connection has not been made to the Arduino board.') + return False + + switches = [] + pins = config.get('pins') + for pinnum, pin in pins.items(): + if pin.get('name'): + switches.append(ArduinoSwitch(pin.get('name'), + pinnum, + pin.get('type'))) + add_devices(switches) + + +class ArduinoSwitch(SwitchDevice): + """ Represents an Arduino switch. """ + def __init__(self, name, pin, pin_type): + self._pin = pin + self._name = name or DEVICE_DEFAULT_NAME + self.pin_type = pin_type + self.direction = 'out' + self._state = False + + arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + + @property + def name(self): + """ Get the name of the pin. """ + return self._name + + @property + def is_on(self): + """ Returns True if pin is high/on. """ + return self._state + + def turn_on(self): + """ Turns the pin to high/on. """ + self._state = True + arduino.BOARD.set_digital_out_high(self._pin) + + def turn_off(self): + """ Turns the pin to low/off. """ + self._state = False + arduino.BOARD.set_digital_out_low(self._pin) diff --git a/homeassistant/components/switch/command_switch.py b/homeassistant/components/switch/command_switch.py new file mode 100644 index 00000000000..4d5aeba94f5 --- /dev/null +++ b/homeassistant/components/switch/command_switch.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" +homeassistant.components.switch.command_switch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Allows to configure custom shell commands to turn a switch on/off. +""" +import logging +from homeassistant.components.switch import SwitchDevice +import subprocess + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Find and return switches controlled by shell commands. """ + + switches = config.get('switches', {}) + devices = [] + + for dev_name, properties in switches.items(): + devices.append( + CommandSwitch( + dev_name, + properties.get('oncmd', 'true'), + properties.get('offcmd', 'true'))) + + add_devices_callback(devices) + + +class CommandSwitch(SwitchDevice): + """ Represents a switch that can be togggled using shell commands. """ + def __init__(self, name, command_on, command_off): + self._name = name + self._state = False + self._command_on = command_on + self._command_off = command_off + + @staticmethod + def _switch(command): + """ Execute the actual commands. """ + _LOGGER.info('Running command: %s', command) + + success = (subprocess.call(command, shell=True) == 0) + + if not success: + _LOGGER.error('Command failed: %s', command) + + return success + + @property + def should_poll(self): + """ No polling needed. """ + return False + + @property + def name(self): + """ The name of the switch. """ + 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. """ + if CommandSwitch._switch(self._command_on): + self._state = True + self.update_ha_state() + + def turn_off(self, **kwargs): + """ Turn the device off. """ + if CommandSwitch._switch(self._command_off): + self._state = False + self.update_ha_state() diff --git a/homeassistant/components/switch/demo.py b/homeassistant/components/switch/demo.py index 998597c3e8c..4ee1dc82413 100644 --- a/homeassistant/components/switch/demo.py +++ b/homeassistant/components/switch/demo.py @@ -1,18 +1,23 @@ -""" Demo platform that has two fake switchces. """ -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.const import STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME +""" +homeassistant.components.switch.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Demo platform that has two fake switches. +""" +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import DEVICE_DEFAULT_NAME # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return demo switches. """ add_devices_callback([ - DemoSwitch('Ceiling', STATE_ON), - DemoSwitch('AC', STATE_OFF) + DemoSwitch('Decorative Lights', True), + DemoSwitch('AC', False) ]) -class DemoSwitch(ToggleEntity): +class DemoSwitch(SwitchDevice): """ Provides a demo switch. """ def __init__(self, name, state): self._name = name or DEVICE_DEFAULT_NAME @@ -29,19 +34,27 @@ class DemoSwitch(ToggleEntity): return self._name @property - def state(self): - """ Returns the name of the device if any. """ - return self._state + def current_power_mwh(self): + """ Current power usage in mwh. """ + if self._state: + return 100 + + @property + def today_power_mw(self): + """ Today total power usage in mw. """ + return 1500 @property def is_on(self): """ True if device is on. """ - return self._state == STATE_ON + return self._state def turn_on(self, **kwargs): """ Turn the device on. """ - self._state = STATE_ON + self._state = True + self.update_ha_state() def turn_off(self, **kwargs): """ Turn the device off. """ - self._state = STATE_OFF + self._state = False + self.update_ha_state() diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py new file mode 100644 index 00000000000..2f38084ed9d --- /dev/null +++ b/homeassistant/components/switch/edimax.py @@ -0,0 +1,117 @@ +""" +homeassistant.components.switch.edimax +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Edimax switches. + +Configuration: + +To use the Edimax switch you will need to add something like the following to +your configuration.yaml file. + +switch: + platform: edimax + host: 192.168.1.32 + username: YOUR_USERNAME + password: YOUR_PASSWORD + name: Edimax Smart Plug + +Variables: + +host +*Required +This is the IP address of your Edimax switch. Example: 192.168.1.32 + +username +*Required +Your username to access your Edimax switch. + +password +*Required +Your password. + +name +*Optional +The name to use when displaying this switch instance. +""" +import logging + +from homeassistant.helpers import validate_config +from homeassistant.components.switch import SwitchDevice, DOMAIN +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD,\ + CONF_NAME + +# constants +DEFAULT_USERNAME = 'admin' +DEFAULT_PASSWORD = '1234' +DEVICE_DEFAULT_NAME = 'Edimax Smart Plug' +REQUIREMENTS = ['https://github.com/rkabadi/pyedimax/archive/' + '365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1'] + +# setup logger +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Find and return Edimax Smart Plugs. """ + try: + # pylint: disable=no-name-in-module, import-error + from pyedimax.smartplug import SmartPlug + except ImportError: + _LOGGER.error('Failed to import pyedimax') + return False + + # pylint: disable=global-statement + # check for required values in configuration file + if not validate_config({DOMAIN: config}, + {DOMAIN: [CONF_HOST]}, + _LOGGER): + return False + + host = config.get(CONF_HOST) + auth = (config.get(CONF_USERNAME, DEFAULT_USERNAME), + config.get(CONF_PASSWORD, DEFAULT_PASSWORD)) + name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + + add_devices_callback([SmartPlugSwitch(SmartPlug(host, auth), name)]) + + +class SmartPlugSwitch(SwitchDevice): + """ Represents an Edimax Smart Plug switch. """ + def __init__(self, smartplug, name): + self.smartplug = smartplug + self._name = name + + @property + def name(self): + """ Returns the name of the Smart Plug, if any. """ + return self._name + + @property + def current_power_mwh(self): + """ Current power usage in mWh. """ + try: + return float(self.smartplug.now_power) / 1000000.0 + except ValueError: + return None + + @property + def today_power_mw(self): + """ Today total power usage in mW. """ + try: + return float(self.smartplug.now_energy_day) / 1000.0 + except ValueError: + return None + + @property + def is_on(self): + """ True if switch is on. """ + return self.smartplug.state == 'ON' + + def turn_on(self, **kwargs): + """ Turns the switch on. """ + self.smartplug.state = 'ON' + + def turn_off(self): + """ Turns the switch off. """ + self.smartplug.state = 'OFF' diff --git a/homeassistant/components/switch/hikvisioncam.py b/homeassistant/components/switch/hikvisioncam.py new file mode 100644 index 00000000000..6ab82df482a --- /dev/null +++ b/homeassistant/components/switch/hikvisioncam.py @@ -0,0 +1,135 @@ +""" +homeassistant.components.switch.hikvision +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support turning on/off motion detection on Hikvision cameras. + +Note: Currently works using default https port only. + +CGI API Guide: http://bit.ly/1RuyUuF + +Configuration: + +To use the Hikvision motion detection switch you will need to add something +like the following to your config/configuration.yaml + +switch: + platform: hikvisioncam + name: Hikvision Cam 1 Motion Detection + host: 192.168.1.32 + username: YOUR_USERNAME + password: YOUR_PASSWORD + +Variables: + +host +*Required +This is the IP address of your Hikvision camera. Example: 192.168.1.32 + +username +*Required +Your Hikvision camera username. + +password +*Required +Your Hikvision camera username. + +name +*Optional +The name to use when displaying this switch instance. +""" +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +import logging + +try: + import hikvision.api + from hikvision.error import HikvisionError, MissingParamError +except ImportError: + hikvision.api = None + +_LOGGING = logging.getLogger(__name__) +REQUIREMENTS = ['hikvision==0.4'] +# pylint: disable=too-many-arguments +# pylint: disable=too-many-instance-attributes + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Setup Hikvision Camera config. """ + + host = config.get(CONF_HOST, None) + port = config.get('port', "80") + name = config.get('name', "Hikvision Camera Motion Detection") + username = config.get(CONF_USERNAME, "admin") + password = config.get(CONF_PASSWORD, "12345") + + if hikvision.api is None: + _LOGGING.error(( + "Failed to import hikvision. Did you maybe not install the " + "'hikvision' dependency?")) + + return False + + try: + hikvision_cam = hikvision.api.CreateDevice( + host, port=port, username=username, + password=password, is_https=False) + except MissingParamError as param_err: + _LOGGING.error("Missing required param: %s", param_err) + return False + except HikvisionError as conn_err: + _LOGGING.error("Unable to connect: %s", conn_err) + return False + + add_devices_callback([ + HikvisionMotionSwitch(name, hikvision_cam) + ]) + + +class HikvisionMotionSwitch(ToggleEntity): + + """ Provides a switch to toggle on/off motion detection. """ + + def __init__(self, name, hikvision_cam): + self._name = name + self._hikvision_cam = hikvision_cam + self._state = STATE_OFF + + @property + def should_poll(self): + """ Poll for status regularly. """ + return True + + @property + def name(self): + """ Returns the name of the device if any. """ + return self._name + + @property + def state(self): + """ Returns the state of the device if any. """ + return self._state + + @property + def is_on(self): + """ True if device is on. """ + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """ Turn the device on. """ + + _LOGGING.info("Turning on Motion Detection ") + self._hikvision_cam.enable_motion_detection() + + def turn_off(self, **kwargs): + """ Turn the device off. """ + + _LOGGING.info("Turning off Motion Detection ") + self._hikvision_cam.disable_motion_detection() + + def update(self): + """ Update Motion Detection state """ + enabled = self._hikvision_cam.is_motion_detection_enabled() + _LOGGING.info('enabled: %s', enabled) + + self._state = STATE_ON if enabled else STATE_OFF diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py new file mode 100644 index 00000000000..75032d2954d --- /dev/null +++ b/homeassistant/components/switch/isy994.py @@ -0,0 +1,85 @@ +""" +homeassistant.components.switch.isy994 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Support for ISY994 switches. +""" +import logging + +from homeassistant.components.isy994 import (ISY, ISYDeviceABC, SENSOR_STRING, + HIDDEN_STRING) +from homeassistant.const import STATE_ON, STATE_OFF # STATE_OPEN, STATE_CLOSED +# The frontend doesn't seem to fully support the open and closed states yet. +# Once it does, the HA.doors programs should report open and closed instead of +# off and on. It appears that on should be open and off should be closed. + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the ISY994 platform. """ + # pylint: disable=too-many-locals + logger = logging.getLogger(__name__) + devs = [] + # verify connection + if ISY is None or not ISY.connected: + logger.error('A connection has not been made to the ISY controller.') + return False + + # import not dimmable nodes and groups + for (path, node) in ISY.nodes: + if not node.dimmable and SENSOR_STRING not in node.name: + if HIDDEN_STRING in path: + node.name += HIDDEN_STRING + devs.append(ISYSwitchDevice(node)) + + # import ISY doors programs + for folder_name, states in (('HA.doors', [STATE_ON, STATE_OFF]), + ('HA.switches', [STATE_ON, STATE_OFF])): + try: + folder = ISY.programs['My Programs'][folder_name] + except KeyError: + # HA.doors folder does not exist + pass + else: + for dtype, name, node_id in folder.children: + if dtype is 'folder': + custom_switch = folder[node_id] + try: + actions = custom_switch['actions'].leaf + assert actions.dtype == 'program', 'Not a program' + node = custom_switch['status'].leaf + except (KeyError, AssertionError): + pass + else: + devs.append(ISYProgramDevice(name, node, actions, + states)) + + add_devices(devs) + + +class ISYSwitchDevice(ISYDeviceABC): + """ Represents as ISY light. """ + + _domain = 'switch' + _dtype = 'binary' + _states = [STATE_ON, STATE_OFF] + + +class ISYProgramDevice(ISYSwitchDevice): + """ Represents a door that can be manipulated. """ + + _domain = 'switch' + _dtype = 'binary' + + def __init__(self, name, node, actions, states): + super().__init__(node) + self._states = states + self._name = name + self.action_node = actions + + def turn_on(self, **kwargs): + """ Turns the device on/closes the device. """ + self.action_node.runThen() + + def turn_off(self, **kwargs): + """ Turns the device off/opens the device. """ + self.action_node.runElse() diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py new file mode 100644 index 00000000000..5a00b9cb174 --- /dev/null +++ b/homeassistant/components/switch/modbus.py @@ -0,0 +1,153 @@ +""" +homeassistant.components.switch.modbus +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Modbus switches. + +Configuration: + +To use the Modbus switches you will need to add something like the following to +your configuration.yaml file. + +sensor: + platform: modbus + slave: 1 + registers: + 24: + bits: + 0: + name: My switch + 2: + name: My other switch + coils: + 0: + name: My coil switch + +VARIABLES: + + - "slave" = slave number (ignored and can be omitted if not serial Modbus) + - "registers" contains a list of relevant registers to read from + - it must contain a "bits" section, listing relevant bits + - "coils" contains a list of relevant coils to read from/write to + + - each named bit will create a switch +""" + +import logging + +import homeassistant.components.modbus as modbus +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['modbus'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Read configuration and create Modbus devices. """ + switches = [] + slave = config.get("slave", None) + if modbus.TYPE == "serial" and not slave: + _LOGGER.error("No slave number provided for serial Modbus") + return False + registers = config.get("registers") + if registers: + for regnum, register in registers.items(): + bits = register.get("bits") + for bitnum, bit in bits.items(): + if bit.get("name"): + switches.append(ModbusSwitch(bit.get("name"), + slave, + regnum, + bitnum)) + coils = config.get("coils") + if coils: + for coilnum, coil in coils.items(): + switches.append(ModbusSwitch(coil.get("name"), + slave, + coilnum, + 0, + coil=True)) + add_devices(switches) + + +class ModbusSwitch(ToggleEntity): + # pylint: disable=too-many-arguments + """ Represents a Modbus switch. """ + + def __init__(self, name, slave, register, bit, coil=False): + self._name = name + self.slave = int(slave) if slave else 1 + self.register = int(register) + self.bit = int(bit) + self._coil = coil + self._is_on = None + self.register_value = None + + def __str__(self): + return "%s: %s" % (self.name, self.state) + + @property + def should_poll(self): + """ + We should poll, because slaves are not allowed to initiate + communication on Modbus networks. + """ + return True + + @property + def unique_id(self): + """ Returns a unique id. """ + return "MODBUS-SWITCH-{}-{}-{}".format(self.slave, + self.register, + self.bit) + + @property + def is_on(self): + """ Returns True if switch is on. """ + return self._is_on + + @property + def name(self): + """ Get the name of the switch. """ + return self._name + + def turn_on(self, **kwargs): + """ Set switch on. """ + if self.register_value is None: + self.update() + + if self._coil: + modbus.NETWORK.write_coil(self.register, True) + else: + val = self.register_value | (0x0001 << self.bit) + modbus.NETWORK.write_register(unit=self.slave, + address=self.register, + value=val) + + def turn_off(self, **kwargs): + """ Set switch off. """ + if self.register_value is None: + self.update() + + if self._coil: + modbus.NETWORK.write_coil(self.register, False) + else: + val = self.register_value & ~(0x0001 << self.bit) + modbus.NETWORK.write_register(unit=self.slave, + address=self.register, + value=val) + + def update(self): + """ Update the state of the switch. """ + if self._coil: + result = modbus.NETWORK.read_coils(self.register, 1) + self.register_value = result.bits[0] + self._is_on = self.register_value + else: + result = modbus.NETWORK.read_holding_registers( + unit=self.slave, address=self.register, + count=1) + val = 0 + for i, res in enumerate(result.registers): + val += res * (2**(i*16)) + self.register_value = val + self._is_on = (val & (0x0001 << self.bit) > 0) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py new file mode 100644 index 00000000000..73618bd9277 --- /dev/null +++ b/homeassistant/components/switch/mqtt.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +""" +homeassistant.components.switch.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to configure a MQTT switch. + +In an ideal scenario, the MQTT device will have a state topic to publish +state changes. If these messages are published with RETAIN flag, the MQTT +switch will receive an instant state update after subscription and will +start with correct state. Otherwise, the initial state of the switch will +be false/off. + +When a state topic is not available, the switch will work in optimistic mode. +In this mode, the switch will immediately change state after every command. +Otherwise, the switch will wait for state confirmation from device +(message from state_topic). + +Optimistic mode can be forced, even if state topic is available. +Try to enable it, if experiencing incorrect switch operation. + + +Configuration: + +switch: + platform: mqtt + name: "Bedroom Switch" + state_topic: "home/bedroom/switch1" + command_topic: "home/bedroom/switch1/set" + qos: 0 + payload_on: "ON" + payload_off: "OFF" + optimistic: false + +Variables: + +name +*Optional +The name of the switch. Default is 'MQTT Switch'. + +state_topic +*Optional +The MQTT topic subscribed to receive state updates. +If not specified, optimistic mode will be forced. + +command_topic +*Required +The MQTT topic to publish commands to change the switch state. + +qos +*Optional +The maximum QoS level of the state topic. Default is 0. +This QoS will also be used to publishing messages. + +payload_on +*Optional +The payload that represents enabled state. Default is "ON". + +payload_off +*Optional +The payload that represents disabled state. Default is "OFF". + +optimistic +*Optional +Flag that defines if switch works in optimistic mode. Default is false. + +""" + +import logging +import homeassistant.components.mqtt as mqtt +from homeassistant.components.switch import SwitchDevice + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Switch" +DEFAULT_QOS = 0 +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_OPTIMISTIC = False + +DEPENDENCIES = ['mqtt'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Add MQTT Switch """ + + if config.get('command_topic') is None: + _LOGGER.error("Missing required variable: command_topic") + return False + + add_devices_callback([MqttSwitch( + hass, + config.get('name', DEFAULT_NAME), + config.get('state_topic'), + config.get('command_topic'), + config.get('qos', DEFAULT_QOS), + config.get('payload_on', DEFAULT_PAYLOAD_ON), + config.get('payload_off', DEFAULT_PAYLOAD_OFF), + config.get('optimistic', DEFAULT_OPTIMISTIC))]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MqttSwitch(SwitchDevice): + """ Represents a switch that can be togggled using MQTT """ + def __init__(self, hass, name, state_topic, command_topic, qos, + payload_on, payload_off, optimistic): + self._state = False + self._hass = hass + self._name = name + self._state_topic = state_topic + self._command_topic = command_topic + self._qos = qos + self._payload_on = payload_on + self._payload_off = payload_off + self._optimistic = optimistic + + def message_received(topic, payload, qos): + """ A new MQTT message has been received. """ + if payload == self._payload_on: + self._state = True + self.update_ha_state() + elif payload == self._payload_off: + self._state = False + self.update_ha_state() + + if self._state_topic is None: + # force optimistic mode + self._optimistic = True + else: + # subscribe the state_topic + 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 switch """ + 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. """ + mqtt.publish(self.hass, self._command_topic, self._payload_on, + self._qos) + if self._optimistic: + # optimistically assume that switch has changed state + self._state = True + self.update_ha_state() + + def turn_off(self, **kwargs): + """ Turn the device off. """ + mqtt.publish(self.hass, self._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/switch/rpi_gpio.py b/homeassistant/components/switch/rpi_gpio.py new file mode 100644 index 00000000000..08c7ff35255 --- /dev/null +++ b/homeassistant/components/switch/rpi_gpio.py @@ -0,0 +1,138 @@ +""" +homeassistant.components.switch.rpi_gpio +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to control the GPIO pins of a Raspberry Pi. + +Note: To use RPi GPIO, Home Assistant must be run as root. + +Configuration: + +To use the Raspberry GPIO switches you will need to add something like the +following to your configuration.yaml file. + +switch: + platform: rpi_gpio + invert_logic: false + ports: + 11: Fan Office + 12: Light Desk + +Variables: + +invert_logic +*Optional +If true, inverts the output logic to ACTIVE LOW. Default is false (ACTIVE HIGH) + +ports +*Required +An array specifying the GPIO ports to use and the name to use in the frontend. +""" + +import logging +try: + import RPi.GPIO as GPIO +except ImportError: + GPIO = None +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import (DEVICE_DEFAULT_NAME, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) + +DEFAULT_INVERT_LOGIC = False + +REQUIREMENTS = ['RPi.GPIO==0.5.11'] +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Raspberry PI GPIO ports. """ + if GPIO is None: + _LOGGER.error('RPi.GPIO not available. rpi_gpio ports ignored.') + return + # pylint: disable=no-member + GPIO.setmode(GPIO.BCM) + + switches = [] + invert_logic = config.get('invert_logic', DEFAULT_INVERT_LOGIC) + ports = config.get('ports') + for port_num, port_name in ports.items(): + switches.append(RPiGPIOSwitch(port_name, port_num, invert_logic)) + add_devices(switches) + + def cleanup_gpio(event): + """ Stuff to do before stop home assistant. """ + # pylint: disable=no-member + GPIO.cleanup() + + def prepare_gpio(event): + """ Stuff to do when home assistant starts. """ + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio) + + +class RPiGPIOSwitch(ToggleEntity): + """ Represents a port that can be toggled using Raspberry Pi GPIO. """ + + def __init__(self, name, gpio, invert_logic): + self._name = name or DEVICE_DEFAULT_NAME + self._gpio = gpio + self._active_state = not invert_logic + self._state = not self._active_state + # pylint: disable=no-member + GPIO.setup(gpio, GPIO.OUT) + + @property + def name(self): + """ The name of the port. """ + return self._name + + @property + def should_poll(self): + """ No polling needed. """ + return False + + @property + def is_on(self): + """ True if device is on. """ + return self._state + + def turn_on(self, **kwargs): + """ Turn the device on. """ + if self._switch(self._active_state): + self._state = True + self.update_ha_state() + + def turn_off(self, **kwargs): + """ Turn the device off. """ + if self._switch(not self._active_state): + self._state = False + self.update_ha_state() + + def _switch(self, new_state): + """ Change the output value to Raspberry Pi GPIO port. """ + _LOGGER.info('Setting GPIO %s to %s', self._gpio, new_state) + # pylint: disable=bare-except + try: + # pylint: disable=no-member + GPIO.output(self._gpio, 1 if new_state else 0) + except: + _LOGGER.error('GPIO "%s" output failed', self._gpio) + return False + return True + + # pylint: disable=no-self-use + @property + def device_state_attributes(self): + """ Returns device specific state attributes. """ + return None + + @property + def state_attributes(self): + """ Returns optional state attributes. """ + data = {} + device_attr = self.device_state_attributes + if device_attr is not None: + data.update(device_attr) + return data diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py index fccd2ba5c08..ae064d4fdb8 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -1,4 +1,14 @@ -""" Support for Tellstick switches. """ +""" +homeassistant.components.switch.tellstick +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Tellstick switches. + +Because the tellstick sends its actions via radio and from most +receivers it's impossible to know if the signal was received or not. +Therefore you can configure the switch to try to send each signal repeatedly +with the config parameter signal_repetitions (default is 1). +signal_repetitions: 3 +""" import logging @@ -6,10 +16,14 @@ from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.helpers.entity import ToggleEntity import tellcore.constants as tellcore_constants +SINGAL_REPETITIONS = 1 + +REQUIREMENTS = ['tellcore-py==1.0.4'] + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """ Find and return tellstick switches. """ + """ Find and return Tellstick switches. """ try: import tellcore.telldus as telldus except ImportError: @@ -17,6 +31,8 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): "Failed to import tellcore") return + signal_repetitions = config.get('signal_repetitions', SINGAL_REPETITIONS) + core = telldus.TelldusCore() switches_and_lights = core.devices() @@ -24,19 +40,20 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): for switch in switches_and_lights: if not switch.methods(tellcore_constants.TELLSTICK_DIM): - switches.append(TellstickSwitchDevice(switch)) + switches.append(TellstickSwitchDevice(switch, signal_repetitions)) add_devices_callback(switches) class TellstickSwitchDevice(ToggleEntity): - """ represents a Tellstick switch within home assistant. """ + """ Represents a Tellstick switch. """ last_sent_command_mask = (tellcore_constants.TELLSTICK_TURNON | tellcore_constants.TELLSTICK_TURNOFF) - def __init__(self, tellstick): + def __init__(self, tellstick, signal_repetitions): self.tellstick = tellstick self.state_attr = {ATTR_FRIENDLY_NAME: tellstick.name} + self.signal_repetitions = signal_repetitions @property def name(self): @@ -58,8 +75,10 @@ class TellstickSwitchDevice(ToggleEntity): def turn_on(self, **kwargs): """ Turns the switch on. """ - self.tellstick.turn_on() + for _ in range(self.signal_repetitions): + self.tellstick.turn_on() def turn_off(self, **kwargs): """ Turns the switch off. """ - self.tellstick.turn_off() + for _ in range(self.signal_repetitions): + self.tellstick.turn_off() diff --git a/homeassistant/components/switch/transmission.py b/homeassistant/components/switch/transmission.py new file mode 100644 index 00000000000..8288ed8456b --- /dev/null +++ b/homeassistant/components/switch/transmission.py @@ -0,0 +1,129 @@ +""" +homeassistant.components.switch.transmission +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Enable or disable Transmission BitTorrent client Turtle Mode. + +Configuration: + +To use the Transmission switch you will need to add something like the +following to your configuration.yaml file. + +switch: + platform: transmission + name: Transmission + host: 192.168.1.26 + port: 9091 + username: YOUR_USERNAME + password: YOUR_PASSWORD + +Variables: + +host +*Required +This is the IP address of your Transmission daemon. Example: 192.168.1.32 + +port +*Optional +The port your Transmission daemon uses, defaults to 9091. Example: 8080 + +username +*Optional +Your Transmission username, if you use authentication. + +password +*Optional +Your Transmission username, if you use authentication. + +name +*Optional +The name to use when displaying this Transmission instance. +""" +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +from homeassistant.const import STATE_ON, STATE_OFF + +from homeassistant.helpers.entity import ToggleEntity +# pylint: disable=no-name-in-module, import-error +import transmissionrpc +from transmissionrpc.error import TransmissionError +import logging + +_LOGGING = logging.getLogger(__name__) +REQUIREMENTS = ['transmissionrpc==0.11'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Sets up the transmission sensor. """ + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME, None) + password = config.get(CONF_PASSWORD, None) + port = config.get('port', 9091) + + name = config.get("name", "Transmission Turtle Mode") + if not host: + _LOGGING.error('Missing config variable %s', CONF_HOST) + return False + + # import logging + # logging.getLogger('transmissionrpc').setLevel(logging.DEBUG) + + transmission_api = transmissionrpc.Client( + host, port=port, user=username, password=password) + try: + transmission_api.session_stats() + except TransmissionError: + _LOGGING.exception("Connection to Transmission API failed.") + return False + + add_devices_callback([ + TransmissionSwitch(transmission_api, name) + ]) + + +class TransmissionSwitch(ToggleEntity): + """ A Transmission sensor. """ + + def __init__(self, transmission_client, name): + self._name = name + self.transmission_client = transmission_client + self._state = STATE_OFF + + @property + def name(self): + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def should_poll(self): + """ Poll for status regularly. """ + return True + + @property + def is_on(self): + """ True if device is on. """ + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """ Turn the device on. """ + + _LOGGING.info("Turning on Turtle Mode") + self.transmission_client.set_session( + alt_speed_enabled=True) + + def turn_off(self, **kwargs): + """ Turn the device off. """ + + _LOGGING.info("Turning off Turtle Mode ") + self.transmission_client.set_session( + alt_speed_enabled=False) + + def update(self): + """ Gets the latest data from Transmission and updates the state. """ + + active = self.transmission_client.get_session( + ).alt_speed_enabled + self._state = STATE_ON if active else STATE_OFF diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py index 7a9f8f96b79..bb7f43522f4 100644 --- a/homeassistant/components/switch/vera.py +++ b/homeassistant/components/switch/vera.py @@ -1,9 +1,11 @@ """ +homeassistant.components.switch.vera +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Support for Vera switches. Configuration: To use the Vera lights you will need to add something like the following to -your config/configuration.yaml +your configuration.yaml file. switch: platform: vera @@ -15,48 +17,45 @@ switch: 13: name: Another Switch -VARIABLES: +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/ - +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 - +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 - +will be used. exclude *Optional This parameter allows you to exclude the specified device from homeassistant, -it should be set to "true" if you want this device excluded - +it should be set to "true" if you want this device excluded. """ import logging import time from requests.exceptions import RequestException +import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import ToggleEntity from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TRIPPED, ATTR_ARMED, ATTR_LAST_TRIP_TIME) -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.vera.vera as veraApi + +REQUIREMENTS = ['https://github.com/balloob/home-assistant-vera-api/archive/' + 'a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip' + '#python-vera==0.1'] _LOGGER = logging.getLogger(__name__) @@ -64,6 +63,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument def get_devices(hass, config): """ Find and return Vera switches. """ + import pyvera as veraApi base_url = config.get('vera_controller_url') if not base_url: @@ -78,9 +78,10 @@ def get_devices(hass, config): vera_controller = veraApi.VeraController(base_url) devices = [] try: - devices = vera_controller.get_devices(['Switch', 'Armable Sensor']) + devices = vera_controller.get_devices([ + 'Switch', 'Armable Sensor', 'On/Off Switch']) except RequestException: - # There was a network related error connecting to the vera controller + # There was a network related error connecting to the vera controller. _LOGGER.exception("Error communicating with Vera API") return False @@ -101,7 +102,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class VeraSwitch(ToggleEntity): - """ Represents a Vera Switch """ + """ Represents a Vera Switch. """ def __init__(self, vera_device, extra_data=None): self.vera_device = vera_device @@ -132,11 +133,12 @@ class VeraSwitch(ToggleEntity): if self.vera_device.is_trippable: last_tripped = self.vera_device.refresh_value('LastTrip') - trip_time_str = time.strftime( - "%Y-%m-%d %H:%M", - time.localtime(int(last_tripped)) - ) - attr[ATTR_LAST_TRIP_TIME] = trip_time_str + if last_tripped is not None: + utc_time = dt_util.utc_from_timestamp(int(last_tripped)) + attr[ATTR_LAST_TRIP_TIME] = dt_util.datetime_to_str( + utc_time) + else: + attr[ATTR_LAST_TRIP_TIME] = None tripped = self.vera_device.refresh_value('Tripped') attr[ATTR_TRIPPED] = 'True' if tripped == '1' else 'False' diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py new file mode 100644 index 00000000000..7840defdfc4 --- /dev/null +++ b/homeassistant/components/switch/verisure.py @@ -0,0 +1,63 @@ +""" +homeassistant.components.switch.verisure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for Verisure Smartplugs. +""" +import logging + +import homeassistant.components.verisure as verisure +from homeassistant.components.switch import SwitchDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Verisure platform. """ + + if not verisure.MY_PAGES: + _LOGGER.error('A connection has not been made to Verisure mypages.') + return False + + switches = [] + + switches.extend([ + VerisureSmartplug(value) + for value in verisure.get_smartplug_status().values() + if verisure.SHOW_SMARTPLUGS + ]) + + add_devices(switches) + + +class VerisureSmartplug(SwitchDevice): + """ Represents a Verisure smartplug. """ + def __init__(self, smartplug_status): + self._id = smartplug_status.id + self.status_on = verisure.MY_PAGES.SMARTPLUG_ON + self.status_off = verisure.MY_PAGES.SMARTPLUG_OFF + + @property + def name(self): + """ Get the name (location) of the smartplug. """ + return verisure.get_smartplug_status()[self._id].location + + @property + def is_on(self): + """ Returns True if on """ + plug_status = verisure.get_smartplug_status()[self._id].status + return plug_status == self.status_on + + def turn_on(self): + """ Set smartplug status on. """ + verisure.MY_PAGES.set_smartplug_status( + self._id, + self.status_on) + + def turn_off(self): + """ Set smartplug status off. """ + verisure.MY_PAGES.set_smartplug_status( + self._id, + self.status_off) + + def update(self): + verisure.update() diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 2baf10f53d8..1a78a7d6725 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -1,28 +1,25 @@ -""" Support for WeMo switchces. """ +""" +homeassistant.components.switch.wemo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Support for WeMo switches. +""" import logging -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.components.switch import ( - ATTR_TODAY_MWH, ATTR_CURRENT_POWER_MWH) +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import STATE_ON, STATE_OFF, STATE_STANDBY + +REQUIREMENTS = ['pywemo==0.3'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """ Find and return wemo switches. """ - try: - # pylint: disable=no-name-in-module, import-error - import homeassistant.external.pywemo.pywemo as pywemo - import homeassistant.external.pywemo.pywemo.discovery as discovery - except ImportError: - logging.getLogger(__name__).exception(( - "Failed to import pywemo. " - "Did you maybe not run `git submodule init` " - "and `git submodule update`?")) - - return + """ Find and return WeMo switches. """ + import pywemo + import pywemo.discovery as discovery if discovery_info is not None: - device = discovery.device_from_description(discovery_info) + device = discovery.device_from_description(discovery_info[2]) if device: add_devices_callback([WemoSwitch(device)]) @@ -38,10 +35,12 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if isinstance(switch, pywemo.Switch)]) -class WemoSwitch(ToggleEntity): - """ represents a WeMo switch within home assistant. """ +class WemoSwitch(SwitchDevice): + """ Represents a WeMo switch. """ def __init__(self, wemo): self.wemo = wemo + self.insight_params = None + self.maker_params = None @property def unique_id(self): @@ -54,15 +53,60 @@ class WemoSwitch(ToggleEntity): return self.wemo.name @property - def state_attributes(self): - """ Returns optional state attributes. """ - if self.wemo.model.startswith('Belkin Insight'): - cur_info = self.wemo.insight_params + def state(self): + """ Returns the state. """ + is_on = self.is_on + if not is_on: + return STATE_OFF + elif self.is_standby: + return STATE_STANDBY + return STATE_ON - return { - ATTR_CURRENT_POWER_MWH: cur_info['currentpower'], - ATTR_TODAY_MWH: cur_info['todaymw'] - } + @property + def current_power_mwh(self): + """ Current power usage in mwh. """ + if self.insight_params: + return self.insight_params['currentpower'] + + @property + def today_power_mw(self): + """ Today total power usage in mw. """ + if self.insight_params: + return self.insight_params['todaymw'] + + @property + def is_standby(self): + """ Is the device on - or in standby. """ + if self.insight_params: + standby_state = self.insight_params['state'] + # Standby is actually '8' but seems more defensive + # to check for the On and Off states + if standby_state == '1' or standby_state == '0': + return False + else: + return True + + @property + def sensor_state(self): + """ Is the sensor on or off. """ + if self.maker_params and self.has_sensor: + # Note a state of 1 matches the WeMo app 'not triggered'! + if self.maker_params['sensorstate']: + return STATE_OFF + else: + return STATE_ON + + @property + def switch_mode(self): + """ Is the switch configured as toggle(0) or momentary (1). """ + if self.maker_params: + return self.maker_params['switchmode'] + + @property + def has_sensor(self): + """ Is the sensor present? """ + if self.maker_params: + return self.maker_params['hassensor'] @property def is_on(self): @@ -78,5 +122,10 @@ class WemoSwitch(ToggleEntity): self.wemo.off() def update(self): - """ Update Wemo state. """ + """ Update WeMo state. """ self.wemo.get_state(True) + if self.wemo.model_name == 'Insight': + self.insight_params = self.wemo.insight_params + self.insight_params['standby_state'] = self.wemo.get_standby_state + elif self.wemo.model_name == 'Maker': + self.maker_params = self.wemo.maker_params diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index f3d38d87c29..da708d2a1f7 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -1,15 +1,23 @@ -""" Support for WeMo switchces. """ -import logging +""" +homeassistant.components.switch.wink +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.wink.pywink as pywink +Support for Wink switches. +""" +import logging 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'] + 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) diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index 08940b977c9..bbc0979e38c 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -10,8 +10,9 @@ from homeassistant.helpers.entity_component import EntityComponent import homeassistant.util as util from homeassistant.helpers.entity import Entity +from homeassistant.helpers.temperature import convert from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF) + ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, TEMP_CELCIUS) DOMAIN = "thermostat" DEPENDENCIES = [] @@ -24,6 +25,8 @@ SERVICE_SET_TEMPERATURE = "set_temperature" ATTR_CURRENT_TEMPERATURE = "current_temperature" ATTR_AWAY_MODE = "away_mode" +ATTR_MAX_TEMP = "max_temp" +ATTR_MIN_TEMP = "min_temp" _LOGGER = logging.getLogger(__name__) @@ -84,7 +87,9 @@ def setup(hass, config): return for thermostat in target_thermostats: - thermostat.set_temperature(temperature) + thermostat.set_temperature(convert( + temperature, hass.config.temperature_unit, + thermostat.unit_of_measurement)) for thermostat in target_thermostats: thermostat.update_ha_state(True) @@ -116,9 +121,20 @@ class ThermostatDevice(Entity): @property def state_attributes(self): """ Returns optional state attributes. """ + + thermostat_unit = self.unit_of_measurement + user_unit = self.hass.config.temperature_unit + data = { - ATTR_CURRENT_TEMPERATURE: self.hass.config.temperature( - self.current_temperature, self.unit_of_measurement)[0] + ATTR_CURRENT_TEMPERATURE: round(convert(self.current_temperature, + thermostat_unit, + user_unit), 1), + ATTR_MIN_TEMP: round(convert(self.min_temp, + thermostat_unit, + user_unit), 0), + ATTR_MAX_TEMP: round(convert(self.max_temp, + thermostat_unit, + user_unit), 0) } is_away = self.is_away_mode_on @@ -133,6 +149,11 @@ class ThermostatDevice(Entity): return data + @property + def unit_of_measurement(self): + """ Unit of measurement this thermostat expresses itself in. """ + return NotImplementedError + @property def current_temperature(self): """ Returns the current temperature. """ @@ -162,3 +183,13 @@ class ThermostatDevice(Entity): def turn_away_mode_off(self): """ Turns away mode off. """ pass + + @property + def min_temp(self): + """ Return minimum temperature. """ + return convert(7, TEMP_CELCIUS, self.unit_of_measurement) + + @property + def max_temp(self): + """ Return maxmum temperature. """ + return convert(35, TEMP_CELCIUS, self.unit_of_measurement) diff --git a/homeassistant/components/thermostat/demo.py b/homeassistant/components/thermostat/demo.py index 5f0898c3086..9ad9e8995cd 100644 --- a/homeassistant/components/thermostat/demo.py +++ b/homeassistant/components/thermostat/demo.py @@ -1,7 +1,9 @@ """ +homeassistant.components.thermostat.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Demo platform that offers a fake thermostat. """ - from homeassistant.components.thermostat import ThermostatDevice from homeassistant.const import TEMP_CELCIUS, TEMP_FAHRENHEIT @@ -16,7 +18,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # pylint: disable=too-many-arguments class DemoThermostat(ThermostatDevice): - """ Represents a HeatControl within Home Assistant. """ + """ Represents a HeatControl thermostat. """ def __init__(self, name, target_temperature, unit_of_measurement, away, current_temperature): @@ -57,7 +59,7 @@ class DemoThermostat(ThermostatDevice): return self._away def set_temperature(self, temperature): - """ Set new target temperature """ + """ Set new target temperature. """ self._target_temperature = temperature def turn_away_mode_on(self): diff --git a/homeassistant/components/thermostat/heat_control.py b/homeassistant/components/thermostat/heat_control.py index d21245dae3a..f77d4285544 100644 --- a/homeassistant/components/thermostat/heat_control.py +++ b/homeassistant/components/thermostat/heat_control.py @@ -1,4 +1,6 @@ """ +homeassistant.components.thermostat.heat_control +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Adds support for a thermostat. Specify a start time, end time and a target temperature. @@ -12,8 +14,8 @@ temperature (min_temp in config). The min temperature is also used as target temperature when no other temperature is specified. If the heater is manually turned on, the target temperature will -be sat to 100*C. Meaning -the thermostat probably will never turn off the heater. +be sat to 100*C. Meaning the thermostat probably will never turn +off the heater. If the heater is manually turned off, the target temperature will be sat according to normal rules. (Based on target temperature for given time intervals and the min temperature.) @@ -21,7 +23,6 @@ for given time intervals and the min temperature.) A target temperature sat with the set_temperature function will override all other rules for the target temperature. - Config: [thermostat] @@ -55,14 +56,14 @@ target temperature will be 17*C. Between 0745 and 1500 no temperature is specified. so the min_temp of 10*C will be used. From 1500 to 1850 the target temperature is 20*, but if away mode is true the target temperature will be sat to 10*C - """ - import logging import datetime import homeassistant.components as core +import homeassistant.util as util from homeassistant.components.thermostat import ThermostatDevice +from homeassistant.helpers.event import track_state_change from homeassistant.const import TEMP_CELCIUS, STATE_ON, STATE_OFF TOL_TEMP = 0.3 @@ -78,7 +79,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # pylint: disable=too-many-instance-attributes class HeatControl(ThermostatDevice): - """ Represents a HeatControl within Home Assistant. """ + """ Represents a HeatControl device. """ def __init__(self, hass, config, logger): @@ -90,27 +91,29 @@ class HeatControl(ThermostatDevice): self.target_sensor_entity_id = config.get("target_sensor") self.time_temp = [] - for time_temp in list(config.get("time_temp").split(",")): - time, temp = time_temp.split(':') - time_start, time_end = time.split('-') - start_time = datetime.datetime.time(datetime.datetime. - strptime(time_start, '%H%M')) - end_time = datetime.datetime.time(datetime.datetime. - strptime(time_end, '%H%M')) - self.time_temp.append((start_time, end_time, float(temp))) + if config.get("time_temp"): + for time_temp in list(config.get("time_temp").split(",")): + time, temp = time_temp.split(':') + time_start, time_end = time.split('-') + start_time = datetime.datetime.time( + datetime.datetime.strptime(time_start, '%H%M')) + end_time = datetime.datetime.time( + datetime.datetime.strptime(time_end, '%H%M')) + self.time_temp.append((start_time, end_time, float(temp))) - self.min_temp = float(config.get("min_temp")) + self._min_temp = util.convert(config.get("min_temp"), float, 0) + self._max_temp = util.convert(config.get("max_temp"), float, 100) self._manual_sat_temp = None self._away = False self._heater_manual_changed = True - hass.states.track_change(self.heater_entity_id, - self._heater_turned_on, - STATE_OFF, STATE_ON) - hass.states.track_change(self.heater_entity_id, - self._heater_turned_off, - STATE_ON, STATE_OFF) + track_state_change(hass, self.heater_entity_id, + self._heater_turned_on, + STATE_OFF, STATE_ON) + track_state_change(hass, self.heater_entity_id, + self._heater_turned_off, + STATE_ON, STATE_OFF) @property def name(self): @@ -146,14 +149,14 @@ class HeatControl(ThermostatDevice): return self.min_temp def set_temperature(self, temperature): - """ Set new target temperature """ + """ Set new target temperature. """ if temperature is None: self._manual_sat_temp = None else: self._manual_sat_temp = float(temperature) def update(self): - """ Update current thermostat """ + """ Update current thermostat. """ heater = self.hass.states.get(self.heater_entity_id) if heater is None: self.logger.error("No heater available") @@ -174,16 +177,16 @@ class HeatControl(ThermostatDevice): core.turn_on(self.hass, self.heater_entity_id) def _heater_turned_on(self, entity_id, old_state, new_state): - """ heater is turned on""" + """ Heater is turned on. """ if not self._heater_manual_changed: pass else: - self.set_temperature(100) + self.set_temperature(self.max_temp) self._heater_manual_changed = True def _heater_turned_off(self, entity_id, old_state, new_state): - """ heater is turned off""" + """ Heater is turned off. """ if self._heater_manual_changed: self.set_temperature(None) @@ -194,3 +197,13 @@ class HeatControl(ThermostatDevice): def turn_away_mode_off(self): """ Turns away mode off. """ self._away = False + + @property + def min_temp(self): + """ Return minimum temperature. """ + return self._min_temp + + @property + def max_temp(self): + """ Return maxmum temperature. """ + return self._max_temp diff --git a/homeassistant/components/thermostat/nest.py b/homeassistant/components/thermostat/nest.py index 06adb49a708..72f10033a91 100644 --- a/homeassistant/components/thermostat/nest.py +++ b/homeassistant/components/thermostat/nest.py @@ -1,4 +1,6 @@ """ +homeassistant.components.thermostat.nest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Adds support for Nest thermostats. """ import logging @@ -6,6 +8,8 @@ import logging from homeassistant.components.thermostat import ThermostatDevice from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, TEMP_CELCIUS) +REQUIREMENTS = ['python-nest==2.6.0'] + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -39,7 +43,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class NestThermostat(ThermostatDevice): - """ Represents a Nest thermostat within Home Assistant. """ + """ Represents a Nest thermostat. """ def __init__(self, structure, device): self.structure = structure @@ -48,11 +52,19 @@ class NestThermostat(ThermostatDevice): @property def name(self): """ Returns the name of the nest, if any. """ - return self.device.name + location = self.device.where + name = self.device.name + if location is None: + return name + else: + if name == '': + return location.capitalize() + else: + return location.capitalize() + '(' + name + ')' @property def unit_of_measurement(self): - """ Returns the unit of measurement. """ + """ Unit of measurement this thermostat expresses itself in. """ return TEMP_CELCIUS @property @@ -74,7 +86,21 @@ class NestThermostat(ThermostatDevice): @property def target_temperature(self): """ Returns the temperature we try to reach. """ - return round(self.device.target, 1) + target = self.device.target + + if isinstance(target, tuple): + low, high = target + + if self.current_temperature < low: + temp = low + elif self.current_temperature > high: + temp = high + else: + temp = (low + high)/2 + else: + temp = target + + return round(temp, 1) @property def is_away_mode_on(self): @@ -93,6 +119,24 @@ class NestThermostat(ThermostatDevice): """ Turns away off. """ self.structure.away = False + @property + def min_temp(self): + """ Identifies min_temp in Nest API or defaults if not available. """ + temp = self.device.away_temperature.low + if temp is None: + return super().min_temp + else: + return temp + + @property + def max_temp(self): + """ Identifies mxn_temp in Nest API or defaults if not available. """ + temp = self.device.away_temperature.high + if temp is None: + return super().max_temp + else: + return temp + def update(self): """ Python-nest has its own mechanism for staying up to date. """ pass diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py new file mode 100644 index 00000000000..c7bc7c205e8 --- /dev/null +++ b/homeassistant/components/verisure.py @@ -0,0 +1,181 @@ +""" +components.verisure +~~~~~~~~~~~~~~~~~~~ +Provides support for verisure components. + +Configuration: + +To use the Verisure component you will need to add something like the +following to your configuration.yaml file. + +verisure: + username: user@example.com + password: password + alarm: 1 + hygrometers: 0 + smartplugs: 1 + thermometers: 0 + +Variables: + +username +*Required +Username to Verisure mypages. + +password +*Required +Password to Verisure mypages. + +alarm +*Optional +Set to 1 to show alarm, 0 to disable. Default 1. + +hygrometers +*Optional +Set to 1 to show hygrometers, 0 to disable. Default 1. + +smartplugs +*Optional +Set to 1 to show smartplugs, 0 to disable. Default 1. + +thermometers +*Optional +Set to 1 to show thermometers, 0 to disable. Default 1. +""" +import logging +from datetime import timedelta + +from homeassistant import bootstrap +from homeassistant.loader import get_component + +from homeassistant.helpers import validate_config +from homeassistant.util import Throttle +from homeassistant.const import ( + EVENT_PLATFORM_DISCOVERED, + ATTR_SERVICE, ATTR_DISCOVERED, + CONF_USERNAME, CONF_PASSWORD) + + +DOMAIN = "verisure" +DISCOVER_SENSORS = 'verisure.sensors' +DISCOVER_SWITCHES = 'verisure.switches' + +DEPENDENCIES = [] +REQUIREMENTS = [ + 'https://github.com/persandstrom/python-verisure/archive/' + '9873c4527f01b1ba1f72ae60f7f35854390d59be.zip#python-verisure==0.2.6' +] + +_LOGGER = logging.getLogger(__name__) + +MY_PAGES = None +STATUS = {} + +VERISURE_LOGIN_ERROR = None +VERISURE_ERROR = None + +SHOW_THERMOMETERS = True +SHOW_HYGROMETERS = True +SHOW_ALARM = True +SHOW_SMARTPLUGS = True + +# if wrong password was given don't try again +WRONG_PASSWORD_GIVEN = False + +MIN_TIME_BETWEEN_REQUESTS = timedelta(seconds=5) + + +def setup(hass, config): + """ Setup the Verisure component. """ + + if not validate_config(config, + {DOMAIN: [CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return False + + from verisure import MyPages, LoginError, Error + + STATUS[MyPages.DEVICE_ALARM] = {} + STATUS[MyPages.DEVICE_CLIMATE] = {} + STATUS[MyPages.DEVICE_SMARTPLUG] = {} + + global SHOW_THERMOMETERS, SHOW_HYGROMETERS, SHOW_ALARM, SHOW_SMARTPLUGS + SHOW_THERMOMETERS = int(config[DOMAIN].get('thermometers', '1')) + SHOW_HYGROMETERS = int(config[DOMAIN].get('hygrometers', '1')) + SHOW_ALARM = int(config[DOMAIN].get('alarm', '1')) + SHOW_SMARTPLUGS = int(config[DOMAIN].get('smartplugs', '1')) + + global MY_PAGES + MY_PAGES = MyPages( + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD]) + global VERISURE_LOGIN_ERROR, VERISURE_ERROR + VERISURE_LOGIN_ERROR = LoginError + VERISURE_ERROR = Error + + try: + MY_PAGES.login() + except (ConnectionError, Error) as ex: + _LOGGER.error('Could not log in to verisure mypages, %s', ex) + return False + + update() + + # Load components for the devices in the ISY controller that we support + for comp_name, discovery in ((('sensor', DISCOVER_SENSORS), + ('switch', DISCOVER_SWITCHES))): + component = get_component(comp_name) + _LOGGER.info(config[DOMAIN]) + bootstrap.setup_component(hass, component.DOMAIN, config) + + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, + {ATTR_SERVICE: discovery, + ATTR_DISCOVERED: {}}) + + return True + + +def get_alarm_status(): + """ Return a list of status overviews for alarm components. """ + return STATUS[MY_PAGES.DEVICE_ALARM] + + +def get_climate_status(): + """ Return a list of status overviews for alarm components. """ + return STATUS[MY_PAGES.DEVICE_CLIMATE] + + +def get_smartplug_status(): + """ Return a list of status overviews for alarm components. """ + return STATUS[MY_PAGES.DEVICE_SMARTPLUG] + + +def reconnect(): + """ Reconnect to verisure mypages. """ + try: + MY_PAGES.login() + except VERISURE_LOGIN_ERROR as ex: + _LOGGER.error("Could not login to Verisure mypages, %s", ex) + global WRONG_PASSWORD_GIVEN + WRONG_PASSWORD_GIVEN = True + except (ConnectionError, VERISURE_ERROR) as ex: + _LOGGER.error("Could not login to Verisure mypages, %s", ex) + + +@Throttle(MIN_TIME_BETWEEN_REQUESTS) +def update(): + """ Updates the status of verisure components. """ + if WRONG_PASSWORD_GIVEN: + # Is there any way to inform user? + return + + try: + for overview in MY_PAGES.get_overview(MY_PAGES.DEVICE_ALARM): + STATUS[MY_PAGES.DEVICE_ALARM][overview.id] = overview + for overview in MY_PAGES.get_overview(MY_PAGES.DEVICE_CLIMATE): + STATUS[MY_PAGES.DEVICE_CLIMATE][overview.id] = overview + for overview in MY_PAGES.get_overview(MY_PAGES.DEVICE_SMARTPLUG): + STATUS[MY_PAGES.DEVICE_SMARTPLUG][overview.id] = overview + except ConnectionError as ex: + _LOGGER.error('Caught connection error %s, tries to reconnect', ex) + reconnect() diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index cfdbf9b1a92..c05d9502ca7 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -1,11 +1,25 @@ """ +homeassistant.components.wink +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Connects to a Wink hub and loads relevant components to control its devices. + +Configuration: + +To use the Wink component you will need to add something like the following +to your configuration.yaml file. + +wink: + access_token: YOUR_ACCESS_TOKEN + +Variables: + +access_token +*Required +Please check https://home-assistant.io/components/wink.html for further +details. """ import logging -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.wink.pywink as pywink - from homeassistant import bootstrap from homeassistant.loader import get_component from homeassistant.helpers import validate_config @@ -16,6 +30,9 @@ from homeassistant.const import ( DOMAIN = "wink" DEPENDENCIES = [] +REQUIREMENTS = ['https://github.com/balloob/python-wink/archive/' + 'c2b700e8ca866159566ecf5e644d9c297f69f257.zip' + '#python-wink==0.1'] DISCOVER_LIGHTS = "wink.lights" DISCOVER_SWITCHES = "wink.switches" @@ -29,6 +46,7 @@ def setup(hass, config): if not validate_config(config, {DOMAIN: [CONF_ACCESS_TOKEN]}, logger): return False + import pywink pywink.set_bearer_token(config[DOMAIN][CONF_ACCESS_TOKEN]) # Load components for the devices in the Wink that we support @@ -53,14 +71,14 @@ def setup(hass, config): class WinkToggleDevice(ToggleEntity): - """ represents a Wink switch within home assistant. """ + """ Represents a Wink switch within Home Assistant. """ def __init__(self, wink): self.wink = wink @property def unique_id(self): - """ Returns the id of this WeMo switch """ + """ Returns the id of this Wink switch. """ return "{}.{}".format(self.__class__, self.wink.deviceId()) @property @@ -90,4 +108,4 @@ class WinkToggleDevice(ToggleEntity): def update(self): """ Update state of the light. """ - self.wink.wait_till_desired_reached() + self.wink.updateState() diff --git a/homeassistant/components/zwave.py b/homeassistant/components/zwave.py index 1d798746a63..ef7e7308959 100644 --- a/homeassistant/components/zwave.py +++ b/homeassistant/components/zwave.py @@ -1,7 +1,6 @@ """ homeassistant.components.zwave ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Connects Home Assistant to a Z-Wave network. """ from pprint import pprint @@ -13,6 +12,7 @@ from homeassistant.const import ( DOMAIN = "zwave" DEPENDENCIES = [] +REQUIREMENTS = ['pydispatcher==2.0.5'] CONF_USB_STICK_PATH = "usb_path" DEFAULT_CONF_USB_STICK_PATH = "/zwaveusbstick" diff --git a/homeassistant/config.py b/homeassistant/config.py new file mode 100644 index 00000000000..2ec706e4512 --- /dev/null +++ b/homeassistant/config.py @@ -0,0 +1,156 @@ +""" +homeassistant.config +~~~~~~~~~~~~~~~~~~~~ + +Module to help with parsing and generating configuration files. +""" +import logging +import os + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_TEMPERATURE_UNIT, CONF_NAME, + CONF_TIME_ZONE) +import homeassistant.util.location as loc_util + +_LOGGER = logging.getLogger(__name__) + +YAML_CONFIG_FILE = 'configuration.yaml' +CONFIG_DIR_NAME = '.homeassistant' + +DEFAULT_CONFIG = ( + # Tuples (attribute, default, auto detect property, description) + (CONF_NAME, 'Home', None, 'Name of the location where Home Assistant is ' + 'running'), + (CONF_LATITUDE, None, 'latitude', 'Location required to calculate the time' + ' the sun rises and sets'), + (CONF_LONGITUDE, None, 'longitude', None), + (CONF_TEMPERATURE_UNIT, 'C', None, 'C for Celcius, F for Fahrenheit'), + (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' + 'pedia.org/wiki/List_of_tz_database_time_zones'), +) +DEFAULT_COMPONENTS = { + 'introduction': 'Show links to resources in log and frontend', + 'frontend': 'Enables the frontend', + 'discovery': 'Discover some devices automatically', + 'conversation': 'Allows you to issue voice commands from the frontend', + 'history': 'Enables support for tracking state changes over time.', + 'logbook': 'View all events in a logbook', + 'sun': 'Track the sun', +} + + +def get_default_config_dir(): + """ Put together the default configuration directory based on OS. """ + data_dir = os.getenv('APPDATA') if os.name == "nt" \ + else os.path.expanduser('~') + return os.path.join(data_dir, CONFIG_DIR_NAME) + + +def ensure_config_exists(config_dir, detect_location=True): + """ Ensures a config file exists in given config dir. + Creating a default one if needed. + Returns path to the config file. """ + config_path = find_config_file(config_dir) + + if config_path is None: + print("Unable to find configuration. Creating default one in", + config_dir) + config_path = create_default_config(config_dir, detect_location) + + return config_path + + +def create_default_config(config_dir, detect_location=True): + """ Creates a default configuration file in given config dir. + Returns path to new config file if success, None if failed. """ + config_path = os.path.join(config_dir, YAML_CONFIG_FILE) + + info = {attr: default for attr, default, *_ in DEFAULT_CONFIG} + + location_info = detect_location and loc_util.detect_location_info() + + if location_info: + if location_info.use_fahrenheit: + info[CONF_TEMPERATURE_UNIT] = 'F' + + for attr, default, prop, _ in DEFAULT_CONFIG: + if prop is None: + continue + info[attr] = getattr(location_info, prop) or default + + # Writing files with YAML does not create the most human readable results + # So we're hard coding a YAML template. + try: + with open(config_path, 'w') as config_file: + config_file.write("homeassistant:\n") + + for attr, _, _, description in DEFAULT_CONFIG: + if info[attr] is None: + continue + elif description: + config_file.write(" # {}\n".format(description)) + config_file.write(" {}: {}\n".format(attr, info[attr])) + + config_file.write("\n") + + for component, description in DEFAULT_COMPONENTS.items(): + config_file.write("# {}\n".format(description)) + config_file.write("{}:\n\n".format(component)) + + return config_path + + except IOError: + print('Unable to create default configuration file', config_path) + return None + + +def find_config_file(config_dir): + """ Looks in given directory for supported config files. """ + config_path = os.path.join(config_dir, YAML_CONFIG_FILE) + + return config_path if os.path.isfile(config_path) else None + + +def load_config_file(config_path): + """ Loads given config file. """ + return load_yaml_config_file(config_path) + + +def load_yaml_config_file(config_path): + """ Parse a YAML configuration file. """ + import yaml + + def parse(fname): + """ Parse a YAML file. """ + try: + with open(fname, encoding='utf-8') as conf_file: + # If configuration file is empty YAML returns None + # We convert that to an empty dict + return yaml.load(conf_file) or {} + except yaml.YAMLError: + error = 'Error reading YAML configuration file {}'.format(fname) + _LOGGER.exception(error) + raise HomeAssistantError(error) + + def yaml_include(loader, node): + """ + Loads another YAML file and embeds it using the !include tag. + + Example: + device_tracker: !include device_tracker.yaml + """ + fname = os.path.join(os.path.dirname(loader.name), node.value) + return parse(fname) + + yaml.add_constructor('!include', yaml_include) + + conf_dict = parse(config_path) + + if not isinstance(conf_dict, dict): + _LOGGER.error( + 'The configuration file %s does not contain a dictionary', + os.path.basename(config_path)) + raise HomeAssistantError() + + return conf_dict diff --git a/homeassistant/const.py b/homeassistant/const.py index 467bb692399..e5a44ff7b55 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,4 +1,7 @@ """ Constants used by Home Assistant components. """ + +__version__ = "0.7.2" + # Can be used to specify a catch all when registering state or event listeners. MATCH_ALL = '*' @@ -11,6 +14,7 @@ CONF_LONGITUDE = "longitude" CONF_TEMPERATURE_UNIT = "temperature_unit" CONF_NAME = "name" CONF_TIME_ZONE = "time_zone" +CONF_CUSTOMIZE = "customize" CONF_PLATFORM = "platform" CONF_HOST = "host" @@ -39,6 +43,10 @@ STATE_NOT_HOME = 'not_home' STATE_UNKNOWN = "unknown" STATE_OPEN = 'open' STATE_CLOSED = 'closed' +STATE_PLAYING = 'playing' +STATE_PAUSED = 'paused' +STATE_IDLE = 'idle' +STATE_STANDBY = 'standby' # #### STATE AND EVENT ATTRIBUTES #### # Contains current time for a TIME_CHANGED event @@ -86,6 +94,9 @@ ATTR_TRIPPED = "device_tripped" # time the device was tripped ATTR_LAST_TRIP_TIME = "last_tripped_time" +# For all entity's, this hold whether or not it should be hidden +ATTR_HIDDEN = "hidden" + # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP = "stop" @@ -95,11 +106,13 @@ SERVICE_TURN_OFF = 'turn_off' SERVICE_VOLUME_UP = "volume_up" SERVICE_VOLUME_DOWN = "volume_down" SERVICE_VOLUME_MUTE = "volume_mute" +SERVICE_VOLUME_SET = "volume_set" SERVICE_MEDIA_PLAY_PAUSE = "media_play_pause" SERVICE_MEDIA_PLAY = "media_play" SERVICE_MEDIA_PAUSE = "media_pause" SERVICE_MEDIA_NEXT_TRACK = "media_next_track" -SERVICE_MEDIA_PREV_TRACK = "media_prev_track" +SERVICE_MEDIA_PREVIOUS_TRACK = "media_previous_track" +SERVICE_MEDIA_SEEK = "media_seek" # #### API / REMOTE #### SERVER_PORT = 8123 @@ -107,6 +120,7 @@ SERVER_PORT = 8123 URL_ROOT = "/" URL_API = "/api/" URL_API_STREAM = "/api/stream" +URL_API_CONFIG = "/api/config" URL_API_STATES = "/api/states" URL_API_STATES_ENTITY = "/api/states/{}" URL_API_EVENTS = "/api/events" @@ -115,6 +129,7 @@ URL_API_SERVICES = "/api/services" URL_API_SERVICES_SERVICE = "/api/services/{}/{}" URL_API_EVENT_FORWARD = "/api/event_forwarding" URL_API_COMPONENTS = "/api/components" +URL_API_BOOTSTRAP = "/api/bootstrap" HTTP_OK = 200 HTTP_CREATED = 201 @@ -135,3 +150,4 @@ HTTP_HEADER_CACHE_CONTROL = "Cache-Control" HTTP_HEADER_EXPIRES = "Expires" CONTENT_TYPE_JSON = "application/json" +CONTENT_TYPE_MULTIPART = 'multipart/x-mixed-replace; boundary={}' diff --git a/homeassistant/core.py b/homeassistant/core.py new file mode 100644 index 00000000000..df18d7e7902 --- /dev/null +++ b/homeassistant/core.py @@ -0,0 +1,801 @@ +""" +homeassistant +~~~~~~~~~~~~~ + +Home Assistant is a Home Automation framework for observing the state +of entities and react to changes. +""" + +import os +import time +import logging +import signal +import threading +import enum +import re +import functools as ft +from collections import namedtuple + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + SERVICE_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, + EVENT_CALL_SERVICE, ATTR_NOW, ATTR_DOMAIN, ATTR_SERVICE, MATCH_ALL, + EVENT_SERVICE_EXECUTED, ATTR_SERVICE_CALL_ID, EVENT_SERVICE_REGISTERED, + TEMP_CELCIUS, TEMP_FAHRENHEIT, ATTR_FRIENDLY_NAME) +from homeassistant.exceptions import ( + HomeAssistantError, InvalidEntityFormatError) +import homeassistant.util as util +import homeassistant.util.dt as date_util +import homeassistant.helpers.temperature as temp_helper +from homeassistant.config import get_default_config_dir + +DOMAIN = "homeassistant" + +# How often time_changed event should fire +TIMER_INTERVAL = 1 # seconds + +# How long we wait for the result of a service call +SERVICE_CALL_LIMIT = 10 # seconds + +# Define number of MINIMUM worker threads. +# During bootstrap of HA (see bootstrap._setup_component()) worker threads +# will be added for each component that polls devices. +MIN_WORKER_THREAD = 2 + +# Pattern for validating entity IDs (format: .) +ENTITY_ID_PATTERN = re.compile(r"^(?P\w+)\.(?P\w+)$") + +_LOGGER = logging.getLogger(__name__) + +# Temporary to support deprecated methods +_MockHA = namedtuple("MockHomeAssistant", ['bus']) + + +class HomeAssistant(object): + """ Core class to route all communication to right components. """ + + def __init__(self): + self.pool = pool = create_worker_pool() + self.bus = EventBus(pool) + self.services = ServiceRegistry(self.bus, pool) + self.states = StateMachine(self.bus) + self.config = Config() + + def start(self): + """ Start home assistant. """ + _LOGGER.info( + "Starting Home Assistant (%d threads)", self.pool.worker_count) + + create_timer(self) + self.bus.fire(EVENT_HOMEASSISTANT_START) + + def block_till_stopped(self): + """ Will register service homeassistant/stop and + will block until called. """ + request_shutdown = threading.Event() + + def stop_homeassistant(*args): + """ Stops Home Assistant. """ + request_shutdown.set() + + self.services.register( + DOMAIN, SERVICE_HOMEASSISTANT_STOP, stop_homeassistant) + + if os.name != "nt": + try: + signal.signal(signal.SIGQUIT, stop_homeassistant) + except ValueError: + _LOGGER.warning( + 'Could not bind to SIGQUIT. Are you running in a thread?') + + while not request_shutdown.isSet(): + try: + time.sleep(1) + except KeyboardInterrupt: + break + + self.stop() + + def stop(self): + """ Stops Home Assistant and shuts down all threads. """ + _LOGGER.info("Stopping") + + self.bus.fire(EVENT_HOMEASSISTANT_STOP) + + # Wait till all responses to homeassistant_stop are done + self.pool.block_till_done() + + self.pool.stop() + + def track_point_in_time(self, action, point_in_time): + """Deprecated method as of 8/4/2015 to track point in time.""" + _LOGGER.warning( + 'hass.track_point_in_time is deprecated. ' + 'Please use homeassistant.helpers.event.track_point_in_time') + import homeassistant.helpers.event as helper + helper.track_point_in_time(self, action, point_in_time) + + def track_point_in_utc_time(self, action, point_in_time): + """Deprecated method as of 8/4/2015 to track point in UTC time.""" + _LOGGER.warning( + 'hass.track_point_in_utc_time is deprecated. ' + 'Please use homeassistant.helpers.event.track_point_in_utc_time') + import homeassistant.helpers.event as helper + helper.track_point_in_utc_time(self, action, point_in_time) + + def track_utc_time_change(self, action, + year=None, month=None, day=None, + hour=None, minute=None, second=None): + """Deprecated method as of 8/4/2015 to track UTC time change.""" + # pylint: disable=too-many-arguments + _LOGGER.warning( + 'hass.track_utc_time_change is deprecated. ' + 'Please use homeassistant.helpers.event.track_utc_time_change') + import homeassistant.helpers.event as helper + helper.track_utc_time_change(self, action, year, month, day, hour, + minute, second) + + def track_time_change(self, action, + year=None, month=None, day=None, + hour=None, minute=None, second=None, utc=False): + """Deprecated method as of 8/4/2015 to track time change.""" + # pylint: disable=too-many-arguments + _LOGGER.warning( + 'hass.track_time_change is deprecated. ' + 'Please use homeassistant.helpers.event.track_time_change') + import homeassistant.helpers.event as helper + helper.track_time_change(self, action, year, month, day, hour, + minute, second) + + +class JobPriority(util.OrderedEnum): + """ Provides priorities for bus events. """ + # pylint: disable=no-init,too-few-public-methods + + EVENT_CALLBACK = 0 + EVENT_SERVICE = 1 + EVENT_STATE = 2 + EVENT_TIME = 3 + EVENT_DEFAULT = 4 + + @staticmethod + def from_event_type(event_type): + """ Returns a priority based on event type. """ + if event_type == EVENT_TIME_CHANGED: + return JobPriority.EVENT_TIME + elif event_type == EVENT_STATE_CHANGED: + return JobPriority.EVENT_STATE + elif event_type == EVENT_CALL_SERVICE: + return JobPriority.EVENT_SERVICE + elif event_type == EVENT_SERVICE_EXECUTED: + return JobPriority.EVENT_CALLBACK + else: + return JobPriority.EVENT_DEFAULT + + +class EventOrigin(enum.Enum): + """ Distinguish between origin of event. """ + # pylint: disable=no-init,too-few-public-methods + + local = "LOCAL" + remote = "REMOTE" + + def __str__(self): + return self.value + + +# pylint: disable=too-few-public-methods +class Event(object): + """ Represents an event within the Bus. """ + + __slots__ = ['event_type', 'data', 'origin', 'time_fired'] + + def __init__(self, event_type, data=None, origin=EventOrigin.local, + time_fired=None): + self.event_type = event_type + self.data = data or {} + self.origin = origin + self.time_fired = date_util.strip_microseconds( + time_fired or date_util.utcnow()) + + def as_dict(self): + """ Returns a dict representation of this Event. """ + return { + 'event_type': self.event_type, + 'data': dict(self.data), + 'origin': str(self.origin), + 'time_fired': date_util.datetime_to_str(self.time_fired), + } + + def __repr__(self): + # pylint: disable=maybe-no-member + if self.data: + return "".format( + self.event_type, str(self.origin)[0], + util.repr_helper(self.data)) + else: + return "".format(self.event_type, + str(self.origin)[0]) + + def __eq__(self, other): + return (self.__class__ == other.__class__ and + self.event_type == other.event_type and + self.data == other.data and + self.origin == other.origin and + self.time_fired == other.time_fired) + + +class EventBus(object): + """ Class that allows different components to communicate via services + and events. + """ + + def __init__(self, pool=None): + self._listeners = {} + self._lock = threading.Lock() + self._pool = pool or create_worker_pool() + + @property + def listeners(self): + """ Dict with events that is being listened for and the number + of listeners. + """ + with self._lock: + return {key: len(self._listeners[key]) + for key in self._listeners} + + def fire(self, event_type, event_data=None, origin=EventOrigin.local): + """ Fire an event. """ + if not self._pool.running: + raise HomeAssistantError('Home Assistant has shut down.') + + with self._lock: + # Copy the list of the current listeners because some listeners + # remove themselves as a listener while being executed which + # causes the iterator to be confused. + get = self._listeners.get + listeners = get(MATCH_ALL, []) + get(event_type, []) + + event = Event(event_type, event_data, origin) + + if event_type != EVENT_TIME_CHANGED: + _LOGGER.info("Bus:Handling %s", event) + + if not listeners: + return + + job_priority = JobPriority.from_event_type(event_type) + + for func in listeners: + self._pool.add_job(job_priority, (func, event)) + + def listen(self, event_type, listener): + """ Listen for all events or events of a specific type. + + To listen to all events specify the constant ``MATCH_ALL`` + as event_type. + """ + with self._lock: + if event_type in self._listeners: + self._listeners[event_type].append(listener) + else: + self._listeners[event_type] = [listener] + + def listen_once(self, event_type, listener): + """ Listen once for event of a specific type. + + To listen to all events specify the constant ``MATCH_ALL`` + as event_type. + + Returns registered listener that can be used with remove_listener. + """ + @ft.wraps(listener) + def onetime_listener(event): + """ Removes listener from eventbus and then fires listener. """ + if hasattr(onetime_listener, 'run'): + return + # Set variable so that we will never run twice. + # Because the event bus might have to wait till a thread comes + # available to execute this listener it might occur that the + # listener gets lined up twice to be executed. + # This will make sure the second time it does nothing. + onetime_listener.run = True + + self.remove_listener(event_type, onetime_listener) + + listener(event) + + self.listen(event_type, onetime_listener) + + return onetime_listener + + def remove_listener(self, event_type, listener): + """ Removes a listener of a specific event_type. """ + with self._lock: + try: + self._listeners[event_type].remove(listener) + + # delete event_type list if empty + if not self._listeners[event_type]: + self._listeners.pop(event_type) + + except (KeyError, ValueError): + # KeyError is key event_type listener did not exist + # ValueError if listener did not exist within event_type + pass + + +class State(object): + """ + Object to represent a state within the state machine. + + entity_id: the entity that is represented. + state: the state of the entity + attributes: extra information on entity and state + last_changed: last time the state was changed, not the attributes. + last_updated: last time this object was updated. + """ + + __slots__ = ['entity_id', 'state', 'attributes', + 'last_changed', 'last_updated'] + + # pylint: disable=too-many-arguments + def __init__(self, entity_id, state, attributes=None, last_changed=None, + last_updated=None): + if not ENTITY_ID_PATTERN.match(entity_id): + raise InvalidEntityFormatError(( + "Invalid entity id encountered: {}. " + "Format should be .").format(entity_id)) + + self.entity_id = entity_id.lower() + self.state = state + self.attributes = attributes or {} + self.last_updated = date_util.strip_microseconds( + last_updated or date_util.utcnow()) + + # Strip microsecond from last_changed else we cannot guarantee + # state == State.from_dict(state.as_dict()) + # This behavior occurs because to_dict uses datetime_to_str + # which does not preserve microseconds + self.last_changed = date_util.strip_microseconds( + last_changed or self.last_updated) + + @property + def domain(self): + """ Returns domain of this state. """ + return util.split_entity_id(self.entity_id)[0] + + @property + def object_id(self): + """ Returns object_id of this state. """ + return util.split_entity_id(self.entity_id)[1] + + @property + def name(self): + """ Name to represent this state. """ + return ( + self.attributes.get(ATTR_FRIENDLY_NAME) or + self.object_id.replace('_', ' ')) + + def copy(self): + """ Creates a copy of itself. """ + return State(self.entity_id, self.state, + dict(self.attributes), self.last_changed) + + def as_dict(self): + """ Converts State to a dict to be used within JSON. + Ensures: state == State.from_dict(state.as_dict()) """ + + return {'entity_id': self.entity_id, + 'state': self.state, + 'attributes': self.attributes, + 'last_changed': date_util.datetime_to_str(self.last_changed), + 'last_updated': date_util.datetime_to_str(self.last_updated)} + + @classmethod + def from_dict(cls, json_dict): + """ Static method to create a state from a dict. + Ensures: state == State.from_json_dict(state.to_json_dict()) """ + + if not (json_dict and + 'entity_id' in json_dict and + 'state' in json_dict): + return None + + last_changed = json_dict.get('last_changed') + + if last_changed: + last_changed = date_util.str_to_datetime(last_changed) + + last_updated = json_dict.get('last_updated') + + if last_updated: + last_updated = date_util.str_to_datetime(last_updated) + + return cls(json_dict['entity_id'], json_dict['state'], + json_dict.get('attributes'), last_changed, last_updated) + + def __eq__(self, other): + return (self.__class__ == other.__class__ and + self.entity_id == other.entity_id and + self.state == other.state and + self.attributes == other.attributes) + + def __repr__(self): + attr = "; {}".format(util.repr_helper(self.attributes)) \ + if self.attributes else "" + + return "".format( + self.entity_id, self.state, attr, + date_util.datetime_to_local_str(self.last_changed)) + + +class StateMachine(object): + """ Helper class that tracks the state of different entities. """ + + def __init__(self, bus): + self._states = {} + self._bus = bus + self._lock = threading.Lock() + + def entity_ids(self, domain_filter=None): + """ List of entity ids that are being tracked. """ + if domain_filter is None: + return list(self._states.keys()) + + domain_filter = domain_filter.lower() + + return [state.entity_id for key, state + in self._states.items() + if util.split_entity_id(key)[0] == domain_filter] + + def all(self): + """ Returns a list of all states. """ + with self._lock: + return [state.copy() for state in self._states.values()] + + def get(self, entity_id): + """ Returns the state of the specified entity. """ + state = self._states.get(entity_id.lower()) + + # Make a copy so people won't mutate the state + return state.copy() if state else None + + def is_state(self, entity_id, state): + """ Returns True if entity exists and is specified state. """ + entity_id = entity_id.lower() + + return (entity_id in self._states and + self._states[entity_id].state == state) + + def remove(self, entity_id): + """ Removes an entity from the state machine. + + Returns boolean to indicate if an entity was removed. """ + entity_id = entity_id.lower() + + with self._lock: + return self._states.pop(entity_id, None) is not None + + def set(self, entity_id, new_state, attributes=None): + """ Set the state of an entity, add entity if it does not exist. + + Attributes is an optional dict to specify attributes of this state. + + If you just update the attributes and not the state, last changed will + not be affected. + """ + entity_id = entity_id.lower() + new_state = str(new_state) + attributes = attributes or {} + + with self._lock: + old_state = self._states.get(entity_id) + + is_existing = old_state is not None + same_state = is_existing and old_state.state == new_state + same_attr = is_existing and old_state.attributes == attributes + + if same_state and same_attr: + return + + # If state did not exist or is different, set it + last_changed = old_state.last_changed if same_state else None + + state = State(entity_id, new_state, attributes, last_changed) + self._states[entity_id] = state + + event_data = {'entity_id': entity_id, 'new_state': state} + + if old_state: + event_data['old_state'] = old_state + + self._bus.fire(EVENT_STATE_CHANGED, event_data) + + def track_change(self, entity_ids, action, from_state=None, to_state=None): + """ + DEPRECATED AS OF 8/4/2015 + """ + _LOGGER.warning( + 'hass.states.track_change is deprecated. ' + 'Use homeassistant.helpers.event.track_state_change instead.') + import homeassistant.helpers.event as helper + helper.track_state_change(_MockHA(self._bus), entity_ids, action, + from_state, to_state) + + +# pylint: disable=too-few-public-methods +class ServiceCall(object): + """ Represents a call to a service. """ + + __slots__ = ['domain', 'service', 'data'] + + def __init__(self, domain, service, data=None): + self.domain = domain + self.service = service + self.data = data or {} + + def __repr__(self): + if self.data: + return "".format( + self.domain, self.service, util.repr_helper(self.data)) + else: + return "".format(self.domain, self.service) + + +class ServiceRegistry(object): + """ Offers services over the eventbus. """ + + def __init__(self, bus, pool=None): + self._services = {} + self._lock = threading.Lock() + self._pool = pool or create_worker_pool() + self._bus = bus + self._cur_id = 0 + bus.listen(EVENT_CALL_SERVICE, self._event_to_service_call) + + @property + def services(self): + """ Dict with per domain a list of available services. """ + with self._lock: + return {domain: list(self._services[domain].keys()) + for domain in self._services} + + def has_service(self, domain, service): + """ Returns True if specified service exists. """ + return service in self._services.get(domain, []) + + def register(self, domain, service, service_func): + """ Register a service. """ + with self._lock: + if domain in self._services: + self._services[domain][service] = service_func + else: + self._services[domain] = {service: service_func} + + self._bus.fire( + EVENT_SERVICE_REGISTERED, + {ATTR_DOMAIN: domain, ATTR_SERVICE: service}) + + def call(self, domain, service, service_data=None, blocking=False): + """ + Calls specified service. + Specify blocking=True to wait till service is executed. + Waits a maximum of SERVICE_CALL_LIMIT. + + If blocking = True, will return boolean if service executed + succesfully within SERVICE_CALL_LIMIT. + + This method will fire an event to call the service. + This event will be picked up by this ServiceRegistry and any + other ServiceRegistry that is listening on the EventBus. + + Because the service is sent as an event you are not allowed to use + the keys ATTR_DOMAIN and ATTR_SERVICE in your service_data. + """ + call_id = self._generate_unique_id() + event_data = service_data or {} + event_data[ATTR_DOMAIN] = domain + event_data[ATTR_SERVICE] = service + event_data[ATTR_SERVICE_CALL_ID] = call_id + + if blocking: + executed_event = threading.Event() + + def service_executed(call): + """ + Called when a service is executed. + Will set the event if matches our service call. + """ + if call.data[ATTR_SERVICE_CALL_ID] == call_id: + executed_event.set() + + self._bus.listen(EVENT_SERVICE_EXECUTED, service_executed) + + self._bus.fire(EVENT_CALL_SERVICE, event_data) + + if blocking: + success = executed_event.wait(SERVICE_CALL_LIMIT) + self._bus.remove_listener( + EVENT_SERVICE_EXECUTED, service_executed) + return success + + def _event_to_service_call(self, event): + """ Calls a service from an event. """ + service_data = dict(event.data) + domain = service_data.pop(ATTR_DOMAIN, None) + service = service_data.pop(ATTR_SERVICE, None) + + if not self.has_service(domain, service): + return + + service_handler = self._services[domain][service] + service_call = ServiceCall(domain, service, service_data) + + # Add a job to the pool that calls _execute_service + self._pool.add_job(JobPriority.EVENT_SERVICE, + (self._execute_service, + (service_handler, service_call))) + + def _execute_service(self, service_and_call): + """ Executes a service and fires a SERVICE_EXECUTED event. """ + service, call = service_and_call + service(call) + + if ATTR_SERVICE_CALL_ID in call.data: + self._bus.fire( + EVENT_SERVICE_EXECUTED, + {ATTR_SERVICE_CALL_ID: call.data[ATTR_SERVICE_CALL_ID]}) + + def _generate_unique_id(self): + """ Generates a unique service call id. """ + self._cur_id += 1 + return "{}-{}".format(id(self), self._cur_id) + + +class Config(object): + """ Configuration settings for Home Assistant. """ + + # pylint: disable=too-many-instance-attributes + def __init__(self): + self.latitude = None + self.longitude = None + self.temperature_unit = None + self.location_name = None + self.time_zone = None + + # If True, pip install is skipped for requirements on startup + self.skip_pip = False + + # List of loaded components + self.components = [] + + # Remote.API object pointing at local API + self.api = None + + # Directory that holds the configuration + self.config_dir = get_default_config_dir() + + def path(self, *path): + """ Returns path to the file within the config dir. """ + return os.path.join(self.config_dir, *path) + + def temperature(self, value, unit): + """ Converts temperature to user preferred unit if set. """ + if not (unit in (TEMP_CELCIUS, TEMP_FAHRENHEIT) and + self.temperature_unit and unit != self.temperature_unit): + return value, unit + + try: + temp = float(value) + except ValueError: # Could not convert value to float + return value, unit + + return ( + round(temp_helper.convert(temp, unit, self.temperature_unit), 1), + self.temperature_unit) + + def as_dict(self): + """ Converts config to a dictionary. """ + time_zone = self.time_zone or date_util.UTC + + return { + 'latitude': self.latitude, + 'longitude': self.longitude, + 'temperature_unit': self.temperature_unit, + 'location_name': self.location_name, + 'time_zone': time_zone.zone, + 'components': self.components, + } + + +def create_timer(hass, interval=TIMER_INTERVAL): + """ Creates a timer. Timer will start on HOMEASSISTANT_START. """ + # We want to be able to fire every time a minute starts (seconds=0). + # We want this so other modules can use that to make sure they fire + # every minute. + assert 60 % interval == 0, "60 % TIMER_INTERVAL should be 0!" + + def timer(): + """Send an EVENT_TIME_CHANGED on interval.""" + stop_event = threading.Event() + + def stop_timer(event): + """Stop the timer.""" + stop_event.set() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_timer) + + _LOGGER.info("Timer:starting") + + last_fired_on_second = -1 + + calc_now = date_util.utcnow + + while not stop_event.isSet(): + now = calc_now() + + # First check checks if we are not on a second matching the + # timer interval. Second check checks if we did not already fire + # this interval. + if now.second % interval or \ + now.second == last_fired_on_second: + + # Sleep till it is the next time that we have to fire an event. + # Aim for halfway through the second that fits TIMER_INTERVAL. + # If TIMER_INTERVAL is 10 fire at .5, 10.5, 20.5, etc seconds. + # This will yield the best results because time.sleep() is not + # 100% accurate because of non-realtime OS's + slp_seconds = interval - now.second % interval + \ + .5 - now.microsecond/1000000.0 + + time.sleep(slp_seconds) + + now = calc_now() + + last_fired_on_second = now.second + + # Event might have been set while sleeping + if not stop_event.isSet(): + try: + hass.bus.fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}) + except HomeAssistantError: + # HA raises error if firing event after it has shut down + break + + def start_timer(event): + """Start the timer.""" + thread = threading.Thread(target=timer) + thread.daemon = True + thread.start() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_timer) + + +def create_worker_pool(worker_count=None): + """ Creates a worker pool to be used. """ + if worker_count is None: + worker_count = MIN_WORKER_THREAD + + def job_handler(job): + """ Called whenever a job is available to do. """ + try: + func, arg = job + func(arg) + except Exception: # pylint: disable=broad-except + # Catch any exception our service/event_listener might throw + # We do not want to crash our ThreadPool + _LOGGER.exception("BusHandler:Exception doing job") + + def busy_callback(worker_count, current_jobs, pending_jobs_count): + """ Callback to be called when the pool queue gets too big. """ + + _LOGGER.warning( + "WorkerPool:All %d threads are busy and %d jobs pending", + worker_count, pending_jobs_count) + + for start, job in current_jobs: + _LOGGER.warning("WorkerPool:Current job from %s: %s", + date_util.datetime_to_local_str(start), job) + + return util.ThreadPool(job_handler, worker_count, busy_callback) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py new file mode 100644 index 00000000000..bd32d356670 --- /dev/null +++ b/homeassistant/exceptions.py @@ -0,0 +1,16 @@ +""" Exceptions used by Home Assistant """ + + +class HomeAssistantError(Exception): + """ General Home Assistant exception occured. """ + pass + + +class InvalidEntityFormatError(HomeAssistantError): + """ When an invalid formatted entity is encountered. """ + pass + + +class NoEntitySpecifiedError(HomeAssistantError): + """ When no entity is specified. """ + pass diff --git a/homeassistant/external/netdisco b/homeassistant/external/netdisco deleted file mode 160000 index 6e712dd65e4..00000000000 --- a/homeassistant/external/netdisco +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6e712dd65e474bf623b35c54f5290dbac192c7e4 diff --git a/homeassistant/external/noop b/homeassistant/external/noop deleted file mode 160000 index 45fae73c1f4..00000000000 --- a/homeassistant/external/noop +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 45fae73c1f44342010fa07f3ed8909bf2819a508 diff --git a/homeassistant/external/nzbclients b/homeassistant/external/nzbclients deleted file mode 160000 index f9f9ba36934..00000000000 --- a/homeassistant/external/nzbclients +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f9f9ba36934f087b9c4241303b900794a7eb6c08 diff --git a/homeassistant/external/pynetgear b/homeassistant/external/pynetgear deleted file mode 160000 index e946ecf7926..00000000000 --- a/homeassistant/external/pynetgear +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e946ecf7926b9b2adaa1e3127a9738201a1b1fc7 diff --git a/homeassistant/external/pywemo b/homeassistant/external/pywemo deleted file mode 160000 index 7f6c383ded7..00000000000 --- a/homeassistant/external/pywemo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7f6c383ded75f1273cbca28e858b8a8c96da66d4 diff --git a/homeassistant/external/vera b/homeassistant/external/vera deleted file mode 160000 index fedbb5c3af1..00000000000 --- a/homeassistant/external/vera +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fedbb5c3af1e5f36b7008d894e9fc1ecf3cc2ea8 diff --git a/homeassistant/external/wink/pywink.py b/homeassistant/external/wink/pywink.py deleted file mode 100644 index cbe50701707..00000000000 --- a/homeassistant/external/wink/pywink.py +++ /dev/null @@ -1,408 +0,0 @@ -__author__ = 'JOHNMCL' - -import json -import time - -import requests - -baseUrl = "https://winkapi.quirky.com" - -headers = {} - - -class wink_sensor_pod(object): - """ represents a wink.py sensor - json_obj holds the json stat at init (and if there is a refresh it's updated - it's the native format for this objects methods - and looks like so: -{ - "data": { - "last_event": { - "brightness_occurred_at": None, - "loudness_occurred_at": None, - "vibration_occurred_at": None - }, - "model_name": "Tripper", - "capabilities": { - "sensor_types": [ - { - "field": "opened", - "type": "boolean" - }, - { - "field": "battery", - "type": "percentage" - } - ] - }, - "manufacturer_device_model": "quirky_ge_tripper", - "location": "", - "radio_type": "zigbee", - "manufacturer_device_id": None, - "gang_id": None, - "sensor_pod_id": "37614", - "subscription": { - }, - "units": { - }, - "upc_id": "184", - "hidden_at": None, - "last_reading": { - "battery_voltage_threshold_2": 0, - "opened": False, - "battery_alarm_mask": 0, - "opened_updated_at": 1421697092.7347496, - "battery_voltage_min_threshold_updated_at": 1421697092.7347229, - "battery_voltage_min_threshold": 0, - "connection": None, - "battery_voltage": 25, - "battery_voltage_threshold_1": 25, - "connection_updated_at": None, - "battery_voltage_threshold_3": 0, - "battery_voltage_updated_at": 1421697092.7347066, - "battery_voltage_threshold_1_updated_at": 1421697092.7347302, - "battery_voltage_threshold_3_updated_at": 1421697092.7347434, - "battery_voltage_threshold_2_updated_at": 1421697092.7347374, - "battery": 1.0, - "battery_updated_at": 1421697092.7347553, - "battery_alarm_mask_updated_at": 1421697092.734716 - }, - "triggers": [ - ], - "name": "MasterBathroom", - "lat_lng": [ - 37.550773, - -122.279182 - ], - "uuid": "a2cb868a-dda3-4211-ab73-fc08087aeed7", - "locale": "en_us", - "device_manufacturer": "quirky_ge", - "created_at": 1421523277, - "local_id": "2", - "hub_id": "88264" - }, -} - - """ - def __init__(self, aJSonObj, objectprefix="sensor_pods"): - self.jsonState = aJSonObj - self.objectprefix = objectprefix - - def __str__(self): - return "%s %s %s" % (self.name(), self.deviceId(), self.state()) - - def __repr__(self): - return "" % (self.name(), self.deviceId(), self.state()) - - @property - def _last_reading(self): - return self.jsonState.get('last_reading') or {} - - def name(self): - return self.jsonState.get('name', "Unknown Name") - - def state(self): - return self._last_reading.get('opened', False) - - def deviceId(self): - return self.jsonState.get('sensor_pod_id', self.name()) - - def refresh_state_at_hub(self): - """ - Tell hub to query latest status from device and upload to Wink. - PS: Not sure if this even works.. - """ - urlString = baseUrl + "/%s/%s/refresh" % (self.objectprefix, self.deviceId()) - requests.get(urlString, headers=headers) - - def updateState(self): - """ Update state with latest info from Wink API. """ - urlString = baseUrl + "/%s/%s" % (self.objectprefix, self.deviceId()) - arequest = requests.get(urlString, headers=headers) - self._updateStateFromResponse(arequest.json()) - - def _updateStateFromResponse(self, response_json): - """ - :param response_json: the json obj returned from query - :return: - """ - self.jsonState = response_json.get('data') - -class wink_binary_switch(object): - """ represents a wink.py switch - json_obj holds the json stat at init (and if there is a refresh it's updated - it's the native format for this objects methods - and looks like so: - -{ - "data": { - "binary_switch_id": "4153", - "name": "Garage door indicator", - "locale": "en_us", - "units": {}, - "created_at": 1411614982, - "hidden_at": null, - "capabilities": {}, - "subscription": {}, - "triggers": [], - "desired_state": { - "powered": false - }, - "manufacturer_device_model": "leviton_dzs15", - "manufacturer_device_id": null, - "device_manufacturer": "leviton", - "model_name": "Switch", - "upc_id": "94", - "gang_id": null, - "hub_id": "11780", - "local_id": "9", - "radio_type": "zwave", - "last_reading": { - "powered": false, - "powered_updated_at": 1411614983.6153464, - "powering_mode": null, - "powering_mode_updated_at": null, - "consumption": null, - "consumption_updated_at": null, - "cost": null, - "cost_updated_at": null, - "budget_percentage": null, - "budget_percentage_updated_at": null, - "budget_velocity": null, - "budget_velocity_updated_at": null, - "summation_delivered": null, - "summation_delivered_updated_at": null, - "sum_delivered_multiplier": null, - "sum_delivered_multiplier_updated_at": null, - "sum_delivered_divisor": null, - "sum_delivered_divisor_updated_at": null, - "sum_delivered_formatting": null, - "sum_delivered_formatting_updated_at": null, - "sum_unit_of_measure": null, - "sum_unit_of_measure_updated_at": null, - "desired_powered": false, - "desired_powered_updated_at": 1417893563.7567682, - "desired_powering_mode": null, - "desired_powering_mode_updated_at": null - }, - "current_budget": null, - "lat_lng": [ - 38.429996, - -122.653721 - ], - "location": "", - "order": 0 - }, - "errors": [], - "pagination": {} -} - - """ - def __init__(self, aJSonObj, objectprefix="binary_switches"): - self.jsonState = aJSonObj - self.objectprefix = objectprefix - # Tuple (desired state, time) - self._last_call = (0, None) - - def __str__(self): - return "%s %s %s" % (self.name(), self.deviceId(), self.state()) - - def __repr__(self): - return "" % (self.name(), self.deviceId(), self.state()) - - @property - def _last_reading(self): - return self.jsonState.get('last_reading') or {} - - def name(self): - return self.jsonState.get('name', "Unknown Name") - - def state(self): - # Optimistic approach to setState: - # Within 15 seconds of a call to setState we assume it worked. - if self._recent_state_set(): - return self._last_call[1] - - return self._last_reading.get('powered', False) - - def deviceId(self): - return self.jsonState.get('binary_switch_id', self.name()) - - def setState(self, state): - """ - :param state: a boolean of true (on) or false ('off') - :return: nothing - """ - urlString = baseUrl + "/%s/%s" % (self.objectprefix, self.deviceId()) - values = {"desired_state": {"powered": state}} - arequest = requests.put(urlString, data=json.dumps(values), headers=headers) - self._updateStateFromResponse(arequest.json()) - - self._last_call = (time.time(), state) - - def refresh_state_at_hub(self): - """ - Tell hub to query latest status from device and upload to Wink. - PS: Not sure if this even works.. - """ - urlString = baseUrl + "/%s/%s/refresh" % (self.objectprefix, self.deviceId()) - requests.get(urlString, headers=headers) - - def updateState(self): - """ Update state with latest info from Wink API. """ - urlString = baseUrl + "/%s/%s" % (self.objectprefix, self.deviceId()) - arequest = requests.get(urlString, headers=headers) - self._updateStateFromResponse(arequest.json()) - - def wait_till_desired_reached(self): - """ Wait till desired state reached. Max 10s. """ - if self._recent_state_set(): - return - - # self.refresh_state_at_hub() - tries = 1 - - while True: - self.updateState() - last_read = self._last_reading - - if last_read.get('desired_powered') == last_read.get('powered') \ - or tries == 5: - break - - time.sleep(2) - - tries += 1 - self.updateState() - last_read = self._last_reading - - def _updateStateFromResponse(self, response_json): - """ - :param response_json: the json obj returned from query - :return: - """ - self.jsonState = response_json.get('data') - - def _recent_state_set(self): - return time.time() - self._last_call[0] < 15 - - -class wink_bulb(wink_binary_switch): - """ represents a wink.py bulb - json_obj holds the json stat at init (and if there is a refresh it's updated - it's the native format for this objects methods - and looks like so: - - "light_bulb_id": "33990", - "name": "downstaurs lamp", - "locale": "en_us", - "units":{}, - "created_at": 1410925804, - "hidden_at": null, - "capabilities":{}, - "subscription":{}, - "triggers":[], - "desired_state":{"powered": true, "brightness": 1}, - "manufacturer_device_model": "lutron_p_pkg1_w_wh_d", - "manufacturer_device_id": null, - "device_manufacturer": "lutron", - "model_name": "Caseta Wireless Dimmer & Pico", - "upc_id": "3", - "hub_id": "11780", - "local_id": "8", - "radio_type": "lutron", - "linked_service_id": null, - "last_reading":{ - "brightness": 1, - "brightness_updated_at": 1417823487.490747, - "connection": true, - "connection_updated_at": 1417823487.4907365, - "powered": true, - "powered_updated_at": 1417823487.4907532, - "desired_powered": true, - "desired_powered_updated_at": 1417823485.054675, - "desired_brightness": 1, - "desired_brightness_updated_at": 1417409293.2591703 - }, - "lat_lng":[38.429962, -122.653715], - "location": "", - "order": 0 - - """ - jsonState = {} - - def __init__(self, ajsonobj): - super().__init__(ajsonobj, "light_bulbs") - - def deviceId(self): - return self.jsonState.get('light_bulb_id', self.name()) - - def brightness(self): - return self._last_reading.get('brightness') - - def setState(self, state, brightness=None): - """ - :param state: a boolean of true (on) or false ('off') - :return: nothing - """ - urlString = baseUrl + "/light_bulbs/%s" % self.deviceId() - values = { - "desired_state": { - "powered": state - } - } - - if brightness is not None: - values["desired_state"]["brightness"] = brightness - - urlString = baseUrl + "/light_bulbs/%s" % self.deviceId() - arequest = requests.put(urlString, data=json.dumps(values), headers=headers) - self._updateStateFromResponse(arequest.json()) - - self._last_call = (time.time(), state) - - def __repr__(self): - return "" % ( - self.name(), self.deviceId(), self.state()) - - -def get_devices(filter, constructor): - arequestUrl = baseUrl + "/users/me/wink_devices" - j = requests.get(arequestUrl, headers=headers).json() - - items = j.get('data') - - devices = [] - for item in items: - id = item.get(filter) - if (id is not None and item.get("hidden_at") is None): - devices.append(constructor(item)) - - return devices - -def get_bulbs(): - return get_devices('light_bulb_id', wink_bulb) - -def get_switches(): - return get_devices('binary_switch_id', wink_binary_switch) - -def get_sensors(): - return get_devices('sensor_pod_id', wink_sensor_pod) - -def is_token_set(): - """ Returns if an auth token has been set. """ - return bool(headers) - - -def set_bearer_token(token): - global headers - - headers = { - "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token) - } - -if __name__ == "__main__": - sw = get_bulbs() - lamp = sw[3] - lamp.setState(False) diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 4d92df43282..286eed4654e 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -2,16 +2,14 @@ Helper methods for components within Home Assistant. """ from homeassistant.loader import get_component -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_PLATFORM, DEVICE_DEFAULT_NAME) from homeassistant.util import ensure_unique_string, slugify -# Deprecated 3/5/2015 - Moved to homeassistant.helpers.entity -# pylint: disable=unused-import -from .entity import Entity as Device, ToggleEntity as ToggleDevice # noqa - def generate_entity_id(entity_id_format, name, current_ids=None, hass=None): """ Generate a unique entity ID based on given entity IDs or used ids. """ + name = name.lower() or DEVICE_DEFAULT_NAME.lower() if current_ids is None: if hass is None: raise RuntimeError("Missing required parameter currentids or hass") diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py deleted file mode 100644 index 4c713693c43..00000000000 --- a/homeassistant/helpers/device.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Deprecated since 3/21/2015 - please use helpers.entity -""" -import logging - -# pylint: disable=unused-import -from .entity import Entity as Device, ToggleEntity as ToggleDevice # noqa - -logging.getLogger(__name__).warning( - 'This file is deprecated. Please use helpers.entity') diff --git a/homeassistant/helpers/device_component.py b/homeassistant/helpers/device_component.py deleted file mode 100644 index 248297a9694..00000000000 --- a/homeassistant/helpers/device_component.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Deprecated since 3/21/2015 - please use helpers.entity_component -""" -import logging - -# pylint: disable=unused-import -from .entity_component import EntityComponent as DeviceComponent # noqa - -logging.getLogger(__name__).warning( - 'This file is deprecated. Please use helpers.entity_component') diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a8ee712b0f7..b29379049d3 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -5,19 +5,28 @@ homeassistant.helpers.entity Provides ABC for entities in HA. """ -from homeassistant import NoEntitySpecifiedError +from collections import defaultdict + +from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, - DEVICE_DEFAULT_NAME, TEMP_CELCIUS, TEMP_FAHRENHEIT) + ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, ATTR_HIDDEN, + STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, TEMP_CELCIUS, + TEMP_FAHRENHEIT) + +# Dict mapping entity_id to a boolean that overwrites the hidden property +_OVERWRITE = defaultdict(dict) class Entity(object): """ ABC for Home Assistant entities. """ # pylint: disable=no-self-use - hass = None - entity_id = None + _hidden = False + + # SAFE TO OVERWRITE + # The properties and methods here are safe to overwrite when inherting this + # class. These may be used to customize the behavior of the entity. @property def should_poll(self): @@ -52,6 +61,20 @@ class Entity(object): """ Unit of measurement of this entity, if any. """ return None + @property + def hidden(self): + """ Suggestion if the entity should be hidden from UIs. """ + return self._hidden + + @hidden.setter + def hidden(self, val): + """ Sets the suggestion for visibility. """ + self._hidden = bool(val) + + def update(self): + """ Retrieve latest state. """ + pass + # DEPRECATION NOTICE: # Device is moving from getters to properties. # For now the new properties will call the old functions @@ -69,9 +92,13 @@ class Entity(object): """ Returns optional state attributes. """ return None - def update(self): - """ Retrieve latest state. """ - pass + # DO NOT OVERWRITE + # These properties and methods are either managed by Home Assistant or they + # are used to perform a very specific function. Overwriting these may + # produce undesirable effects in the entity's operation. + + hass = None + entity_id = None def update_ha_state(self, force_refresh=False): """ @@ -97,6 +124,16 @@ class Entity(object): if ATTR_UNIT_OF_MEASUREMENT not in attr and self.unit_of_measurement: attr[ATTR_UNIT_OF_MEASUREMENT] = self.unit_of_measurement + if self.hidden: + attr[ATTR_HIDDEN] = self.hidden + + # overwrite properties that have been set in the config file + attr.update(_OVERWRITE.get(self.entity_id, {})) + + # remove hidden property if false so it won't show up + if not attr.get(ATTR_HIDDEN, True): + attr.pop(ATTR_HIDDEN) + # Convert temperature if we detect one if attr.get(ATTR_UNIT_OF_MEASUREMENT) in (TEMP_CELCIUS, TEMP_FAHRENHEIT): @@ -115,6 +152,20 @@ class Entity(object): def __repr__(self): return "".format(self.name, self.state) + @staticmethod + def overwrite_attribute(entity_id, attrs, vals): + """ + Overwrite any attribute of an entity. + This function should receive a list of attributes and a + list of values. Set attribute to None to remove any overwritten + value in place. + """ + for attr, val in zip(attrs, vals): + if val is None: + _OVERWRITE[entity_id.lower()].pop(attr, None) + else: + _OVERWRITE[entity_id.lower()][attr] = val + class ToggleEntity(Entity): """ ABC for entities that can be turned on and off. """ diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 1723091d5c2..80084178fe0 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -4,9 +4,10 @@ homeassistant.helpers.entity_component Provides helpers for components that manage entities. """ -from homeassistant.loader import get_component +from homeassistant.bootstrap import prepare_setup_platform from homeassistant.helpers import ( generate_entity_id, config_per_platform, extract_entity_ids) +from homeassistant.helpers.event import track_utc_time_change from homeassistant.components import group, discovery from homeassistant.const import ATTR_ENTITY_ID @@ -35,12 +36,16 @@ class EntityComponent(object): self.group = None self.is_polling = False + self.config = None + def setup(self, config): """ Sets up a full entity component: - Loads the platforms from the config - Will listen for supported discovered platforms """ + self.config = config + # Look in config for Domain, Domain 2, Domain 3 etc and load them for p_type, p_config in \ config_per_platform(config, self.domain, self.logger): @@ -111,39 +116,26 @@ class EntityComponent(object): self.is_polling = True - self.hass.track_time_change( - self._update_entity_states, + track_utc_time_change( + self.hass, self._update_entity_states, second=range(0, 60, self.scan_interval)) - def _setup_platform(self, platform_type, config, discovery_info=None): + def _setup_platform(self, platform_type, platform_config, + discovery_info=None): """ Tries to setup a platform for this component. """ - platform_name = '{}.{}'.format(self.domain, platform_type) - platform = get_component(platform_name) + platform = prepare_setup_platform( + self.hass, self.config, self.domain, platform_type) if platform is None: - self.logger.error('Unable to find platform %s', platform_type) return + platform_name = '{}.{}'.format(self.domain, platform_type) + try: platform.setup_platform( - self.hass, config, self.add_entities, discovery_info) + self.hass, platform_config, self.add_entities, discovery_info) self.hass.config.components.append(platform_name) - - except AttributeError: - # AttributeError if setup_platform does not exist - # Support old deprecated method for now - 3/1/2015 - if hasattr(platform, 'get_devices'): - self.logger.warning( - "Please upgrade %s to return new entities using " - "setup_platform. See %s/demo.py for an example.", - platform_name, self.domain) - self.add_entities(platform.get_devices(self.hass, config)) - - else: - self.logger.exception( - "Error while setting up platform %s", platform_type) - except Exception: # pylint: disable=broad-except self.logger.exception( - "Error while setting up platform %s", platform_type) + 'Error while setting up platform %s', platform_type) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py new file mode 100644 index 00000000000..60377fd1f5d --- /dev/null +++ b/homeassistant/helpers/event.py @@ -0,0 +1,163 @@ +""" +Helpers for listening to events +""" +import functools as ft + +from ..util import dt as dt_util +from ..const import ( + ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) + + +def track_state_change(hass, entity_ids, action, from_state=None, + to_state=None): + """ + Track specific state changes. + entity_ids, from_state and to_state can be string or list. + Use list to match multiple. + + Returns the listener that listens on the bus for EVENT_STATE_CHANGED. + Pass the return value into hass.bus.remove_listener to remove it. + """ + from_state = _process_match_param(from_state) + to_state = _process_match_param(to_state) + + # Ensure it is a lowercase list with entity ids we want to match on + if isinstance(entity_ids, str): + entity_ids = (entity_ids.lower(),) + else: + entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) + + @ft.wraps(action) + def state_change_listener(event): + """ The listener that listens for specific state changes. """ + if event.data['entity_id'] not in entity_ids: + return + + if 'old_state' in event.data: + old_state = event.data['old_state'].state + else: + old_state = None + + if _matcher(old_state, from_state) and \ + _matcher(event.data['new_state'].state, to_state): + + action(event.data['entity_id'], + event.data.get('old_state'), + event.data['new_state']) + + hass.bus.listen(EVENT_STATE_CHANGED, state_change_listener) + + return state_change_listener + + +def track_point_in_time(hass, action, point_in_time): + """ + Adds a listener that fires once after a spefic point in time. + """ + utc_point_in_time = dt_util.as_utc(point_in_time) + + @ft.wraps(action) + def utc_converter(utc_now): + """ Converts passed in UTC now to local now. """ + action(dt_util.as_local(utc_now)) + + return track_point_in_utc_time(hass, utc_converter, utc_point_in_time) + + +def track_point_in_utc_time(hass, action, point_in_time): + """ + Adds a listener that fires once after a specific point in UTC time. + """ + # Ensure point_in_time is UTC + point_in_time = dt_util.as_utc(point_in_time) + + @ft.wraps(action) + def point_in_time_listener(event): + """ Listens for matching time_changed events. """ + now = event.data[ATTR_NOW] + + if now >= point_in_time and \ + not hasattr(point_in_time_listener, 'run'): + + # Set variable so that we will never run twice. + # Because the event bus might have to wait till a thread comes + # available to execute this listener it might occur that the + # listener gets lined up twice to be executed. This will make + # sure the second time it does nothing. + point_in_time_listener.run = True + + hass.bus.remove_listener(EVENT_TIME_CHANGED, + point_in_time_listener) + + action(now) + + hass.bus.listen(EVENT_TIME_CHANGED, point_in_time_listener) + return point_in_time_listener + + +# pylint: disable=too-many-arguments +def track_utc_time_change(hass, action, year=None, month=None, day=None, + hour=None, minute=None, second=None, local=False): + """ Adds a listener that will fire if time matches a pattern. """ + # We do not have to wrap the function with time pattern matching logic + # if no pattern given + if all(val is None for val in (year, month, day, hour, minute, second)): + @ft.wraps(action) + def time_change_listener(event): + """ Fires every time event that comes in. """ + action(event.data[ATTR_NOW]) + + hass.bus.listen(EVENT_TIME_CHANGED, time_change_listener) + return time_change_listener + + pmp = _process_match_param + year, month, day = pmp(year), pmp(month), pmp(day) + hour, minute, second = pmp(hour), pmp(minute), pmp(second) + + @ft.wraps(action) + def pattern_time_change_listener(event): + """ Listens for matching time_changed events. """ + now = event.data[ATTR_NOW] + + if local: + now = dt_util.as_local(now) + + mat = _matcher + + if mat(now.year, year) and \ + mat(now.month, month) and \ + mat(now.day, day) and \ + mat(now.hour, hour) and \ + mat(now.minute, minute) and \ + mat(now.second, second): + + action(now) + + hass.bus.listen(EVENT_TIME_CHANGED, pattern_time_change_listener) + return pattern_time_change_listener + + +# pylint: disable=too-many-arguments +def track_time_change(hass, action, year=None, month=None, day=None, + hour=None, minute=None, second=None): + """ Adds a listener that will fire if UTC time matches a pattern. """ + track_utc_time_change(hass, action, year, month, day, hour, minute, second, + local=True) + + +def _process_match_param(parameter): + """ Wraps parameter in a tuple if it is not one and returns it. """ + if parameter is None or parameter == MATCH_ALL: + return MATCH_ALL + elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): + return (parameter,) + else: + return tuple(parameter) + + +def _matcher(subject, pattern): + """ Returns True if subject matches the pattern. + + Pattern is either a tuple of allowed subjects or a `MATCH_ALL`. + """ + return MATCH_ALL == pattern or subject in pattern diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 43d48898303..d87ee48930c 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -5,9 +5,9 @@ homeassistant.helpers.state Helpers that help with state related things. """ import logging -from datetime import datetime -from homeassistant import State +from homeassistant.core import State +import homeassistant.util.dt as dt_util from homeassistant.const import ( STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID) @@ -26,11 +26,20 @@ class TrackStates(object): self.states = [] def __enter__(self): - self.now = datetime.now() + self.now = dt_util.utcnow() return self.states def __exit__(self, exc_type, exc_value, traceback): - self.states.extend(self.hass.states.get_since(self.now)) + self.states.extend(get_changed_since(self.hass.states.all(), self.now)) + + +def get_changed_since(states, utc_point_in_time): + """ + Returns all states that have been changed since utc_point_in_time. + """ + point_in_time = dt_util.strip_microseconds(utc_point_in_time) + + return [state for state in states if state.last_updated >= point_in_time] def reproduce_state(hass, states, blocking=False): diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py new file mode 100644 index 00000000000..eaf1f78d927 --- /dev/null +++ b/homeassistant/helpers/temperature.py @@ -0,0 +1,19 @@ +""" +homeassistant.helpers.temperature +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Methods to help handle temperature in Home Assistant. +""" + +from homeassistant.const import TEMP_CELCIUS +import homeassistant.util.temperature as temp_util + + +def convert(temperature, unit, to_unit): + """ Converts temperature to correct unit. """ + if unit == to_unit: + return temperature + elif unit == TEMP_CELCIUS: + return temp_util.celcius_to_fahrenheit(temperature) + + return temp_util.fahrenheit_to_celcius(temperature) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index d38e0df4465..7b755214252 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -61,11 +61,10 @@ def prepare(hass): # python components. If this assumption is not true, HA won't break, # just might output more errors. for fil in os.listdir(custom_path): - if os.path.isdir(os.path.join(custom_path, fil)): - if fil != '__pycache__': - AVAILABLE_COMPONENTS.append( - 'custom_components.{}'.format(fil)) - + if fil == '__pycache__': + continue + elif os.path.isdir(os.path.join(custom_path, fil)): + AVAILABLE_COMPONENTS.append('custom_components.{}'.format(fil)) else: # For files we will strip out .py extension AVAILABLE_COMPONENTS.append( @@ -167,9 +166,10 @@ def load_order_components(components): key=lambda order: 'group' in order): load_order.update(comp_load_order) - # Push recorder to first place in load order - if 'recorder' in load_order: - load_order.promote('recorder') + # Push some to first place in load order + for comp in ('recorder', 'introduction'): + if comp in load_order: + load_order.promote(comp) return load_order @@ -195,24 +195,24 @@ def _load_order_component(comp_name, load_order, loading): for dependency in component.DEPENDENCIES: # Check not already loaded - if dependency not in load_order: - # If we are already loading it, we have a circular dependency - if dependency in loading: - _LOGGER.error('Circular dependency detected: %s -> %s', - comp_name, dependency) + if dependency in load_order: + continue - return OrderedSet() + # If we are already loading it, we have a circular dependency + if dependency in loading: + _LOGGER.error('Circular dependency detected: %s -> %s', + comp_name, dependency) + return OrderedSet() - dep_load_order = _load_order_component( - dependency, load_order, loading) + dep_load_order = _load_order_component(dependency, load_order, loading) - # length == 0 means error loading dependency or children - if len(dep_load_order) == 0: - _LOGGER.error('Error loading %s dependency: %s', - comp_name, dependency) - return OrderedSet() + # length == 0 means error loading dependency or children + if len(dep_load_order) == 0: + _LOGGER.error('Error loading %s dependency: %s', + comp_name, dependency) + return OrderedSet() - load_order.update(dep_load_order) + load_order.update(dep_load_order) load_order.add(comp_name) loading.remove(comp_name) diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 19aa86f67b9..2193ede86e7 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -17,7 +17,8 @@ import urllib.parse import requests -import homeassistant as ha +import homeassistant.core as ha +from homeassistant.exceptions import HomeAssistantError import homeassistant.bootstrap as bootstrap from homeassistant.const import ( @@ -84,12 +85,12 @@ class API(object): except requests.exceptions.ConnectionError: _LOGGER.exception("Error connecting to server") - raise ha.HomeAssistantError("Error connecting to server") + raise HomeAssistantError("Error connecting to server") except requests.exceptions.Timeout: error = "Timeout when talking to {}".format(self.host) _LOGGER.exception(error) - raise ha.HomeAssistantError(error) + raise HomeAssistantError(error) def __repr__(self): return "API({}, {}, {})".format( @@ -102,7 +103,7 @@ class HomeAssistant(ha.HomeAssistant): def __init__(self, remote_api, local_api=None): if not remote_api.validate_api(): - raise ha.HomeAssistantError( + raise HomeAssistantError( "Remote API at {}:{} not valid: {}".format( remote_api.host, remote_api.port, remote_api.status)) @@ -120,10 +121,11 @@ class HomeAssistant(ha.HomeAssistant): def start(self): # Ensure a local API exists to connect with remote if self.config.api is None: - bootstrap.setup_component(self, 'http') - bootstrap.setup_component(self, 'api') + if not bootstrap.setup_component(self, 'api'): + raise HomeAssistantError( + 'Unable to setup local API to receive events') - ha.Timer(self) + ha.create_timer(self) self.bus.fire(ha.EVENT_HOMEASSISTANT_START, origin=ha.EventOrigin.remote) @@ -131,7 +133,7 @@ class HomeAssistant(ha.HomeAssistant): # Setup that events from remote_api get forwarded to local_api # Do this after we fire START, otherwise HTTP is not started if not connect_remote_events(self.remote_api, self.config.api): - raise ha.HomeAssistantError(( + raise HomeAssistantError(( 'Could not setup event forwarding from api {} to ' 'local api {}').format(self.remote_api, self.config.api)) @@ -262,7 +264,7 @@ class JSONEncoder(json.JSONEncoder): def default(self, obj): """ Converts Home Assistant objects and hands other objects to the original method. """ - if isinstance(obj, (ha.State, ha.Event)): + if hasattr(obj, 'as_dict'): return obj.as_dict() try: @@ -292,7 +294,7 @@ def validate_api(api): else: return APIStatus.UNKNOWN - except ha.HomeAssistantError: + except HomeAssistantError: return APIStatus.CANNOT_CONNECT @@ -317,7 +319,7 @@ def connect_remote_events(from_api, to_api): return False - except ha.HomeAssistantError: + except HomeAssistantError: _LOGGER.exception("Error setting up event forwarding") return False @@ -341,7 +343,7 @@ def disconnect_remote_events(from_api, to_api): return False - except ha.HomeAssistantError: + except HomeAssistantError: _LOGGER.exception("Error removing an event forwarder") return False @@ -353,7 +355,7 @@ def get_event_listeners(api): return req.json() if req.status_code == 200 else {} - except (ha.HomeAssistantError, ValueError): + except (HomeAssistantError, ValueError): # ValueError if req.json() can't parse the json _LOGGER.exception("Unexpected result retrieving event listeners") @@ -370,7 +372,7 @@ def fire_event(api, event_type, data=None): _LOGGER.error("Error firing event: %d - %d", req.status_code, req.text) - except ha.HomeAssistantError: + except HomeAssistantError: _LOGGER.exception("Error firing event") @@ -386,7 +388,7 @@ def get_state(api, entity_id): return ha.State.from_dict(req.json()) \ if req.status_code == 200 else None - except (ha.HomeAssistantError, ValueError): + except (HomeAssistantError, ValueError): # ValueError if req.json() can't parse the json _LOGGER.exception("Error fetching state") @@ -403,7 +405,7 @@ def get_states(api): return [ha.State.from_dict(item) for item in req.json()] - except (ha.HomeAssistantError, ValueError, AttributeError): + except (HomeAssistantError, ValueError, AttributeError): # ValueError if req.json() can't parse the json _LOGGER.exception("Error fetching states") @@ -433,7 +435,7 @@ def set_state(api, entity_id, new_state, attributes=None): else: return True - except ha.HomeAssistantError: + except HomeAssistantError: _LOGGER.exception("Error setting state") return False @@ -456,7 +458,7 @@ def get_services(api): return req.json() if req.status_code == 200 else {} - except (ha.HomeAssistantError, ValueError): + except (HomeAssistantError, ValueError): # ValueError if req.json() can't parse the json _LOGGER.exception("Got unexpected services result") @@ -474,5 +476,5 @@ def call_service(api, domain, service, service_data=None): _LOGGER.error("Error calling service: %d - %s", req.status_code, req.text) - except ha.HomeAssistantError: + except HomeAssistantError: _LOGGER.exception("Error calling service") diff --git a/homeassistant/util.py b/homeassistant/util/__init__.py similarity index 85% rename from homeassistant/util.py rename to homeassistant/util/__init__.py index ae5fcea609d..2e399384e63 100644 --- a/homeassistant/util.py +++ b/homeassistant/util/__init__.py @@ -8,7 +8,7 @@ import collections from itertools import chain import threading import queue -from datetime import datetime, timedelta +from datetime import datetime import re import enum import socket @@ -16,12 +16,13 @@ import random import string from functools import wraps +from .dt import datetime_to_local_str, utcnow + + RE_SANITIZE_FILENAME = re.compile(r'(~|\.\.|/|\\)') RE_SANITIZE_PATH = re.compile(r'(~|\.(\.)+)') RE_SLUGIFY = re.compile(r'[^A-Za-z0-9_]+') -DATE_STR_FORMAT = "%H:%M:%S %d-%m-%Y" - def sanitize_filename(filename): """ Sanitizes a filename by removing .. / and \\. """ @@ -40,33 +41,6 @@ def slugify(text): return RE_SLUGIFY.sub("", text) -def datetime_to_str(dattim): - """ Converts datetime to a string format. - - @rtype : str - """ - return dattim.strftime(DATE_STR_FORMAT) - - -def str_to_datetime(dt_str): - """ Converts a string to a datetime object. - - @rtype: datetime - """ - try: - return datetime.strptime(dt_str, DATE_STR_FORMAT) - except ValueError: # If dt_str did not match our format - return None - - -def strip_microseconds(dattim): - """ Returns a copy of dattime object but with microsecond set to 0. """ - if dattim.microsecond: - return dattim - timedelta(microseconds=dattim.microsecond) - else: - return dattim - - def split_entity_id(entity_id): """ Splits a state entity_id into domain, object_id. """ return entity_id.split(".", 1) @@ -79,51 +53,11 @@ def repr_helper(inp): repr_helper(key)+"="+repr_helper(item) for key, item in inp.items()) elif isinstance(inp, datetime): - return datetime_to_str(inp) + return datetime_to_local_str(inp) else: return str(inp) -# Taken from: http://www.cse.unr.edu/~quiroz/inc/colortransforms.py -# License: Code is given as is. Use at your own risk and discretion. -# pylint: disable=invalid-name -def color_RGB_to_xy(R, G, B): - """ Convert from RGB color to XY color. """ - if R + G + B == 0: - return 0, 0 - - var_R = (R / 255.) - var_G = (G / 255.) - var_B = (B / 255.) - - if var_R > 0.04045: - var_R = ((var_R + 0.055) / 1.055) ** 2.4 - else: - var_R /= 12.92 - - if var_G > 0.04045: - var_G = ((var_G + 0.055) / 1.055) ** 2.4 - else: - var_G /= 12.92 - - if var_B > 0.04045: - var_B = ((var_B + 0.055) / 1.055) ** 2.4 - else: - var_B /= 12.92 - - var_R *= 100 - var_G *= 100 - var_B *= 100 - - # Observer. = 2 deg, Illuminant = D65 - X = var_R * 0.4124 + var_G * 0.3576 + var_B * 0.1805 - Y = var_R * 0.2126 + var_G * 0.7152 + var_B * 0.0722 - Z = var_R * 0.0193 + var_G * 0.1192 + var_B * 0.9505 - - # Convert XYZ to xy, see CIE 1931 color space on wikipedia - return X / (X + Y + Z), Y / (X + Y + Z) - - def convert(value, to_type, default=None): """ Converts value to to_type, returns default if fails. """ try: @@ -156,13 +90,12 @@ def get_local_ip(): # Use Google Public DNS server to determine own IP sock.connect(('8.8.8.8', 80)) - ip_addr = sock.getsockname()[0] - sock.close() - - return ip_addr + return sock.getsockname()[0] except socket.error: return socket.gethostbyname(socket.gethostname()) + finally: + sock.close() # Taken from http://stackoverflow.com/a/23728630 @@ -436,7 +369,7 @@ class ThreadPool(object): return # Add to current running jobs - job_log = (datetime.now(), job) + job_log = (utcnow(), job) self.current_jobs.append(job_log) # Do the job diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py new file mode 100644 index 00000000000..5f967fa87b9 --- /dev/null +++ b/homeassistant/util/color.py @@ -0,0 +1,41 @@ +"""Color util methods.""" + + +# Taken from: http://www.cse.unr.edu/~quiroz/inc/colortransforms.py +# License: Code is given as is. Use at your own risk and discretion. +# pylint: disable=invalid-name +def color_RGB_to_xy(R, G, B): + """ Convert from RGB color to XY color. """ + if R + G + B == 0: + return 0, 0 + + var_R = (R / 255.) + var_G = (G / 255.) + var_B = (B / 255.) + + if var_R > 0.04045: + var_R = ((var_R + 0.055) / 1.055) ** 2.4 + else: + var_R /= 12.92 + + if var_G > 0.04045: + var_G = ((var_G + 0.055) / 1.055) ** 2.4 + else: + var_G /= 12.92 + + if var_B > 0.04045: + var_B = ((var_B + 0.055) / 1.055) ** 2.4 + else: + var_B /= 12.92 + + var_R *= 100 + var_G *= 100 + var_B *= 100 + + # Observer. = 2 deg, Illuminant = D65 + X = var_R * 0.4124 + var_G * 0.3576 + var_B * 0.1805 + Y = var_R * 0.2126 + var_G * 0.7152 + var_B * 0.0722 + Z = var_R * 0.0193 + var_G * 0.1192 + var_B * 0.9505 + + # Convert XYZ to xy, see CIE 1931 color space on wikipedia + return X / (X + Y + Z), Y / (X + Y + Z) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py new file mode 100644 index 00000000000..d8fecf20db8 --- /dev/null +++ b/homeassistant/util/dt.py @@ -0,0 +1,133 @@ +""" +homeassistant.util.dt +~~~~~~~~~~~~~~~~~~~~~ + +Provides helper methods to handle the time in HA. + +""" +import datetime as dt + +import pytz + +DATETIME_STR_FORMAT = "%H:%M:%S %d-%m-%Y" +DATE_STR_FORMAT = "%Y-%m-%d" +TIME_STR_FORMAT = "%H:%M" +UTC = DEFAULT_TIME_ZONE = pytz.utc + + +def set_default_time_zone(time_zone): + """ Sets a default time zone to be used when none is specified. """ + global DEFAULT_TIME_ZONE # pylint: disable=global-statement + + assert isinstance(time_zone, dt.tzinfo) + + DEFAULT_TIME_ZONE = time_zone + + +def get_time_zone(time_zone_str): + """ Get time zone from string. Return None if unable to determine. """ + try: + return pytz.timezone(time_zone_str) + except pytz.exceptions.UnknownTimeZoneError: + return None + + +def utcnow(): + """ Get now in UTC time. """ + return dt.datetime.now(UTC) + + +def now(time_zone=None): + """ Get now in specified time zone. """ + return dt.datetime.now(time_zone or DEFAULT_TIME_ZONE) + + +def as_utc(dattim): + """ Return a datetime as UTC time. + Assumes datetime without tzinfo to be in the DEFAULT_TIME_ZONE. """ + if dattim.tzinfo == UTC: + return dattim + elif dattim.tzinfo is None: + dattim = dattim.replace(tzinfo=DEFAULT_TIME_ZONE) + + return dattim.astimezone(UTC) + + +def as_local(dattim): + """ Converts a UTC datetime object to local time_zone. """ + if dattim.tzinfo == DEFAULT_TIME_ZONE: + return dattim + elif dattim.tzinfo is None: + dattim = dattim.replace(tzinfo=UTC) + + return dattim.astimezone(DEFAULT_TIME_ZONE) + + +def utc_from_timestamp(timestamp): + """ Returns a UTC time from a timestamp. """ + return dt.datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) + + +def start_of_local_day(dt_or_d=None): + """ Return local datetime object of start of day from date or datetime. """ + if dt_or_d is None: + dt_or_d = now().date() + elif isinstance(dt_or_d, dt.datetime): + dt_or_d = dt_or_d.date() + + return dt.datetime.combine(dt_or_d, dt.time()).replace( + tzinfo=DEFAULT_TIME_ZONE) + + +def datetime_to_local_str(dattim): + """ Converts datetime to specified time_zone and returns a string. """ + return datetime_to_str(as_local(dattim)) + + +def datetime_to_str(dattim): + """ Converts datetime to a string format. + + @rtype : str + """ + return dattim.strftime(DATETIME_STR_FORMAT) + + +def datetime_to_time_str(dattim): + """ Converts datetime to a string containing only the time. + + @rtype : str + """ + return dattim.strftime(TIME_STR_FORMAT) + + +def datetime_to_date_str(dattim): + """ Converts datetime to a string containing only the date. + + @rtype : str + """ + return dattim.strftime(DATE_STR_FORMAT) + + +def str_to_datetime(dt_str): + """ Converts a string to a UTC datetime object. + + @rtype: datetime + """ + try: + return dt.datetime.strptime( + dt_str, DATETIME_STR_FORMAT).replace(tzinfo=pytz.utc) + except ValueError: # If dt_str did not match our format + return None + + +def date_str_to_date(dt_str): + """ Converts a date string to a date object. """ + try: + return dt.datetime.strptime(dt_str, DATE_STR_FORMAT).date() + except ValueError: # If dt_str did not match our format + return None + + +def strip_microseconds(dattim): + """ Returns a copy of dattime object but with microsecond set to 0. """ + return dattim.replace(microsecond=0) diff --git a/homeassistant/util/environment.py b/homeassistant/util/environment.py new file mode 100644 index 00000000000..53364899030 --- /dev/null +++ b/homeassistant/util/environment.py @@ -0,0 +1,9 @@ +""" Environement helpers. """ +import sys + + +def is_virtual(): + """ Return if we run in a virtual environtment. """ + # Check supports venv && virtualenv + return (getattr(sys, 'base_prefix', sys.prefix) != sys.prefix or + hasattr(sys, 'real_prefix')) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py new file mode 100644 index 00000000000..8cc008613cb --- /dev/null +++ b/homeassistant/util/location.py @@ -0,0 +1,30 @@ +"""Module with location helpers.""" +import collections + +import requests + + +LocationInfo = collections.namedtuple( + "LocationInfo", + ['ip', 'country_code', 'country_name', 'region_code', 'region_name', + 'city', 'zip_code', 'time_zone', 'latitude', 'longitude', + 'use_fahrenheit']) + + +def detect_location_info(): + """ Detect location information. """ + try: + raw_info = requests.get( + 'https://freegeoip.net/json/', timeout=5).json() + except requests.RequestException: + return + + data = {key: raw_info.get(key) for key in LocationInfo._fields} + + # From Wikipedia: Fahrenheit is used in the Bahamas, Belize, + # the Cayman Islands, Palau, and the United States and associated + # territories of American Samoa and the U.S. Virgin Islands + data['use_fahrenheit'] = data['country_code'] in ( + 'BS', 'BZ', 'KY', 'PW', 'US', 'AS', 'VI') + + return LocationInfo(**data) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py new file mode 100644 index 00000000000..5d32c087efe --- /dev/null +++ b/homeassistant/util/package.py @@ -0,0 +1,58 @@ +"""Helpers to install PyPi packages.""" +import os +import logging +import pkg_resources +import subprocess +import sys +import threading +from urllib.parse import urlparse + +_LOGGER = logging.getLogger(__name__) +INSTALL_LOCK = threading.Lock() + + +def install_package(package, upgrade=True, target=None): + """Install a package on PyPi. Accepts pip compatible package strings. + Return boolean if install successfull.""" + # Not using 'import pip; pip.main([])' because it breaks the logger + args = [sys.executable, '-m', 'pip', 'install', '--quiet', package] + + if upgrade: + args.append('--upgrade') + if target: + args += ['--target', os.path.abspath(target)] + + with INSTALL_LOCK: + if check_package_exists(package, target): + return True + + _LOGGER.info('Attempting install of %s', package) + try: + return 0 == subprocess.call(args) + except subprocess.SubprocessError: + return False + + +def check_package_exists(package, target=None): + """Check if a package exists. + Returns True when the requirement is met. + Returns False when the package is not installed or doesn't meet req.""" + try: + req = pkg_resources.Requirement.parse(package) + except ValueError: + # This is a zip file + req = pkg_resources.Requirement.parse(urlparse(package).fragment) + + if target: + work_set = pkg_resources.WorkingSet([target]) + search_fun = work_set.find + + else: + search_fun = pkg_resources.get_distribution + + try: + result = search_fun(req) + except (pkg_resources.DistributionNotFound, pkg_resources.VersionConflict): + return False + + return bool(result) diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py new file mode 100644 index 00000000000..658639aae55 --- /dev/null +++ b/homeassistant/util/temperature.py @@ -0,0 +1,16 @@ +""" +homeassistant.util.temperature +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Temperature util functions. +""" + + +def fahrenheit_to_celcius(fahrenheit): + """ Convert a Fahrenheit temperature to Celcius. """ + return (fahrenheit - 32.0) / 1.8 + + +def celcius_to_fahrenheit(celcius): + """ Convert a Celcius temperature to Fahrenheit. """ + return celcius * 1.8 + 32.0 diff --git a/pylintrc b/pylintrc index a9994eb70f5..e8455cf4245 100644 --- a/pylintrc +++ b/pylintrc @@ -1,5 +1,4 @@ [MASTER] -ignore=external reports=no # Reasons disabled: @@ -9,13 +8,15 @@ reports=no # abstract-class-little-used - Prevents from setting right foundation # abstract-class-not-used - is flaky, should not show up but does # unused-argument - generic callbacks and setup methods create a lot of warnings +# global-statement - used for the on-demand requirement installation disable= locally-disabled, duplicate-code, cyclic-import, abstract-class-little-used, abstract-class-not-used, - unused-argument + unused-argument, + global-statement [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError diff --git a/requirements.txt b/requirements.txt index 9f0352ccef1..1b7d2396971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,41 +1,4 @@ -# required for Home Assistant core -requests>=2.0 -pyyaml>=3.11 - -# optional, needed for specific components - -# discovery -zeroconf>=0.16.0 - -# sun -pyephem>=3.7 - -# lights.hue -phue>=0.8 - -# media_player.cast -pychromecast>=0.5 - -# keyboard -pyuserinput>=0.1.9 - -# switch.tellstick, sensor.tellstick -tellcore-py>=1.0.4 - -# device_tracker.nmap -python-libnmap>=0.6.2 - -# notify.pushbullet -pushbullet.py>=0.7.1 - -# thermostat.nest -python-nest>=2.1 - -# z-wave -pydispatcher>=2.0.5 - -# sensor.systemmonitor -psutil>=2.2.1 - -#pushover notifications -python-pushover>=0.2 \ No newline at end of file +requests>=2,<3 +pyyaml>=3.11,<4 +pytz>=2015.4 +pip>=7.0.0 diff --git a/requirements_all.txt b/requirements_all.txt new file mode 100644 index 00000000000..f44b14d9ba7 --- /dev/null +++ b/requirements_all.txt @@ -0,0 +1,132 @@ +# Required for Home Assistant core +requests>=2,<3 +pyyaml>=3.11,<4 +pytz>=2015.4 +pip>=7.0.0 + +# Optional, needed for specific components + +# Sun (sun) +astral==0.8.1 + +# Philips Hue library (lights.hue) +phue==0.8 + +# Limitlessled/Easybulb/Milight library (lights.limitlessled) +ledcontroller==1.0.7 + +# Chromecast bindings (media_player.cast) +pychromecast==0.6.12 + +# Keyboard (keyboard) +pyuserinput==0.1.9 + +# Tellstick bindings (*.tellstick) +tellcore-py==1.0.4 + +# Nmap bindings (device_tracker.nmap) +python-nmap==0.4.1 + +# PushBullet bindings (notify.pushbullet) +pushbullet.py==0.7.1 + +# Nest Thermostat bindings (thermostat.nest) +python-nest==2.6.0 + +# Z-Wave (*.zwave) +pydispatcher==2.0.5 + +# ISY994 bindings (*.isy994) +PyISY==1.0.5 + +# PSutil (sensor.systemmonitor) +psutil==3.0.0 + +# Pushover bindings (notify.pushover) +python-pushover==0.2 + +# Transmission Torrent Client (*.transmission) +transmissionrpc==0.11 + +# OpenWeatherMap Web API (sensor.openweathermap) +pyowm==2.2.1 + +# XMPP Bindings (notify.xmpp) +sleekxmpp==1.3.1 +dnspython3==1.12.0 + +# Blockchain (sensor.bitcoin) +blockchain==1.1.2 + +# MPD Bindings (media_player.mpd) +python-mpd2==0.5.4 + +# Hikvision (switch.hikvisioncam) +hikvision==0.4 + +# console log coloring +colorlog==2.6.0 + +# JSON-RPC interface (media_player.kodi) +jsonrpc-requests==0.1 + +# Forecast.io Bindings (sensor.forecast) +python-forecastio==1.3.3 + +# Firmata Bindings (*.arduino) +PyMata==2.07a + +# Rfxtrx sensor (sensor.rfxtrx) +https://github.com/Danielhiversen/pyRFXtrx/archive/ec7a1aaddf8270db6e5da1c13d58c1547effd7cf.zip#RFXtrx==0.15 + +# Mysensors +https://github.com/theolind/pymysensors/archive/35b87d880147a34107da0d40cb815d75e6cb4af7.zip#pymysensors==0.2 + +# Netgear (device_tracker.netgear) +pynetgear==0.3 + +# Netdisco (discovery) +netdisco==0.3 + +# Wemo (switch.wemo) +pywemo==0.3 + +# Wink (*.wink) +https://github.com/balloob/python-wink/archive/c2b700e8ca866159566ecf5e644d9c297f69f257.zip#python-wink==0.1 + +# Slack notifier (notify.slack) +slacker==0.6.8 + +# Temper sensors (sensor.temper) +https://github.com/rkabadi/temper-python/archive/3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip#temperusb==1.2.3 + +# PyEdimax +https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 + +# RPI-GPIO platform (*.rpi_gpio) +# Uncomment for Raspberry Pi +# RPi.GPIO==0.5.11 + +# Adafruit temperature/humidity sensor +# uncomment on a Raspberry Pi / Beaglebone +# http://github.com/mala-zaba/Adafruit_Python_DHT/archive/4101340de8d2457dd194bca1e8d11cbfc237e919.zip#Adafruit_DHT==1.1.0 + +# PAHO MQTT Binding (mqtt) +paho-mqtt==1.1 + +# PyModbus (modbus) +https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 + +# Verisure (verisure) +https://github.com/persandstrom/python-verisure/archive/9873c4527f01b1ba1f72ae60f7f35854390d59be.zip#python-verisure==0.2.6 + +# Python tools for interacting with IFTTT Maker Channel (ifttt) +pyfttt==0.3 + +# sensor.sabnzbd +https://github.com/balloob/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 + +# switch.vera +# sensor.vera +# light.vera +https://github.com/balloob/home-assistant-vera-api/archive/a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip#python-vera==0.1 diff --git a/scripts/build_frontend b/scripts/build_frontend index 406aed45f46..9554e82256d 100755 --- a/scripts/build_frontend +++ b/scripts/build_frontend @@ -1,38 +1,24 @@ # Builds the frontend for production -# Call 'build_frontend demo' to build a demo frontend. # If current pwd is scripts, go 1 up. if [ ${PWD##*/} == "scripts" ]; then cd .. fi -scripts/build_js $1 +cd homeassistant/components/frontend/www_static/home-assistant-polymer +npm install +npm run frontend_prod -# To build the frontend, you need node, bower and vulcanize -# npm install -g bower vulcanize - -# Install dependencies -cd homeassistant/components/frontend/www_static/polymer -bower install -cd .. -cp polymer/bower_components/webcomponentsjs/webcomponents.min.js . - -# Let Polymer refer to the minified JS version before we compile -sed -i.bak 's/polymer\.js/polymer\.min\.js/' polymer/bower_components/polymer/polymer.html - -vulcanize -o frontend.html --inline --strip polymer/home-assistant.html - -# Revert back the change to the Polymer component -rm polymer/bower_components/polymer/polymer.html -mv polymer/bower_components/polymer/polymer.html.bak polymer/bower_components/polymer/polymer.html +cp bower_components/webcomponentsjs/webcomponents-lite.min.js .. +cp build/frontend.html .. # Generate the MD5 hash of the new frontend -cd .. +cd ../.. echo '""" DO NOT MODIFY. Auto-generated by build_frontend script """' > version.py if [ $(command -v md5) ]; then echo 'VERSION = "'`md5 -q www_static/frontend.html`'"' >> version.py elif [ $(command -v md5sum) ]; then echo 'VERSION = "'`md5sum www_static/frontend.html | cut -c-32`'"' >> version.py else - echo 'Could not find a MD5 utility' + echo 'Could not find an MD5 utility' fi diff --git a/scripts/build_js b/scripts/build_js deleted file mode 100755 index a75b48f9bf9..00000000000 --- a/scripts/build_js +++ /dev/null @@ -1,17 +0,0 @@ -# Builds the JS for production - -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi - -cd homeassistant/components/frontend/www_static/polymer/home-assistant-js - -npm install - -if [ "$1" = "demo" ]; then - echo "Building a demo mode build!" - npm run demo -else - npm run prod -fi diff --git a/scripts/check_style b/scripts/check_style index cacebba15a1..5fc8861b91a 100755 --- a/scripts/check_style +++ b/scripts/check_style @@ -5,5 +5,5 @@ if [ ${PWD##*/} == "scripts" ]; then cd .. fi +flake8 homeassistant pylint homeassistant -flake8 homeassistant --exclude bower_components,external diff --git a/scripts/dev_js b/scripts/dev_js deleted file mode 100755 index a62f0d85ef3..00000000000 --- a/scripts/dev_js +++ /dev/null @@ -1,11 +0,0 @@ -# Builds the JS for developing, rebuilds when files change - -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi - -cd homeassistant/components/frontend/www_static/polymer/home-assistant-js - -npm install -npm run dev diff --git a/scripts/get_entities.py b/scripts/get_entities.py new file mode 100755 index 00000000000..249a06f0d9b --- /dev/null +++ b/scripts/get_entities.py @@ -0,0 +1,97 @@ +#! /usr/bin/python +""" +Query the Home Assistant API for available entities then print them and any +desired attributes to the screen. +""" + +import sys +import getpass +import argparse +try: + from urllib2 import urlopen + PYTHON = 2 +except ImportError: + from urllib.request import urlopen + PYTHON = 3 +import json + + +def main(password, askpass, attrs, address, port): + """ fetch Home Assistant api json page and post process """ + # ask for password + if askpass: + password = getpass.getpass('Home Assistant API Password: ') + + # fetch API result + url = mk_url(address, port, password) + response = urlopen(url).read() + if PYTHON == 3: + response = response.decode('utf-8') + data = json.loads(response) + + # parse data + output = {'entity_id': []} + output.update([(attr, []) for attr in attrs]) + for item in data: + output['entity_id'].append(item['entity_id']) + for attr in attrs: + output[attr].append(item['attributes'].get(attr, '')) + + # output data + print_table(output, ['entity_id'] + attrs) + + +def print_table(data, columns): + """ format and print a table of data from a dictionary """ + # get column lengths + lengths = {} + for key, value in data.items(): + lengths[key] = max([len(str(val)) for val in value] + [len(key)]) + + # print header + for item in columns: + itemup = item.upper() + sys.stdout.write(itemup + ' ' * (lengths[item] - len(item) + 4)) + sys.stdout.write('\n') + + # print body + for ind in range(len(data[columns[0]])): + for item in columns: + val = str(data[item][ind]) + sys.stdout.write(val + ' ' * (lengths[item] - len(val) + 4)) + sys.stdout.write("\n") + + +def mk_url(address, port, password): + """ construct the url call for the api states page """ + url = '' + if address.startswith('http://'): + url += address + else: + url += 'http://' + address + url += ':' + port + '/api/states?' + if password is not None: + url += 'api_password=' + password + return url + + +if __name__ == "__main__": + all_options = {'password': None, 'askpass': False, 'attrs': [], + 'address': 'localhost', 'port': '8123'} + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('attrs', metavar='ATTRIBUTE', type=str, nargs='*', + help='an attribute to read from the state') + parser.add_argument('--password', dest='password', default=None, + type=str, help='API password for the HA server') + parser.add_argument('--ask-password', dest='askpass', default=False, + action='store_const', const=True, + help='prompt for HA API password') + parser.add_argument('--addr', dest='address', + default='localhost', type=str, + help='address of the HA server') + parser.add_argument('--port', dest='port', default='8123', + type=str, help='port that HA is hosting on') + + args = parser.parse_args() + main(args.password, args.askpass, args.attrs, args.address, args.port) diff --git a/scripts/hass-daemon b/scripts/hass-daemon new file mode 100644 index 00000000000..d11c2669e87 --- /dev/null +++ b/scripts/hass-daemon @@ -0,0 +1,101 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: hass +# Required-Start: $local_fs $network $named $time $syslog +# Required-Stop: $local_fs $network $named $time $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Description: Home\ Assistant +### END INIT INFO + +# /etc/init.d Service Script for Home Assistant +# Created with: https://gist.github.com/naholyr/4275302#file-new-service-sh +# +# Installation: +# 1) If any commands need to run before executing hass (like loading a +# virutal environment), put them in PRE_EXEC. This command must end with +# a semicolon. +# 2) Set RUN_AS to the username that should be used to execute hass. +# 3) Copy this script to /etc/init.d/ +# sudo cp hass-daemon /etc/init.d/hass-daemon +# sudo chmod +x /etc/init.d/hass-daemon +# 4) Register the daemon with Linux +# sudo update-rc.d hass-daemon defaults +# 5) Install this service +# sudo service hass-daemon install +# 6) Restart Machine +# +# After installation, HA should start automatically. If HA does not start, +# check the log file output for errors. +# /var/opt/homeassistant/home-assistant.log + +PRE_EXEC="" +RUN_AS="USER" +PID_FILE="/var/run/hass.pid" +CONFIG_DIR="/var/opt/homeassistant" +FLAGS="-v --config $CONFIG_DIR --pid-file $PID_FILE --daemon" + +start() { + if [ -f $PID_FILE ] && kill -0 $(cat $PID_FILE); then + echo 'Service already running' >&2 + return 1 + fi + echo 'Starting service…' >&2 + local CMD="$PRE_EXEC hass $FLAGS;" + su -c "$CMD" $RUN_AS + echo 'Service started' >&2 +} + +stop() { + if [ ! -f "$PID_FILE" ] || ! kill -0 $(cat "$PID_FILE"); then + echo 'Service not running' >&2 + return 1 + fi + echo 'Stopping service…' >&2 + kill -3 $(cat "$PID_FILE") + echo 'Service stopped' >&2 +} + +install() { + echo "Installing Home Assistant Daemon (hass-daemon)" + echo "999999" > $PID_FILE + chown $RUN_AS $PID_FILE + mkdir -p $CONFIG_DIR + chown $RUN_AS $CONFIG_DIR +} + +uninstall() { + echo -n "Are you really sure you want to uninstall this service? That cannot be undone. [yes|No] " + local SURE + read SURE + if [ "$SURE" = "yes" ]; then + stop + rm -fv "$PID_FILE" + echo "Notice: The config directory has not been removed" + echo $CONFIG_DIR + update-rc.d -f hass-daemon remove + rm -fv "$0" + echo "Home Assistant Daemon has been removed. Home Assistant is still installed." + fi +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + install) + install + ;; + uninstall) + uninstall + ;; + restart) + stop + start + ;; + *) + echo "Usage: $0 {start|stop|restart|install|uninstall}" +esac diff --git a/scripts/run_tests b/scripts/run_tests index 8d1c6aed114..75b25ca805a 100755 --- a/scripts/run_tests +++ b/scripts/run_tests @@ -3,4 +3,8 @@ if [ ${PWD##*/} == "scripts" ]; then cd .. fi -python3 -m unittest discover tests +if [ "$1" = "coverage" ]; then + coverage run -m unittest discover tests +else + python3 -m unittest discover tests +fi diff --git a/scripts/update b/scripts/update index 7f2b59147bd..be5e8fc01bf 100755 --- a/scripts/update +++ b/scripts/update @@ -1,8 +1,6 @@ -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi - -git pull --recurse-submodules=yes -git submodule update --init --recursive -python3 -m pip install -r requirements.txt +echo "The update script has been deprecated since Home Assistant v0.7" +echo +echo "Home Assistant is now distributed via PyPi and can be installed and" +echo "upgraded by running: pip3 install --upgrade homeassistant" +echo +echo "If you are developing a new feature for Home Assistant, run: git pull" diff --git a/setup.py b/setup.py new file mode 100755 index 00000000000..ce8a75072ac --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +import os +from setuptools import setup, find_packages +from homeassistant.const import __version__ + +PACKAGE_NAME = 'homeassistant' +HERE = os.path.abspath(os.path.dirname(__file__)) +DOWNLOAD_URL = ('https://github.com/balloob/home-assistant/archive/' + '{}.zip'.format(__version__)) + +PACKAGES = find_packages(exclude=['tests', 'tests.*']) + +PACKAGE_DATA = \ + {'homeassistant.components.frontend': ['index.html.template'], + 'homeassistant.components.frontend.www_static': ['*.*'], + 'homeassistant.components.frontend.www_static.images': ['*.*']} + +REQUIRES = [ + 'requests>=2,<3', + 'pyyaml>=3.11,<4', + 'pytz>=2015.4', + 'pip>=7.0.0', +] + +setup( + name=PACKAGE_NAME, + version=__version__, + license='MIT License', + url='https://home-assistant.io/', + download_url=DOWNLOAD_URL, + author='Paulus Schoutsen', + author_email='paulus@paulusschoutsen.nl', + description='Open-source home automation platform running on Python 3.', + packages=PACKAGES, + include_package_data=True, + package_data=PACKAGE_DATA, + zip_safe=False, + platforms='any', + install_requires=REQUIRES, + keywords=['home', 'automation'], + entry_points={ + 'console_scripts': [ + 'hass = homeassistant.__main__:main' + ] + }, + classifiers=[ + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.4', + 'Topic :: Home Automation' + ] +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000000..c39a22e0b57 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +import logging +logging.disable(logging.CRITICAL) diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 00000000000..72be8c5b735 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,198 @@ +""" +tests.helper +~~~~~~~~~~~~~ + +Helper method for writing tests. +""" +import os +from datetime import timedelta +from unittest import mock + +from homeassistant import core as ha, loader +import homeassistant.util.location as location_util +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import ( + STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, + EVENT_STATE_CHANGED) +from homeassistant.components import sun, mqtt + + +def get_test_config_dir(): + """ Returns a path to a test config dir. """ + return os.path.join(os.path.dirname(__file__), "config") + + +def get_test_home_assistant(num_threads=None): + """ Returns a Home Assistant object pointing at test config dir. """ + if num_threads: + orig_num_threads = ha.MIN_WORKER_THREAD + ha.MIN_WORKER_THREAD = num_threads + + hass = ha.HomeAssistant() + + if num_threads: + ha.MIN_WORKER_THREAD = orig_num_threads + + hass.config.config_dir = get_test_config_dir() + hass.config.latitude = 32.87336 + hass.config.longitude = -117.22743 + + # if not loader.PREPARED: + loader. prepare(hass) + + return hass + + +def mock_detect_location_info(): + """ Mock implementation of util.detect_location_info. """ + return location_util.LocationInfo( + ip='1.1.1.1', + country_code='US', + country_name='United States', + region_code='CA', + region_name='California', + city='San Diego', + zip_code='92122', + time_zone='America/Los_Angeles', + latitude='2.0', + longitude='1.0', + use_fahrenheit=True, + ) + + +def mock_service(hass, domain, service): + """ + Sets up a fake service. + Returns a list that logs all calls to fake service. + """ + calls = [] + + hass.services.register( + domain, service, lambda call: calls.append(call)) + + return calls + + +def fire_mqtt_message(hass, topic, payload, qos=0): + hass.bus.fire(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, { + mqtt.ATTR_TOPIC: topic, + mqtt.ATTR_PAYLOAD: payload, + mqtt.ATTR_QOS: qos, + }) + + +def fire_time_changed(hass, time): + hass.bus.fire(EVENT_TIME_CHANGED, {'now': time}) + + +def trigger_device_tracker_scan(hass): + """ Triggers the device tracker to scan. """ + fire_time_changed( + hass, dt_util.utcnow().replace(second=0) + timedelta(hours=1)) + + +def ensure_sun_risen(hass): + """ Trigger sun to rise if below horizon. """ + if sun.is_on(hass): + return + fire_time_changed(hass, sun.next_rising_utc(hass) + timedelta(seconds=10)) + + +def ensure_sun_set(hass): + """ Trigger sun to set if above horizon. """ + if not sun.is_on(hass): + return + fire_time_changed(hass, sun.next_setting_utc(hass) + timedelta(seconds=10)) + + +def mock_state_change_event(hass, new_state, old_state=None): + event_data = { + 'entity_id': new_state.entity_id, + 'new_state': new_state, + } + + if old_state: + event_data['old_state'] = old_state + + hass.bus.fire(EVENT_STATE_CHANGED, event_data) + + +def mock_http_component(hass): + hass.http = MockHTTP() + hass.config.components.append('http') + + +def mock_mqtt_component(hass): + with mock.patch('homeassistant.components.mqtt.MQTT'): + mqtt.setup(hass, { + mqtt.DOMAIN: { + mqtt.CONF_BROKER: 'mock-broker', + } + }) + hass.config.components.append(mqtt.DOMAIN) + + +class MockHTTP(object): + """ Mocks the HTTP module. """ + + def register_path(self, method, url, callback, require_auth=True): + pass + + +class MockModule(object): + """ Provides a fake module. """ + + def __init__(self, domain, dependencies=[], setup=None): + self.DOMAIN = domain + self.DEPENDENCIES = dependencies + # Setup a mock setup if none given. + self.setup = lambda hass, config: False if setup is None else setup + + +class MockToggleDevice(ToggleEntity): + """ Provides a mock toggle device. """ + def __init__(self, name, state): + self._name = name or DEVICE_DEFAULT_NAME + self._state = state + self.calls = [] + + @property + def name(self): + """ Returns the name of the device if any. """ + self.calls.append(('name', {})) + return self._name + + @property + def state(self): + """ Returns the name of the device if any. """ + self.calls.append(('state', {})) + return self._state + + @property + def is_on(self): + """ True if device is on. """ + self.calls.append(('is_on', {})) + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """ Turn the device on. """ + self.calls.append(('turn_on', kwargs)) + self._state = STATE_ON + + def turn_off(self, **kwargs): + """ Turn the device off. """ + self.calls.append(('turn_off', kwargs)) + self._state = STATE_OFF + + def last_call(self, method=None): + if not self.calls: + return None + elif method is None: + return self.calls[-1] + else: + try: + return next(call for call in reversed(self.calls) + if call[0] == method) + except StopIteration: + return None diff --git a/tests/components/__init__.py b/tests/components/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/automation/__init__.py b/tests/components/automation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py new file mode 100644 index 00000000000..a2c36283c9a --- /dev/null +++ b/tests/components/automation/test_event.py @@ -0,0 +1,78 @@ +""" +tests.test_component_demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests demo component. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.automation as automation +import homeassistant.components.automation.event as event +from homeassistant.const import CONF_PLATFORM + + +class TestAutomationEvent(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_fails_setup_if_no_event_type(self): + self.assertFalse(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + automation.CONF_SERVICE: 'test.automation' + } + })) + + def test_if_fires_on_event(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_event_with_data(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + event.CONF_EVENT_DATA: {'some_attr': 'some_value'}, + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.bus.fire('test_event', {'some_attr': 'some_value'}) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_if_event_data_not_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + event.CONF_EVENT_DATA: {'some_attr': 'some_value'}, + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.bus.fire('test_event', {'some_attr': 'some_other_value'}) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py new file mode 100644 index 00000000000..507c37dc20a --- /dev/null +++ b/tests/components/automation/test_init.py @@ -0,0 +1,95 @@ +""" +tests.test_component_demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests demo component. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.automation as automation +import homeassistant.components.automation.event as event +from homeassistant.const import CONF_PLATFORM, ATTR_ENTITY_ID + + +class TestAutomationEvent(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_setup_fails_if_unknown_platform(self): + self.assertFalse(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'i_do_not_exist' + } + })) + + def test_service_data_not_a_dict(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + automation.CONF_SERVICE: 'test.automation', + automation.CONF_SERVICE_DATA: 100 + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_service_specify_data(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + automation.CONF_SERVICE: 'test.automation', + automation.CONF_SERVICE_DATA: {'some': 'data'} + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('data', self.calls[0].data['some']) + + def test_service_specify_entity_id(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + automation.CONF_SERVICE: 'test.automation', + automation.CONF_SERVICE_ENTITY_ID: 'hello.world' + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual(['hello.world'], self.calls[0].data[ATTR_ENTITY_ID]) + + def test_service_specify_entity_id_list(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + automation.CONF_SERVICE: 'test.automation', + automation.CONF_SERVICE_ENTITY_ID: ['hello.world', 'hello.world2'] + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual(['hello.world', 'hello.world2'], self.calls[0].data[ATTR_ENTITY_ID]) diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py new file mode 100644 index 00000000000..9402b5300b6 --- /dev/null +++ b/tests/components/automation/test_mqtt.py @@ -0,0 +1,81 @@ +""" +tests.test_component_demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests demo component. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.automation as automation +import homeassistant.components.automation.mqtt as mqtt +from homeassistant.const import CONF_PLATFORM + +from tests.common import mock_mqtt_component, fire_mqtt_message + + +class TestAutomationState(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + mock_mqtt_component(self.hass) + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_setup_fails_if_no_topic(self): + self.assertFalse(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'mqtt', + automation.CONF_SERVICE: 'test.automation' + } + })) + + def test_if_fires_on_topic_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'mqtt', + mqtt.CONF_TOPIC: 'test-topic', + automation.CONF_SERVICE: 'test.automation' + } + })) + + fire_mqtt_message(self.hass, 'test-topic', '') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_topic_and_payload_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'mqtt', + mqtt.CONF_TOPIC: 'test-topic', + mqtt.CONF_PAYLOAD: 'hello', + automation.CONF_SERVICE: 'test.automation' + } + })) + + fire_mqtt_message(self.hass, 'test-topic', 'hello') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_on_topic_but_no_payload_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'mqtt', + mqtt.CONF_TOPIC: 'test-topic', + mqtt.CONF_PAYLOAD: 'hello', + automation.CONF_SERVICE: 'test.automation' + } + })) + + fire_mqtt_message(self.hass, 'test-topic', 'no-hello') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py new file mode 100644 index 00000000000..47d612cbb02 --- /dev/null +++ b/tests/components/automation/test_state.py @@ -0,0 +1,139 @@ +""" +tests.test_component_demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests demo component. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.automation as automation +import homeassistant.components.automation.state as state +from homeassistant.const import CONF_PLATFORM + + +class TestAutomationState(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.hass.states.set('test.entity', 'hello') + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_setup_fails_if_no_entity_id(self): + self.assertFalse(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + automation.CONF_SERVICE: 'test.automation' + } + })) + + def test_if_fires_on_entity_change(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + state.CONF_ENTITY_ID: 'test.entity', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_from_filter(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + state.CONF_ENTITY_ID: 'test.entity', + state.CONF_FROM: 'hello', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_to_filter(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + state.CONF_ENTITY_ID: 'test.entity', + state.CONF_TO: 'world', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_both_filters(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + state.CONF_ENTITY_ID: 'test.entity', + state.CONF_FROM: 'hello', + state.CONF_TO: 'world', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_if_to_filter_not_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + state.CONF_ENTITY_ID: 'test.entity', + state.CONF_FROM: 'hello', + state.CONF_TO: 'world', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'moon') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_not_fires_if_from_filter_not_match(self): + self.hass.states.set('test.entity', 'bye') + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + state.CONF_ENTITY_ID: 'test.entity', + state.CONF_FROM: 'hello', + state.CONF_TO: 'world', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_not_fires_if_entity_not_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'state', + state.CONF_ENTITY_ID: 'test.another_entity', + automation.CONF_SERVICE: 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py new file mode 100644 index 00000000000..05c5ade1d53 --- /dev/null +++ b/tests/components/automation/test_time.py @@ -0,0 +1,96 @@ +""" +tests.test_component_demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests demo component. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.loader as loader +import homeassistant.util.dt as dt_util +import homeassistant.components.automation as automation +import homeassistant.components.automation.time as time +from homeassistant.const import CONF_PLATFORM + +from tests.common import fire_time_changed + + +class TestAutomationTime(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_if_fires_when_hour_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'time', + time.CONF_HOURS: 0, + automation.CONF_SERVICE: 'test.automation' + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0)) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_when_minute_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'time', + time.CONF_MINUTES: 0, + automation.CONF_SERVICE: 'test.automation' + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace(minute=0)) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_when_second_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'time', + time.CONF_SECONDS: 0, + automation.CONF_SERVICE: 'test.automation' + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace(second=0)) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_when_all_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'time', + time.CONF_HOURS: 0, + time.CONF_MINUTES: 0, + time.CONF_SECONDS: 0, + automation.CONF_SERVICE: 'test.automation' + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace( + hour=0, minute=0, second=0)) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) diff --git a/tests/test_component_api.py b/tests/components/test_api.py similarity index 98% rename from tests/test_component_api.py rename to tests/components/test_api.py index ff25b476d32..b267e6b3c1c 100644 --- a/tests/test_component_api.py +++ b/tests/components/test_api.py @@ -7,10 +7,11 @@ Tests Home Assistant HTTP component does what it should do. # pylint: disable=protected-access,too-many-public-methods import unittest import json +from unittest.mock import patch import requests -import homeassistant as ha +import homeassistant.core as ha import homeassistant.bootstrap as bootstrap import homeassistant.remote as remote import homeassistant.components.http as http @@ -35,7 +36,9 @@ def _url(path=""): return HTTP_BASE_URL + path -def setUpModule(): # pylint: disable=invalid-name +@patch('homeassistant.components.http.util.get_local_ip', + return_value='127.0.0.1') +def setUpModule(mock_get_local_ip): # pylint: disable=invalid-name """ Initalizes a Home Assistant server. """ global hass diff --git a/tests/test_component_configurator.py b/tests/components/test_configurator.py similarity index 99% rename from tests/test_component_configurator.py rename to tests/components/test_configurator.py index c64fc39e50a..f41a5319ffd 100644 --- a/tests/test_component_configurator.py +++ b/tests/components/test_configurator.py @@ -7,7 +7,7 @@ Tests Configurator component. # pylint: disable=too-many-public-methods,protected-access import unittest -import homeassistant as ha +import homeassistant.core as ha import homeassistant.components.configurator as configurator from homeassistant.const import EVENT_TIME_CHANGED diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py new file mode 100644 index 00000000000..243fe128b28 --- /dev/null +++ b/tests/components/test_conversation.py @@ -0,0 +1,111 @@ +""" +tests.components.test_conversation +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests Conversation component. +""" +# pylint: disable=too-many-public-methods,protected-access +import unittest +from unittest.mock import patch + +import homeassistant.components as core_components +from homeassistant.components import conversation +from homeassistant.const import ATTR_ENTITY_ID + +from tests.common import get_test_home_assistant + + +class TestConversation(unittest.TestCase): + """ Test the conversation component. """ + + def setUp(self): # pylint: disable=invalid-name + """ Start up ha for testing """ + self.ent_id = 'light.kitchen_lights' + self.hass = get_test_home_assistant(3) + self.hass.states.set(self.ent_id, 'on') + self.assertTrue(core_components.setup(self.hass, {})) + self.assertTrue( + conversation.setup(self.hass, {conversation.DOMAIN: {}})) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_turn_on(self): + """ Setup and perform good turn on requests """ + calls = [] + + def record_call(service): + calls.append(service) + + self.hass.services.register('light', 'turn_on', record_call) + + event_data = {conversation.ATTR_TEXT: 'turn kitchen lights on'} + self.assertTrue(self.hass.services.call( + conversation.DOMAIN, 'process', event_data, True)) + + call = calls[-1] + self.assertEqual('light', call.domain) + self.assertEqual('turn_on', call.service) + self.assertEqual([self.ent_id], call.data[ATTR_ENTITY_ID]) + + def test_turn_off(self): + """ Setup and perform good turn off requests """ + calls = [] + + def record_call(service): + calls.append(service) + + self.hass.services.register('light', 'turn_off', record_call) + + event_data = {conversation.ATTR_TEXT: 'turn kitchen lights off'} + self.assertTrue(self.hass.services.call( + conversation.DOMAIN, 'process', event_data, True)) + + call = calls[-1] + self.assertEqual('light', call.domain) + self.assertEqual('turn_off', call.service) + self.assertEqual([self.ent_id], call.data[ATTR_ENTITY_ID]) + + @patch('homeassistant.components.conversation.logging.Logger.error') + @patch('homeassistant.core.ServiceRegistry.call') + def test_bad_request_format(self, mock_logger, mock_call): + """ Setup and perform a badly formatted request """ + event_data = { + conversation.ATTR_TEXT: + 'what is the answer to the ultimate question of life, ' + + 'the universe and everything'} + self.assertTrue(self.hass.services.call( + conversation.DOMAIN, 'process', event_data, True)) + self.assertTrue(mock_logger.called) + self.assertFalse(mock_call.called) + + @patch('homeassistant.components.conversation.logging.Logger.error') + @patch('homeassistant.core.ServiceRegistry.call') + def test_bad_request_entity(self, mock_logger, mock_call): + """ Setup and perform requests with bad entity id """ + event_data = {conversation.ATTR_TEXT: 'turn something off'} + self.assertTrue(self.hass.services.call( + conversation.DOMAIN, 'process', event_data, True)) + self.assertTrue(mock_logger.called) + self.assertFalse(mock_call.called) + + @patch('homeassistant.components.conversation.logging.Logger.error') + @patch('homeassistant.core.ServiceRegistry.call') + def test_bad_request_command(self, mock_logger, mock_call): + """ Setup and perform requests with bad command """ + event_data = {conversation.ATTR_TEXT: 'turn kitchen lights over'} + self.assertTrue(self.hass.services.call( + conversation.DOMAIN, 'process', event_data, True)) + self.assertTrue(mock_logger.called) + self.assertFalse(mock_call.called) + + @patch('homeassistant.components.conversation.logging.Logger.error') + @patch('homeassistant.core.ServiceRegistry.call') + def test_bad_request_notext(self, mock_logger, mock_call): + """ Setup and perform requests with bad command with no text """ + event_data = {} + self.assertTrue(self.hass.services.call( + conversation.DOMAIN, 'process', event_data, True)) + self.assertTrue(mock_logger.called) + self.assertFalse(mock_call.called) diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py new file mode 100644 index 00000000000..0abd546e4c4 --- /dev/null +++ b/tests/components/test_demo.py @@ -0,0 +1,36 @@ +""" +tests.test_component_demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests demo component. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.demo as demo + +from tests.common import mock_http_component + + +class TestDemo(unittest.TestCase): + """ Test the demo module. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + mock_http_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_if_demo_state_shows_by_default(self): + """ Test if demo state shows if we give no configuration. """ + demo.setup(self.hass, {demo.DOMAIN: {}}) + + self.assertIsNotNone(self.hass.states.get('a.Demo_Mode')) + + def test_hiding_demo_state(self): + """ Test if you can hide the demo card. """ + demo.setup(self.hass, {demo.DOMAIN: {'hide_demo_state': 1}}) + + self.assertIsNone(self.hass.states.get('a.Demo_Mode')) diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py new file mode 100644 index 00000000000..1f4dbf765ff --- /dev/null +++ b/tests/components/test_device_sun_light_trigger.py @@ -0,0 +1,122 @@ +""" +tests.test_component_device_sun_light_trigger +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests device sun light trigger component. +""" +# pylint: disable=too-many-public-methods,protected-access +import os +import unittest + +import homeassistant.loader as loader +from homeassistant.const import CONF_PLATFORM +from homeassistant.components import ( + device_tracker, light, sun, device_sun_light_trigger) + + +from tests.common import ( + get_test_config_dir, get_test_home_assistant, ensure_sun_risen, + ensure_sun_set, trigger_device_tracker_scan) + + +KNOWN_DEV_PATH = None + + +def setUpModule(): # pylint: disable=invalid-name + """ Initalizes a Home Assistant server. """ + global KNOWN_DEV_PATH + + KNOWN_DEV_PATH = os.path.join(get_test_config_dir(), + device_tracker.KNOWN_DEVICES_FILE) + + with open(KNOWN_DEV_PATH, 'w') as fil: + fil.write('device,name,track,picture\n') + fil.write('DEV1,device 1,1,http://example.com/dev1.jpg\n') + fil.write('DEV2,device 2,1,http://example.com/dev2.jpg\n') + + +def tearDownModule(): # pylint: disable=invalid-name + """ Stops the Home Assistant server. """ + os.remove(KNOWN_DEV_PATH) + + +class TestDeviceSunLightTrigger(unittest.TestCase): + """ Test the device sun light trigger module. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = get_test_home_assistant() + + self.scanner = loader.get_component( + 'device_tracker.test').get_scanner(None, None) + + self.scanner.reset() + self.scanner.come_home('DEV1') + + loader.get_component('light.test').init() + + device_tracker.setup(self.hass, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} + }) + + light.setup(self.hass, { + light.DOMAIN: {CONF_PLATFORM: 'test'} + }) + + sun.setup(self.hass, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_lights_on_when_sun_sets(self): + """ Test lights go on when there is someone home and the sun sets. """ + + device_sun_light_trigger.setup( + self.hass, {device_sun_light_trigger.DOMAIN: {}}) + + ensure_sun_risen(self.hass) + + light.turn_off(self.hass) + + self.hass.pool.block_till_done() + + ensure_sun_set(self.hass) + + self.hass.pool.block_till_done() + + self.assertTrue(light.is_on(self.hass)) + + def test_lights_turn_off_when_everyone_leaves(self): + """ Test lights turn off when everyone leaves the house. """ + light.turn_on(self.hass) + + self.hass.pool.block_till_done() + + device_sun_light_trigger.setup( + self.hass, {device_sun_light_trigger.DOMAIN: {}}) + + self.scanner.leave_home('DEV1') + + trigger_device_tracker_scan(self.hass) + + self.hass.pool.block_till_done() + + self.assertFalse(light.is_on(self.hass)) + + def test_lights_turn_on_when_coming_home_after_sun_set(self): + """ Test lights turn on when coming home after sun set. """ + light.turn_off(self.hass) + + ensure_sun_set(self.hass) + + self.hass.pool.block_till_done() + + device_sun_light_trigger.setup( + self.hass, {device_sun_light_trigger.DOMAIN: {}}) + + self.scanner.come_home('DEV2') + trigger_device_tracker_scan(self.hass) + + self.hass.pool.block_till_done() + + self.assertTrue(light.is_on(self.hass)) diff --git a/tests/test_component_device_scanner.py b/tests/components/test_device_tracker.py similarity index 93% rename from tests/test_component_device_scanner.py rename to tests/components/test_device_tracker.py index 2bd392c21d0..08ac641d19f 100644 --- a/tests/test_component_device_scanner.py +++ b/tests/components/test_device_tracker.py @@ -1,27 +1,23 @@ """ -tests.test_component_group -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +tests.test_component_device_tracker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Tests the group compoments. +Tests the device tracker compoments. """ # pylint: disable=protected-access,too-many-public-methods import unittest -from datetime import datetime, timedelta -import logging +from datetime import timedelta import os -import homeassistant as ha +import homeassistant.core as ha import homeassistant.loader as loader +import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, CONF_PLATFORM) + STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, CONF_PLATFORM, + DEVICE_DEFAULT_NAME) import homeassistant.components.device_tracker as device_tracker -from helpers import get_test_home_assistant - - -def setUpModule(): # pylint: disable=invalid-name - """ Setup to ignore group errors. """ - logging.disable(logging.CRITICAL) +from tests.common import get_test_home_assistant class TestComponentsDeviceTracker(unittest.TestCase): @@ -30,7 +26,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """ Init needed objects. """ self.hass = get_test_home_assistant() - loader.prepare(self.hass) self.known_dev_path = self.hass.config.path( device_tracker.KNOWN_DEVICES_FILE) @@ -80,6 +75,8 @@ class TestComponentsDeviceTracker(unittest.TestCase): scanner = loader.get_component( 'device_tracker.test').get_scanner(None, None) + scanner.reset() + scanner.come_home('DEV1') scanner.come_home('DEV2') @@ -93,7 +90,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): # To ensure all the three expected lines are there, we sort the file with open(self.known_dev_path) as fil: self.assertEqual( - ['DEV1,unknown device,0,\n', 'DEV2,dev2,0,\n', + ['DEV1,{},0,\n'.format(DEVICE_DEFAULT_NAME), 'DEV2,dev2,0,\n', 'device,name,track,picture\n'], sorted(fil)) @@ -116,7 +113,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): dev2 = device_tracker.ENTITY_ID_FORMAT.format('device_2') dev3 = device_tracker.ENTITY_ID_FORMAT.format('DEV3') - now = datetime.now() + now = dt_util.utcnow() # Device scanner scans every 12 seconds. We need to sync our times to # be every 12 seconds or else the time_changed event will be ignored. diff --git a/tests/test_component_frontend.py b/tests/components/test_frontend.py similarity index 91% rename from tests/test_component_frontend.py rename to tests/components/test_frontend.py index d6431f5f5df..e8c0c53d13e 100644 --- a/tests/test_component_frontend.py +++ b/tests/components/test_frontend.py @@ -7,10 +7,11 @@ Tests Home Assistant HTTP component does what it should do. # pylint: disable=protected-access,too-many-public-methods import re import unittest +from unittest.mock import patch import requests -import homeassistant as ha +import homeassistant.core as ha import homeassistant.bootstrap as bootstrap import homeassistant.components.http as http from homeassistant.const import HTTP_HEADER_HA_AUTH @@ -34,7 +35,9 @@ def _url(path=""): return HTTP_BASE_URL + path -def setUpModule(): # pylint: disable=invalid-name +@patch('homeassistant.components.http.util.get_local_ip', + return_value='127.0.0.1') +def setUpModule(mock_get_local_ip): # pylint: disable=invalid-name """ Initalizes a Home Assistant server. """ global hass diff --git a/tests/test_component_group.py b/tests/components/test_group.py similarity index 95% rename from tests/test_component_group.py rename to tests/components/test_group.py index 36ce2b80319..d7ed7f105d0 100644 --- a/tests/test_component_group.py +++ b/tests/components/test_group.py @@ -8,7 +8,7 @@ Tests the group compoments. import unittest import logging -import homeassistant as ha +import homeassistant.core as ha from homeassistant.const import STATE_ON, STATE_OFF, STATE_HOME, STATE_UNKNOWN import homeassistant.components.group as group @@ -54,7 +54,7 @@ class TestComponentsGroup(unittest.TestCase): self.hass, 'light_and_nothing', ['light.Bowl', 'non.existing']) - self.assertEqual(STATE_ON, grp.state.state) + self.assertEqual(STATE_ON, grp.state) def test_setup_group_with_non_groupable_states(self): self.hass.states.set('cast.living_room', "Plex") @@ -64,13 +64,13 @@ class TestComponentsGroup(unittest.TestCase): self.hass, 'chromecasts', ['cast.living_room', 'cast.bedroom']) - self.assertEqual(STATE_UNKNOWN, grp.state.state) + self.assertEqual(STATE_UNKNOWN, grp.state) def test_setup_empty_group(self): """ Try to setup an empty group. """ grp = group.setup_group(self.hass, 'nothing', []) - self.assertEqual(STATE_UNKNOWN, grp.state.state) + self.assertEqual(STATE_UNKNOWN, grp.state) def test_monitor_group(self): """ Test if the group keeps track of states. """ @@ -199,8 +199,7 @@ class TestComponentsGroup(unittest.TestCase): self.hass, { group.DOMAIN: { - 'second_group': ','.join((self.group_entity_id, - 'light.Bowl')) + 'second_group': 'light.Bowl, ' + self.group_entity_id } })) @@ -208,6 +207,8 @@ class TestComponentsGroup(unittest.TestCase): group.ENTITY_ID_FORMAT.format('second_group')) self.assertEqual(STATE_ON, group_state.state) + self.assertEqual(set((self.group_entity_id, 'light.bowl')), + set(group_state.attributes['entity_id'])) self.assertFalse(group_state.attributes[group.ATTR_AUTO]) def test_groups_get_unique_names(self): diff --git a/tests/components/test_history.py b/tests/components/test_history.py new file mode 100644 index 00000000000..12d10c52744 --- /dev/null +++ b/tests/components/test_history.py @@ -0,0 +1,135 @@ +""" +tests.test_component_history +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests the history component. +""" +# pylint: disable=protected-access,too-many-public-methods +import time +import os +import unittest + +import homeassistant.core as ha +import homeassistant.util.dt as dt_util +from homeassistant.components import history, recorder + +from tests.common import ( + mock_http_component, mock_state_change_event, get_test_home_assistant) + + +class TestComponentHistory(unittest.TestCase): + """ Tests homeassistant.components.history module. """ + + def setUp(self): # pylint: disable=invalid-name + """ Init needed objects. """ + self.hass = get_test_home_assistant(1) + self.init_rec = False + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + if self.init_rec: + recorder._INSTANCE.block_till_done() + os.remove(self.hass.config.path(recorder.DB_FILE)) + + def init_recorder(self): + recorder.setup(self.hass, {}) + self.hass.start() + recorder._INSTANCE.block_till_done() + self.init_rec = True + + def test_setup(self): + """ Test setup method of history. """ + mock_http_component(self.hass) + self.assertTrue(history.setup(self.hass, {})) + + def test_last_5_states(self): + """ Test retrieving the last 5 states. """ + self.init_recorder() + states = [] + + entity_id = 'test.last_5_states' + + for i in range(7): + self.hass.states.set(entity_id, "State {}".format(i)) + + if i > 1: + states.append(self.hass.states.get(entity_id)) + + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + + self.assertEqual( + list(reversed(states)), history.last_5_states(entity_id)) + + def test_get_states(self): + """ Test getting states at a specific point in time. """ + self.init_recorder() + states = [] + + # Create 10 states for 5 different entities + # After the first 5, sleep a second and save the time + # history.get_states takes the latest states BEFORE point X + + for i in range(10): + state = ha.State( + 'test.point_in_time_{}'.format(i % 5), + "State {}".format(i), + {'attribute_test': i}) + + mock_state_change_event(self.hass, state) + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + + if i < 5: + states.append(state) + + if i == 4: + time.sleep(1) + point = dt_util.utcnow() + + self.assertEqual( + states, + sorted( + history.get_states(point), key=lambda state: state.entity_id)) + + # Test get_state here because we have a DB setup + self.assertEqual( + states[0], history.get_state(point, states[0].entity_id)) + + def test_state_changes_during_period(self): + self.init_recorder() + entity_id = 'media_player.test' + + def set_state(state): + self.hass.states.set(entity_id, state) + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + + return self.hass.states.get(entity_id) + + set_state('idle') + set_state('YouTube') + + start = dt_util.utcnow() + + time.sleep(1) + + states = [ + set_state('idle'), + set_state('Netflix'), + set_state('Plex'), + set_state('YouTube'), + ] + + time.sleep(1) + + end = dt_util.utcnow() + + set_state('Netflix') + set_state('Plex') + + self.assertEqual( + {entity_id: states}, + history.state_changes_during_period(start, end, entity_id)) diff --git a/tests/test_component_core.py b/tests/components/test_init.py similarity index 96% rename from tests/test_component_core.py rename to tests/components/test_init.py index 8c00616bbb4..4ff334c1b1e 100644 --- a/tests/test_component_core.py +++ b/tests/components/test_init.py @@ -7,7 +7,7 @@ Tests core compoments. # pylint: disable=protected-access,too-many-public-methods import unittest -import homeassistant as ha +import homeassistant.core as ha import homeassistant.loader as loader from homeassistant.const import ( STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF) @@ -20,7 +20,6 @@ class TestComponentsCore(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """ Init needed objects. """ self.hass = ha.HomeAssistant() - loader.prepare(self.hass) self.assertTrue(comps.setup(self.hass, {})) self.hass.states.set('light.Bowl', STATE_ON) diff --git a/tests/test_component_light.py b/tests/components/test_light.py similarity index 97% rename from tests/test_component_light.py rename to tests/components/test_light.py index eb8a17361bf..515b79b6fc0 100644 --- a/tests/test_component_light.py +++ b/tests/components/test_light.py @@ -9,13 +9,13 @@ import unittest import os import homeassistant.loader as loader -import homeassistant.util as util +import homeassistant.util.color as color_util from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, SERVICE_TURN_ON, SERVICE_TURN_OFF) import homeassistant.components.light as light -from helpers import mock_service, get_test_home_assistant +from tests.common import mock_service, get_test_home_assistant class TestLight(unittest.TestCase): @@ -23,7 +23,6 @@ class TestLight(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name self.hass = get_test_home_assistant() - loader.prepare(self.hass) def tearDown(self): # pylint: disable=invalid-name """ Stop down stuff we started. """ @@ -154,7 +153,7 @@ class TestLight(unittest.TestCase): method, data = dev2.last_call('turn_on') self.assertEqual( - {light.ATTR_XY_COLOR: util.color_RGB_to_xy(255, 255, 255)}, + {light.ATTR_XY_COLOR: color_util.color_RGB_to_xy(255, 255, 255)}, data) method, data = dev3.last_call('turn_on') diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py new file mode 100644 index 00000000000..16f6ba8aa33 --- /dev/null +++ b/tests/components/test_logbook.py @@ -0,0 +1,95 @@ +""" +tests.test_component_logbook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests the logbook component. +""" +# pylint: disable=protected-access,too-many-public-methods +import unittest +from datetime import timedelta + +import homeassistant.core as ha +from homeassistant.const import ( + EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +import homeassistant.util.dt as dt_util +from homeassistant.components import logbook + +from tests.common import get_test_home_assistant, mock_http_component + + +class TestComponentHistory(unittest.TestCase): + """ Tests homeassistant.components.history module. """ + + def test_setup(self): + """ Test setup method. """ + try: + hass = get_test_home_assistant() + mock_http_component(hass) + self.assertTrue(logbook.setup(hass, {})) + finally: + hass.stop() + + def test_humanify_filter_sensor(self): + """ Test humanify filter too frequent sensor values. """ + entity_id = 'sensor.bla' + + pointA = dt_util.strip_microseconds(dt_util.utcnow().replace(minute=2)) + pointB = pointA.replace(minute=5) + pointC = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) + + eventA = self.create_state_changed_event(pointA, entity_id, 10) + eventB = self.create_state_changed_event(pointB, entity_id, 20) + eventC = self.create_state_changed_event(pointC, entity_id, 30) + + entries = list(logbook.humanify((eventA, eventB, eventC))) + + self.assertEqual(2, len(entries)) + self.assert_entry( + entries[0], pointB, 'bla', domain='sensor', entity_id=entity_id) + + self.assert_entry( + entries[1], pointC, 'bla', domain='sensor', entity_id=entity_id) + + def test_home_assistant_start_stop_grouped(self): + """ Tests if home assistant start and stop events are grouped if + occuring in the same minute. """ + entries = list(logbook.humanify(( + ha.Event(EVENT_HOMEASSISTANT_STOP), + ha.Event(EVENT_HOMEASSISTANT_START), + ))) + + self.assertEqual(1, len(entries)) + self.assert_entry( + entries[0], name='Home Assistant', message='restarted', + domain=ha.DOMAIN) + + def assert_entry(self, entry, when=None, name=None, message=None, + domain=None, entity_id=None): + """ Asserts an entry is what is expected """ + if when: + self.assertEqual(when, entry.when) + + if name: + self.assertEqual(name, entry.name) + + if message: + self.assertEqual(message, entry.message) + + if domain: + self.assertEqual(domain, entry.domain) + + if entity_id: + self.assertEqual(entity_id, entry.entity_id) + + def create_state_changed_event(self, event_time_fired, entity_id, state): + """ Create state changed event. """ + + # Logbook only cares about state change events that + # contain an old state but will not actually act on it. + state = ha.State(entity_id, state).as_dict() + + return ha.Event(EVENT_STATE_CHANGED, { + 'entity_id': entity_id, + 'old_state': state, + 'new_state': state, + }, time_fired=event_time_fired) diff --git a/tests/test_component_media_player.py b/tests/components/test_media_player.py similarity index 83% rename from tests/test_component_media_player.py rename to tests/components/test_media_player.py index 3e6ea347f28..28d39206c47 100644 --- a/tests/test_component_media_player.py +++ b/tests/components/test_media_player.py @@ -5,21 +5,16 @@ tests.test_component_media_player Tests media_player component. """ # pylint: disable=too-many-public-methods,protected-access -import logging import unittest -import homeassistant as ha +import homeassistant.core as ha from homeassistant.const import ( - SERVICE_TURN_OFF, SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, + STATE_OFF, + SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREV_TRACK, ATTR_ENTITY_ID) + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, ATTR_ENTITY_ID) import homeassistant.components.media_player as media_player -from helpers import mock_service - - -def setUpModule(): # pylint: disable=invalid-name - """ Setup to ignore media_player errors. """ - logging.disable(logging.CRITICAL) +from tests.common import mock_service class TestMediaPlayer(unittest.TestCase): @@ -29,7 +24,7 @@ class TestMediaPlayer(unittest.TestCase): self.hass = ha.HomeAssistant() self.test_entity = media_player.ENTITY_ID_FORMAT.format('living_room') - self.hass.states.set(self.test_entity, media_player.STATE_NO_APP) + self.hass.states.set(self.test_entity, STATE_OFF) self.test_entity2 = media_player.ENTITY_ID_FORMAT.format('bedroom') self.hass.states.set(self.test_entity2, "YouTube") @@ -48,6 +43,7 @@ class TestMediaPlayer(unittest.TestCase): Test if the call service methods conver to correct service calls. """ services = { + SERVICE_TURN_ON: media_player.turn_on, SERVICE_TURN_OFF: media_player.turn_off, SERVICE_VOLUME_UP: media_player.volume_up, SERVICE_VOLUME_DOWN: media_player.volume_down, @@ -55,7 +51,7 @@ class TestMediaPlayer(unittest.TestCase): SERVICE_MEDIA_PLAY: media_player.media_play, SERVICE_MEDIA_PAUSE: media_player.media_pause, SERVICE_MEDIA_NEXT_TRACK: media_player.media_next_track, - SERVICE_MEDIA_PREV_TRACK: media_player.media_prev_track + SERVICE_MEDIA_PREVIOUS_TRACK: media_player.media_previous_track } for service_name, service_method in services.items(): diff --git a/tests/components/test_mqtt.py b/tests/components/test_mqtt.py new file mode 100644 index 00000000000..4c3dbb1d20a --- /dev/null +++ b/tests/components/test_mqtt.py @@ -0,0 +1,138 @@ +""" +tests.test_component_mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests MQTT component. +""" +import unittest +from unittest import mock +import socket + +import homeassistant.components.mqtt as mqtt +from homeassistant.const import ( + EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) + +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) + + +class TestDemo(unittest.TestCase): + """ Test the demo module. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = get_test_home_assistant(1) + mock_mqtt_component(self.hass) + self.calls = [] + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def record_calls(self, *args): + self.calls.append(args) + + def test_client_starts_on_home_assistant_start(self): + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + self.hass.pool.block_till_done() + self.assertTrue(mqtt.MQTT_CLIENT.start.called) + + def test_client_stops_on_home_assistant_start(self): + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + self.hass.pool.block_till_done() + self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) + self.hass.pool.block_till_done() + self.assertTrue(mqtt.MQTT_CLIENT.stop.called) + + def test_setup_fails_if_no_broker_config(self): + self.assertFalse(mqtt.setup(self.hass, {mqtt.DOMAIN: {}})) + + def test_setup_fails_if_no_connect_broker(self): + with mock.patch('homeassistant.components.mqtt.MQTT', + side_effect=socket.error()): + self.assertFalse(mqtt.setup(self.hass, {mqtt.DOMAIN: { + mqtt.CONF_BROKER: 'test-broker', + }})) + + def test_publish_calls_service(self): + self.hass.bus.listen_once(EVENT_CALL_SERVICE, self.record_calls) + + mqtt.publish(self.hass, 'test-topic', 'test-payload') + + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + self.assertEqual('test-topic', self.calls[0][0].data[mqtt.ATTR_TOPIC]) + self.assertEqual('test-payload', self.calls[0][0].data[mqtt.ATTR_PAYLOAD]) + + def test_service_call_without_topic_does_not_publush(self): + self.hass.bus.fire(EVENT_CALL_SERVICE, { + ATTR_DOMAIN: mqtt.DOMAIN, + ATTR_SERVICE: mqtt.SERVICE_PUBLISH + }) + self.hass.pool.block_till_done() + self.assertTrue(not mqtt.MQTT_CLIENT.publish.called) + + def test_subscribe_topic(self): + mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + + fire_mqtt_message(self.hass, 'test-topic', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('test-topic', self.calls[0][0]) + self.assertEqual('test-payload', self.calls[0][1]) + + def test_subscribe_topic_not_match(self): + mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + + fire_mqtt_message(self.hass, 'another-test-topic', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_subscribe_topic_level_wildcard(self): + mqtt.subscribe(self.hass, 'test-topic/+/on', self.record_calls) + + fire_mqtt_message(self.hass, 'test-topic/bier/on', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('test-topic/bier/on', self.calls[0][0]) + self.assertEqual('test-payload', self.calls[0][1]) + + def test_subscribe_topic_level_wildcard_no_subtree_match(self): + mqtt.subscribe(self.hass, 'test-topic/+/on', self.record_calls) + + fire_mqtt_message(self.hass, 'test-topic/bier', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_subscribe_topic_subtree_wildcard_subtree_topic(self): + mqtt.subscribe(self.hass, 'test-topic/#', self.record_calls) + + fire_mqtt_message(self.hass, 'test-topic/bier/on', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('test-topic/bier/on', self.calls[0][0]) + self.assertEqual('test-payload', self.calls[0][1]) + + def test_subscribe_topic_subtree_wildcard_root_topic(self): + mqtt.subscribe(self.hass, 'test-topic/#', self.record_calls) + + fire_mqtt_message(self.hass, 'test-topic', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('test-topic', self.calls[0][0]) + self.assertEqual('test-payload', self.calls[0][1]) + + def test_subscribe_topic_subtree_wildcard_no_match(self): + mqtt.subscribe(self.hass, 'test-topic/#', self.record_calls) + + fire_mqtt_message(self.hass, 'another-test-topic', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) diff --git a/tests/components/test_recorder.py b/tests/components/test_recorder.py new file mode 100644 index 00000000000..26e5fdfd6b7 --- /dev/null +++ b/tests/components/test_recorder.py @@ -0,0 +1,70 @@ +""" +tests.test_component_recorder +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests Recorder component. +""" +# pylint: disable=too-many-public-methods,protected-access +import unittest +import os + +from homeassistant.const import MATCH_ALL +from homeassistant.components import recorder + +from tests.common import get_test_home_assistant + + +class TestRecorder(unittest.TestCase): + """ Test the chromecast module. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = get_test_home_assistant() + recorder.setup(self.hass, {}) + self.hass.start() + recorder._INSTANCE.block_till_done() + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + recorder._INSTANCE.block_till_done() + os.remove(self.hass.config.path(recorder.DB_FILE)) + + def test_saving_state(self): + """ Tests saving and restoring a state. """ + entity_id = 'test.recorder' + state = 'restoring_from_db' + attributes = {'test_attr': 5, 'test_attr_10': 'nice'} + + self.hass.states.set(entity_id, state, attributes) + + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + + states = recorder.query_states('SELECT * FROM states') + + self.assertEqual(1, len(states)) + self.assertEqual(self.hass.states.get(entity_id), states[0]) + + def test_saving_event(self): + """ Tests saving and restoring an event. """ + event_type = 'EVENT_TEST' + event_data = {'test_attr': 5, 'test_attr_10': 'nice'} + + events = [] + + def event_listener(event): + """ Records events from eventbus. """ + if event.event_type == event_type: + events.append(event) + + self.hass.bus.listen(MATCH_ALL, event_listener) + + self.hass.bus.fire(event_type, event_data) + + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + + db_events = recorder.query_events( + 'SELECT * FROM events WHERE event_type = ?', (event_type, )) + + self.assertEqual(events, db_events) diff --git a/tests/test_component_sun.py b/tests/components/test_sun.py similarity index 61% rename from tests/test_component_sun.py rename to tests/components/test_sun.py index a4ff19429f3..366b483d3ff 100644 --- a/tests/test_component_sun.py +++ b/tests/components/test_sun.py @@ -6,11 +6,12 @@ Tests Sun component. """ # pylint: disable=too-many-public-methods,protected-access import unittest -import datetime as dt +from datetime import timedelta -import ephem +from astral import Astral -import homeassistant as ha +import homeassistant.core as ha +import homeassistant.util.dt as dt_util import homeassistant.components.sun as sun @@ -33,31 +34,35 @@ class TestSun(unittest.TestCase): def test_setting_rising(self): """ Test retrieving sun setting and rising. """ + latitude = 32.87336 + longitude = 117.22743 + # Compare it with the real data - self.hass.config.latitude = '32.87336' - self.hass.config.longitude = '117.22743' - sun.setup(self.hass, None) + self.hass.config.latitude = latitude + self.hass.config.longitude = longitude + sun.setup(self.hass, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - observer = ephem.Observer() - observer.lat = '32.87336' # pylint: disable=assigning-non-slot - observer.long = '117.22743' # pylint: disable=assigning-non-slot + astral = Astral() + utc_now = dt_util.utcnow() - utc_now = dt.datetime.utcnow() - body_sun = ephem.Sun() # pylint: disable=no-member - next_rising_dt = ephem.localtime( - observer.next_rising(body_sun, start=utc_now)) - next_setting_dt = ephem.localtime( - observer.next_setting(body_sun, start=utc_now)) + mod = -1 + while True: + next_rising = (astral.sunrise_utc(utc_now + + timedelta(days=mod), latitude, longitude)) + if next_rising > utc_now: + break + mod += 1 - # Home Assistant strips out microseconds - # strip it out of the datetime objects - next_rising_dt = next_rising_dt - dt.timedelta( - microseconds=next_rising_dt.microsecond) - next_setting_dt = next_setting_dt - dt.timedelta( - microseconds=next_setting_dt.microsecond) + mod = -1 + while True: + next_setting = (astral.sunset_utc(utc_now + + timedelta(days=mod), latitude, longitude)) + if next_setting > utc_now: + break + mod += 1 - self.assertEqual(next_rising_dt, sun.next_rising(self.hass)) - self.assertEqual(next_setting_dt, sun.next_setting(self.hass)) + self.assertEqual(next_rising, sun.next_rising_utc(self.hass)) + self.assertEqual(next_setting, sun.next_setting_utc(self.hass)) # Point it at a state without the proper attributes self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON) @@ -72,7 +77,7 @@ class TestSun(unittest.TestCase): """ Test if the state changes at next setting/rising. """ self.hass.config.latitude = '32.87336' self.hass.config.longitude = '117.22743' - sun.setup(self.hass, None) + sun.setup(self.hass, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) if sun.is_on(self.hass): test_state = sun.STATE_BELOW_HORIZON @@ -84,7 +89,7 @@ class TestSun(unittest.TestCase): self.assertIsNotNone(test_time) self.hass.bus.fire(ha.EVENT_TIME_CHANGED, - {ha.ATTR_NOW: test_time + dt.timedelta(seconds=5)}) + {ha.ATTR_NOW: test_time + timedelta(seconds=5)}) self.hass.pool.block_till_done() diff --git a/tests/test_component_switch.py b/tests/components/test_switch.py similarity index 97% rename from tests/test_component_switch.py rename to tests/components/test_switch.py index cbc161be853..afa96290aa0 100644 --- a/tests/test_component_switch.py +++ b/tests/components/test_switch.py @@ -11,7 +11,7 @@ import homeassistant.loader as loader from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM import homeassistant.components.switch as switch -from helpers import get_test_home_assistant +from tests.common import get_test_home_assistant class TestSwitch(unittest.TestCase): @@ -19,7 +19,6 @@ class TestSwitch(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name self.hass = get_test_home_assistant() - loader.prepare(self.hass) platform = loader.get_component('switch.test') diff --git a/tests/config/custom_components/device_tracker/test.py b/tests/config/custom_components/device_tracker/test.py index 481892a9a67..635d400316f 100644 --- a/tests/config/custom_components/device_tracker/test.py +++ b/tests/config/custom_components/device_tracker/test.py @@ -26,6 +26,10 @@ class MockScanner(object): """ Make a device leave the house. """ self.devices_home.remove(device) + def reset(self): + """ Resets which devices are home. """ + self.devices_home = [] + def scan_devices(self): """ Returns a list of fake devices. """ diff --git a/tests/config/custom_components/light/test.py b/tests/config/custom_components/light/test.py index f7f355c4b30..1512d080b05 100644 --- a/tests/config/custom_components/light/test.py +++ b/tests/config/custom_components/light/test.py @@ -7,7 +7,7 @@ Provides a mock switch platform. Call init before using it in your tests to ensure clean test data. """ from homeassistant.const import STATE_ON, STATE_OFF -from tests.helpers import MockToggleDevice +from tests.common import MockToggleDevice DEVICES = [] diff --git a/tests/config/custom_components/switch/test.py b/tests/config/custom_components/switch/test.py index 178faf7fcdc..bb95154a94b 100644 --- a/tests/config/custom_components/switch/test.py +++ b/tests/config/custom_components/switch/test.py @@ -7,7 +7,7 @@ Provides a mock switch platform. Call init before using it in your tests to ensure clean test data. """ from homeassistant.const import STATE_ON, STATE_OFF -from tests.helpers import MockToggleDevice +from tests.common import MockToggleDevice DEVICES = [] diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index d98c549346d..00000000000 --- a/tests/helpers.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -tests.helper -~~~~~~~~~~~~~ - -Helper method for writing tests. -""" -import os - -import homeassistant as ha -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.const import STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME - - -def get_test_home_assistant(): - """ Returns a Home Assistant object pointing at test config dir. """ - hass = ha.HomeAssistant() - hass.config.config_dir = os.path.join(os.path.dirname(__file__), "config") - - return hass - - -def mock_service(hass, domain, service): - """ - Sets up a fake service. - Returns a list that logs all calls to fake service. - """ - calls = [] - - hass.services.register( - domain, service, lambda call: calls.append(call)) - - return calls - - -class MockModule(object): - """ Provides a fake module. """ - - def __init__(self, domain, dependencies=[], setup=None): - self.DOMAIN = domain - self.DEPENDENCIES = dependencies - # Setup a mock setup if none given. - self.setup = lambda hass, config: False if setup is None else setup - - -class MockToggleDevice(ToggleEntity): - """ Provides a mock toggle device. """ - def __init__(self, name, state): - self._name = name or DEVICE_DEFAULT_NAME - self._state = state - self.calls = [] - - @property - def name(self): - """ Returns the name of the device if any. """ - self.calls.append(('name', {})) - return self._name - - @property - def state(self): - """ Returns the name of the device if any. """ - self.calls.append(('state', {})) - return self._state - - @property - def is_on(self): - """ True if device is on. """ - self.calls.append(('is_on', {})) - return self._state == STATE_ON - - def turn_on(self, **kwargs): - """ Turn the device on. """ - self.calls.append(('turn_on', kwargs)) - self._state = STATE_ON - - def turn_off(self, **kwargs): - """ Turn the device off. """ - self.calls.append(('turn_off', kwargs)) - self._state = STATE_OFF - - def last_call(self, method=None): - if not self.calls: - return None - elif method is None: - return self.calls[-1] - else: - try: - return next(call for call in reversed(self.calls) - if call[0] == method) - except StopIteration: - return None diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py new file mode 100644 index 00000000000..b8823f23a5a --- /dev/null +++ b/tests/helpers/test_entity.py @@ -0,0 +1,63 @@ +""" +tests.test_helper_entity +~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests the entity helper. +""" +# pylint: disable=protected-access,too-many-public-methods +import unittest + +import homeassistant.core as ha +import homeassistant.helpers.entity as entity +from homeassistant.const import ATTR_HIDDEN + + +class TestHelpersEntity(unittest.TestCase): + """ Tests homeassistant.helpers.entity module. """ + + def setUp(self): # pylint: disable=invalid-name + """ Init needed objects. """ + self.entity = entity.Entity() + self.entity.entity_id = 'test.overwrite_hidden_true' + self.hass = self.entity.hass = ha.HomeAssistant() + self.entity.update_ha_state() + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + entity.Entity.overwrite_attribute(self.entity.entity_id, + [ATTR_HIDDEN], [None]) + + def test_default_hidden_not_in_attributes(self): + """ Test that the default hidden property is set to False. """ + self.assertNotIn( + ATTR_HIDDEN, + self.hass.states.get(self.entity.entity_id).attributes) + + def test_setting_hidden_to_true(self): + self.entity.hidden = True + self.entity.update_ha_state() + + state = self.hass.states.get(self.entity.entity_id) + + self.assertTrue(state.attributes.get(ATTR_HIDDEN)) + + def test_overwriting_hidden_property_to_true(self): + """ Test we can overwrite hidden property to True. """ + entity.Entity.overwrite_attribute(self.entity.entity_id, + [ATTR_HIDDEN], [True]) + self.entity.update_ha_state() + + state = self.hass.states.get(self.entity.entity_id) + self.assertTrue(state.attributes.get(ATTR_HIDDEN)) + + def test_overwriting_hidden_property_to_false(self): + """ Test we can overwrite hidden property to True. """ + entity.Entity.overwrite_attribute(self.entity.entity_id, + [ATTR_HIDDEN], [False]) + self.entity.hidden = True + self.entity.update_ha_state() + + self.assertNotIn( + ATTR_HIDDEN, + self.hass.states.get(self.entity.entity_id).attributes) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py new file mode 100644 index 00000000000..89711e2584e --- /dev/null +++ b/tests/helpers/test_event.py @@ -0,0 +1,126 @@ +""" +tests.helpers.event_test +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests event helpers. +""" +# pylint: disable=protected-access,too-many-public-methods +# pylint: disable=too-few-public-methods +import unittest +from datetime import datetime + +import homeassistant.core as ha +from homeassistant.helpers.event import * + + +class TestEventHelpers(unittest.TestCase): + """ + Tests the Home Assistant event helpers. + """ + + def setUp(self): # pylint: disable=invalid-name + """ things to be run when tests are started. """ + self.hass = ha.HomeAssistant() + self.hass.states.set("light.Bowl", "on") + self.hass.states.set("switch.AC", "off") + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_track_point_in_time(self): + """ Test track point in time. """ + before_birthday = datetime(1985, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) + birthday_paulus = datetime(1986, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) + after_birthday = datetime(1987, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) + + runs = [] + + track_point_in_utc_time( + self.hass, lambda x: runs.append(1), birthday_paulus) + + self._send_time_changed(before_birthday) + self.hass.pool.block_till_done() + self.assertEqual(0, len(runs)) + + self._send_time_changed(birthday_paulus) + self.hass.pool.block_till_done() + self.assertEqual(1, len(runs)) + + # A point in time tracker will only fire once, this should do nothing + self._send_time_changed(birthday_paulus) + self.hass.pool.block_till_done() + self.assertEqual(1, len(runs)) + + track_point_in_time( + self.hass, lambda x: runs.append(1), birthday_paulus) + + self._send_time_changed(after_birthday) + self.hass.pool.block_till_done() + self.assertEqual(2, len(runs)) + + def test_track_time_change(self): + """ Test tracking time change. """ + wildcard_runs = [] + specific_runs = [] + + track_time_change(self.hass, lambda x: wildcard_runs.append(1)) + track_utc_time_change( + self.hass, lambda x: specific_runs.append(1), second=[0, 30]) + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) + self.hass.pool.block_till_done() + self.assertEqual(1, len(specific_runs)) + self.assertEqual(1, len(wildcard_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 15)) + self.hass.pool.block_till_done() + self.assertEqual(1, len(specific_runs)) + self.assertEqual(2, len(wildcard_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + self.assertEqual(3, len(wildcard_runs)) + + def test_track_state_change(self): + """ Test track_state_change. """ + # 2 lists to track how often our callbacks get called + specific_runs = [] + wildcard_runs = [] + + track_state_change( + self.hass, 'light.Bowl', lambda a, b, c: specific_runs.append(1), + 'on', 'off') + + track_state_change( + self.hass, 'light.Bowl', lambda a, b, c: wildcard_runs.append(1), + ha.MATCH_ALL, ha.MATCH_ALL) + + # Set same state should not trigger a state change/listener + self.hass.states.set('light.Bowl', 'on') + self.hass.pool.block_till_done() + self.assertEqual(0, len(specific_runs)) + self.assertEqual(0, len(wildcard_runs)) + + # State change off -> on + self.hass.states.set('light.Bowl', 'off') + self.hass.pool.block_till_done() + self.assertEqual(1, len(specific_runs)) + self.assertEqual(1, len(wildcard_runs)) + + # State change off -> off + self.hass.states.set('light.Bowl', 'off', {"some_attr": 1}) + self.hass.pool.block_till_done() + self.assertEqual(1, len(specific_runs)) + self.assertEqual(2, len(wildcard_runs)) + + # State change off -> on + self.hass.states.set('light.Bowl', 'on') + self.hass.pool.block_till_done() + self.assertEqual(1, len(specific_runs)) + self.assertEqual(3, len(wildcard_runs)) + + def _send_time_changed(self, now): + """ Send a time changed event. """ + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) diff --git a/tests/test_helpers.py b/tests/helpers/test_init.py similarity index 93% rename from tests/test_helpers.py rename to tests/helpers/test_init.py index adc4b0d0788..1e3d8b98b1a 100644 --- a/tests/test_helpers.py +++ b/tests/helpers/test_init.py @@ -7,9 +7,9 @@ Tests component helpers. # pylint: disable=protected-access,too-many-public-methods import unittest -from helpers import get_test_home_assistant +from common import get_test_home_assistant -import homeassistant as ha +import homeassistant.core as ha import homeassistant.loader as loader from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID from homeassistant.helpers import extract_entity_ids @@ -21,7 +21,6 @@ class TestComponentsCore(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """ Init needed objects. """ self.hass = get_test_home_assistant() - loader.prepare(self.hass) self.hass.states.set('light.Bowl', STATE_ON) self.hass.states.set('light.Ceiling', STATE_OFF) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 00000000000..df05964a79f --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,41 @@ +""" +tests.test_bootstrap +~~~~~~~~~~~~~~~~~~~~ + +Tests bootstrap. +""" +# pylint: disable=too-many-public-methods,protected-access +import tempfile +import unittest +from unittest import mock + +from homeassistant import bootstrap +import homeassistant.util.dt as dt_util + +from tests.common import mock_detect_location_info + + +class TestBootstrap(unittest.TestCase): + """ Test the bootstrap utils. """ + + def setUp(self): + self.orig_timezone = dt_util.DEFAULT_TIME_ZONE + + def tearDown(self): + dt_util.DEFAULT_TIME_ZONE = self.orig_timezone + + def test_from_config_file(self): + components = ['browser', 'conversation', 'script'] + with tempfile.NamedTemporaryFile() as fp: + for comp in components: + fp.write('{}:\n'.format(comp).encode('utf-8')) + fp.flush() + + with mock.patch('homeassistant.util.location.detect_location_info', + mock_detect_location_info): + hass = bootstrap.from_config_file(fp.name) + + components.append('group') + + self.assertEqual(sorted(components), + sorted(hass.config.components)) diff --git a/tests/test_component_demo.py b/tests/test_component_demo.py deleted file mode 100644 index 3107ba40833..00000000000 --- a/tests/test_component_demo.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -tests.test_component_demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Tests demo component. -""" -# pylint: disable=too-many-public-methods,protected-access -import unittest - -import homeassistant as ha -import homeassistant.components.demo as demo -from homeassistant.const import ( - SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON, STATE_OFF, ATTR_ENTITY_ID) - - -class TestDemo(unittest.TestCase): - """ Test the demo module. """ - - def setUp(self): # pylint: disable=invalid-name - self.hass = ha.HomeAssistant() - - def tearDown(self): # pylint: disable=invalid-name - """ Stop down stuff we started. """ - self.hass.stop() - - def test_services(self): - """ Test the demo services. """ - # Test turning on and off different types - demo.setup(self.hass, {}) - - for domain in ('light', 'switch'): - # Focus on 1 entity - entity_id = self.hass.states.entity_ids(domain)[0] - - self.hass.services.call( - domain, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}) - - self.hass.pool.block_till_done() - - self.assertEqual(STATE_ON, self.hass.states.get(entity_id).state) - - self.hass.services.call( - domain, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) - - self.hass.pool.block_till_done() - - self.assertEqual(STATE_OFF, self.hass.states.get(entity_id).state) - - # Act on all - self.hass.services.call(domain, SERVICE_TURN_ON) - - self.hass.pool.block_till_done() - - for entity_id in self.hass.states.entity_ids(domain): - self.assertEqual( - STATE_ON, self.hass.states.get(entity_id).state) - - self.hass.services.call(domain, SERVICE_TURN_OFF) - - self.hass.pool.block_till_done() - - for entity_id in self.hass.states.entity_ids(domain): - self.assertEqual( - STATE_OFF, self.hass.states.get(entity_id).state) - - def test_if_demo_state_shows_by_default(self): - """ Test if demo state shows if we give no configuration. """ - demo.setup(self.hass, {demo.DOMAIN: {}}) - - self.assertIsNotNone(self.hass.states.get('a.Demo_Mode')) - - def test_hiding_demo_state(self): - """ Test if you can hide the demo card. """ - demo.setup(self.hass, {demo.DOMAIN: {'hide_demo_state': 1}}) - - self.assertIsNone(self.hass.states.get('a.Demo_Mode')) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000000..c986d7551c6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,138 @@ +""" +tests.test_config +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests config utils. +""" +# pylint: disable=too-many-public-methods,protected-access +import unittest +import unittest.mock as mock +import os + +from homeassistant.core import DOMAIN, HomeAssistantError +import homeassistant.config as config_util +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_TEMPERATURE_UNIT, CONF_NAME, + CONF_TIME_ZONE) + +from common import get_test_config_dir, mock_detect_location_info + +CONFIG_DIR = get_test_config_dir() +YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) + + +def create_file(path): + """ Creates an empty file. """ + with open(path, 'w'): + pass + + +class TestConfig(unittest.TestCase): + """ Test the config utils. """ + + def tearDown(self): # pylint: disable=invalid-name + """ Clean up. """ + if os.path.isfile(YAML_PATH): + os.remove(YAML_PATH) + + def test_create_default_config(self): + """ Test creationg of default config. """ + + config_util.create_default_config(CONFIG_DIR, False) + + self.assertTrue(os.path.isfile(YAML_PATH)) + + def test_find_config_file_yaml(self): + """ Test if it finds a YAML config file. """ + + create_file(YAML_PATH) + + self.assertEqual(YAML_PATH, config_util.find_config_file(CONFIG_DIR)) + + @mock.patch('builtins.print') + def test_ensure_config_exists_creates_config(self, mock_print): + """ Test that calling ensure_config_exists creates a new config file if + none exists. """ + + config_util.ensure_config_exists(CONFIG_DIR, False) + + self.assertTrue(os.path.isfile(YAML_PATH)) + self.assertTrue(mock_print.called) + + def test_ensure_config_exists_uses_existing_config(self): + """ Test that calling ensure_config_exists uses existing config. """ + + create_file(YAML_PATH) + config_util.ensure_config_exists(CONFIG_DIR, False) + + with open(YAML_PATH) as f: + content = f.read() + + # File created with create_file are empty + self.assertEqual('', content) + + def test_load_yaml_config_converts_empty_files_to_dict(self): + """ Test that loading an empty file returns an empty dict. """ + create_file(YAML_PATH) + + self.assertIsInstance( + config_util.load_yaml_config_file(YAML_PATH), dict) + + def test_load_yaml_config_raises_error_if_not_dict(self): + """ Test error raised when YAML file is not a dict. """ + with open(YAML_PATH, 'w') as f: + f.write('5') + + with self.assertRaises(HomeAssistantError): + config_util.load_yaml_config_file(YAML_PATH) + + def test_load_yaml_config_raises_error_if_malformed_yaml(self): + """ Test error raised if invalid YAML. """ + with open(YAML_PATH, 'w') as f: + f.write(':') + + with self.assertRaises(HomeAssistantError): + config_util.load_yaml_config_file(YAML_PATH) + + def test_load_config_loads_yaml_config(self): + """ Test correct YAML config loading. """ + with open(YAML_PATH, 'w') as f: + f.write('hello: world') + + self.assertEqual({'hello': 'world'}, + config_util.load_config_file(YAML_PATH)) + + @mock.patch('homeassistant.util.location.detect_location_info', + mock_detect_location_info) + @mock.patch('builtins.print') + def test_create_default_config_detect_location(self, mock_print): + """ Test that detect location sets the correct config keys. """ + config_util.ensure_config_exists(CONFIG_DIR) + + config = config_util.load_config_file(YAML_PATH) + + self.assertIn(DOMAIN, config) + + ha_conf = config[DOMAIN] + + expected_values = { + CONF_LATITUDE: 2.0, + CONF_LONGITUDE: 1.0, + CONF_TEMPERATURE_UNIT: 'F', + CONF_NAME: 'Home', + CONF_TIME_ZONE: 'America/Los_Angeles' + } + + self.assertEqual(expected_values, ha_conf) + self.assertTrue(mock_print.called) + + @mock.patch('builtins.print') + def test_create_default_config_returns_none_if_write_error(self, + mock_print): + """ + Test that writing default config to non existing folder returns None. + """ + self.assertIsNone( + config_util.create_default_config( + os.path.join(CONFIG_DIR, 'non_existing_dir/'), False)) + self.assertTrue(mock_print.called) diff --git a/tests/test_core.py b/tests/test_core.py index a5c37f753b9..1aab679805a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,18 +8,29 @@ Provides tests to verify that Home Assistant core works. # pylint: disable=too-few-public-methods import os import unittest +import unittest.mock as mock import time import threading from datetime import datetime -import homeassistant as ha +import pytz + +import homeassistant.core as ha +from homeassistant.exceptions import ( + HomeAssistantError, InvalidEntityFormatError) +import homeassistant.util.dt as dt_util +from homeassistant.helpers.event import track_state_change +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + ATTR_FRIENDLY_NAME, TEMP_CELCIUS, + TEMP_FAHRENHEIT) + +PST = pytz.timezone('America/Los_Angeles') class TestHomeAssistant(unittest.TestCase): """ Tests the Home Assistant core classes. - Currently only includes tests to test cases that do not - get tested in the API integration tests. """ def setUp(self): # pylint: disable=invalid-name @@ -30,15 +41,19 @@ class TestHomeAssistant(unittest.TestCase): def tearDown(self): # pylint: disable=invalid-name """ Stop down stuff we started. """ - self.hass.stop() + try: + self.hass.stop() + except HomeAssistantError: + # Already stopped after the block till stopped test + pass - def test_get_config_path(self): - """ Test get_config_path method. """ - self.assertEqual(os.path.join(os.getcwd(), "config"), - self.hass.config.config_dir) - - self.assertEqual(os.path.join(os.getcwd(), "config", "test.conf"), - self.hass.config.path("test.conf")) + def test_start(self): + calls = [] + self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, + lambda event: calls.append(1)) + self.hass.start() + self.hass.pool.block_till_done() + self.assertEqual(1, len(calls)) def test_block_till_stoped(self): """ Test if we can block till stop service is called. """ @@ -47,32 +62,52 @@ class TestHomeAssistant(unittest.TestCase): self.assertFalse(blocking_thread.is_alive()) blocking_thread.start() - # Python will now give attention to the other thread - time.sleep(1) + + # Threads are unpredictable, try 20 times if we're ready + wait_loops = 0 + while not blocking_thread.is_alive() and wait_loops < 20: + wait_loops += 1 + time.sleep(0.05) self.assertTrue(blocking_thread.is_alive()) self.hass.services.call(ha.DOMAIN, ha.SERVICE_HOMEASSISTANT_STOP) self.hass.pool.block_till_done() - # hass.block_till_stopped checks every second if it should quit - # we have to wait worst case 1 second + # Threads are unpredictable, try 20 times if we're ready wait_loops = 0 - while blocking_thread.is_alive() and wait_loops < 50: + while blocking_thread.is_alive() and wait_loops < 20: wait_loops += 1 - time.sleep(0.1) + time.sleep(0.05) self.assertFalse(blocking_thread.is_alive()) + def test_stopping_with_keyboardinterrupt(self): + calls = [] + self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: calls.append(1)) + + def raise_keyboardinterrupt(length): + # We don't want to patch the sleep of the timer. + if length == 1: + raise KeyboardInterrupt + + self.hass.start() + + with mock.patch('time.sleep', raise_keyboardinterrupt): + self.hass.block_till_stopped() + + self.assertEqual(1, len(calls)) + def test_track_point_in_time(self): """ Test track point in time. """ - before_birthday = datetime(1985, 7, 9, 12, 0, 0) - birthday_paulus = datetime(1986, 7, 9, 12, 0, 0) - after_birthday = datetime(1987, 7, 9, 12, 0, 0) + before_birthday = datetime(1985, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) + birthday_paulus = datetime(1986, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) + after_birthday = datetime(1987, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) runs = [] - self.hass.track_point_in_time( + self.hass.track_point_in_utc_time( lambda x: runs.append(1), birthday_paulus) self._send_time_changed(before_birthday) @@ -101,7 +136,7 @@ class TestHomeAssistant(unittest.TestCase): specific_runs = [] self.hass.track_time_change(lambda x: wildcard_runs.append(1)) - self.hass.track_time_change( + self.hass.track_utc_time_change( lambda x: specific_runs.append(1), second=[0, 30]) self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) @@ -126,6 +161,16 @@ class TestHomeAssistant(unittest.TestCase): class TestEvent(unittest.TestCase): """ Test Event class. """ + def test_eq(self): + now = dt_util.utcnow() + data = {'some': 'attr'} + event1, event2 = [ + ha.Event('some_type', data, time_fired=now) + for _ in range(2) + ] + + self.assertEqual(event1, event2) + def test_repr(self): """ Test that repr method works. #MoreCoverage """ self.assertEqual( @@ -138,13 +183,27 @@ class TestEvent(unittest.TestCase): {"beer": "nice"}, ha.EventOrigin.remote))) + def test_as_dict(self): + event_type = 'some_type' + now = dt_util.utcnow() + data = {'some': 'attr'} + + event = ha.Event(event_type, data, ha.EventOrigin.local, now) + expected = { + 'event_type': event_type, + 'data': data, + 'origin': 'LOCAL', + 'time_fired': dt_util.datetime_to_str(now), + } + self.assertEqual(expected, event.as_dict()) + class TestEventBus(unittest.TestCase): """ Test EventBus methods. """ def setUp(self): # pylint: disable=invalid-name """ things to be run when tests are started. """ - self.bus = ha.EventBus() + self.bus = ha.EventBus(ha.create_worker_pool(0)) self.bus.listen('test_event', lambda x: len) def tearDown(self): # pylint: disable=invalid-name @@ -153,6 +212,7 @@ class TestEventBus(unittest.TestCase): def test_add_remove_listener(self): """ Test remove_listener method. """ + self.bus._pool.add_worker() old_count = len(self.bus.listeners) listener = lambda x: len @@ -178,11 +238,10 @@ class TestEventBus(unittest.TestCase): self.bus.listen_once('test_event', lambda x: runs.append(1)) self.bus.fire('test_event') - self.bus._pool.block_till_done() - self.assertEqual(1, len(runs)) - # Second time it should not increase runs self.bus.fire('test_event') + + self.bus._pool.add_worker() self.bus._pool.block_till_done() self.assertEqual(1, len(runs)) @@ -193,9 +252,40 @@ class TestState(unittest.TestCase): def test_init(self): """ Test state.init """ self.assertRaises( - ha.InvalidEntityFormatError, ha.State, + InvalidEntityFormatError, ha.State, 'invalid_entity_format', 'test_state') + def test_domain(self): + state = ha.State('some_domain.hello', 'world') + self.assertEqual('some_domain', state.domain) + + def test_object_id(self): + state = ha.State('domain.hello', 'world') + self.assertEqual('hello', state.object_id) + + def test_name_if_no_friendly_name_attr(self): + state = ha.State('domain.hello_world', 'world') + self.assertEqual('hello world', state.name) + + def test_name_if_friendly_name_attr(self): + name = 'Some Unique Name' + state = ha.State('domain.hello_world', 'world', + {ATTR_FRIENDLY_NAME: name}) + self.assertEqual(name, state.name) + + def test_copy(self): + state = ha.State('domain.hello', 'world', {'some': 'attr'}) + self.assertEqual(state, state.copy()) + + def test_dict_conversion(self): + state = ha.State('domain.hello', 'world', {'some': 'attr'}) + self.assertEqual(state, ha.State.from_dict(state.as_dict())) + + def test_dict_conversion_with_wrong_data(self): + self.assertIsNone(ha.State.from_dict(None)) + self.assertIsNone(ha.State.from_dict({'state': 'yes'})) + self.assertIsNone(ha.State.from_dict({'entity_id': 'yes'})) + def test_repr(self): """ Test state.repr """ self.assertEqual("", @@ -214,14 +304,15 @@ class TestStateMachine(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """ things to be run when tests are started. """ - self.bus = ha.EventBus() + self.pool = ha.create_worker_pool(0) + self.bus = ha.EventBus(self.pool) self.states = ha.StateMachine(self.bus) self.states.set("light.Bowl", "on") self.states.set("switch.AC", "off") def tearDown(self): # pylint: disable=invalid-name """ Stop down stuff we started. """ - self.bus._pool.stop() + self.pool.stop() def test_is_state(self): """ Test is_state method. """ @@ -240,6 +331,10 @@ class TestStateMachine(unittest.TestCase): self.assertEqual(1, len(ent_ids)) self.assertTrue('light.bowl' in ent_ids) + def test_all(self): + states = sorted(state.entity_id for state in self.states.all()) + self.assertEqual(['light.bowl', 'switch.ac'], states) + def test_remove(self): """ Test remove method. """ self.assertTrue('light.bowl' in self.states.entity_ids()) @@ -251,6 +346,8 @@ class TestStateMachine(unittest.TestCase): def test_track_change(self): """ Test states.track_change. """ + self.pool.add_worker() + # 2 lists to track how often our callbacks got called specific_runs = [] wildcard_runs = [] @@ -287,10 +384,11 @@ class TestStateMachine(unittest.TestCase): self.assertEqual(3, len(wildcard_runs)) def test_case_insensitivty(self): + self.pool.add_worker() runs = [] - self.states.track_change( - 'light.BoWl', lambda a, b, c: runs.append(1), + track_state_change( + ha._MockHA(self.bus), 'light.BoWl', lambda a, b, c: runs.append(1), ha.MATCH_ALL, ha.MATCH_ALL) self.states.set('light.BOWL', 'off') @@ -328,16 +426,159 @@ class TestServiceRegistry(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """ things to be run when tests are started. """ - self.pool = ha.create_worker_pool() + self.pool = ha.create_worker_pool(0) self.bus = ha.EventBus(self.pool) self.services = ha.ServiceRegistry(self.bus, self.pool) - self.services.register("test_domain", "test_service", lambda x: len) + self.services.register("test_domain", "test_service", lambda x: None) def tearDown(self): # pylint: disable=invalid-name """ Stop down stuff we started. """ - self.pool.stop() + if self.pool.worker_count: + self.pool.stop() def test_has_service(self): """ Test has_service method. """ self.assertTrue( self.services.has_service("test_domain", "test_service")) + self.assertFalse( + self.services.has_service("test_domain", "non_existing")) + self.assertFalse( + self.services.has_service("non_existing", "test_service")) + + def test_services(self): + expected = { + 'test_domain': ['test_service'] + } + self.assertEqual(expected, self.services.services) + + def test_call_with_blocking_done_in_time(self): + self.pool.add_worker() + self.pool.add_worker() + calls = [] + self.services.register("test_domain", "register_calls", + lambda x: calls.append(1)) + + self.assertTrue( + self.services.call('test_domain', 'register_calls', blocking=True)) + self.assertEqual(1, len(calls)) + + def test_call_with_blocking_not_done_in_time(self): + calls = [] + self.services.register("test_domain", "register_calls", + lambda x: calls.append(1)) + + orig_limit = ha.SERVICE_CALL_LIMIT + ha.SERVICE_CALL_LIMIT = 0.01 + self.assertFalse( + self.services.call('test_domain', 'register_calls', blocking=True)) + self.assertEqual(0, len(calls)) + ha.SERVICE_CALL_LIMIT = orig_limit + + def test_call_non_existing_with_blocking(self): + self.pool.add_worker() + self.pool.add_worker() + orig_limit = ha.SERVICE_CALL_LIMIT + ha.SERVICE_CALL_LIMIT = 0.01 + self.assertFalse( + self.services.call('test_domain', 'i_do_not_exist', blocking=True)) + ha.SERVICE_CALL_LIMIT = orig_limit + + +class TestConfig(unittest.TestCase): + def setUp(self): # pylint: disable=invalid-name + """ things to be run when tests are started. """ + self.config = ha.Config() + + def test_config_dir_set_correct(self): + """ Test config dir set correct. """ + data_dir = os.getenv('APPDATA') if os.name == "nt" \ + else os.path.expanduser('~') + self.assertEqual(os.path.join(data_dir, ".homeassistant"), + self.config.config_dir) + + def test_path_with_file(self): + """ Test get_config_path method. """ + data_dir = os.getenv('APPDATA') if os.name == "nt" \ + else os.path.expanduser('~') + self.assertEqual(os.path.join(data_dir, ".homeassistant", "test.conf"), + self.config.path("test.conf")) + + def test_path_with_dir_and_file(self): + """ Test get_config_path method. """ + data_dir = os.getenv('APPDATA') if os.name == "nt" \ + else os.path.expanduser('~') + self.assertEqual( + os.path.join(data_dir, ".homeassistant", "dir", "test.conf"), + self.config.path("dir", "test.conf")) + + def test_temperature_not_convert_if_no_preference(self): + """ No unit conversion to happen if no preference. """ + self.assertEqual( + (25, TEMP_CELCIUS), + self.config.temperature(25, TEMP_CELCIUS)) + self.assertEqual( + (80, TEMP_FAHRENHEIT), + self.config.temperature(80, TEMP_FAHRENHEIT)) + + def test_temperature_not_convert_if_invalid_value(self): + """ No unit conversion to happen if no preference. """ + self.config.temperature_unit = TEMP_FAHRENHEIT + self.assertEqual( + ('25a', TEMP_CELCIUS), + self.config.temperature('25a', TEMP_CELCIUS)) + + def test_temperature_not_convert_if_invalid_unit(self): + """ No unit conversion to happen if no preference. """ + self.assertEqual( + (25, 'Invalid unit'), + self.config.temperature(25, 'Invalid unit')) + + def test_temperature_to_convert_to_celcius(self): + self.config.temperature_unit = TEMP_CELCIUS + + self.assertEqual( + (25, TEMP_CELCIUS), + self.config.temperature(25, TEMP_CELCIUS)) + self.assertEqual( + (26.7, TEMP_CELCIUS), + self.config.temperature(80, TEMP_FAHRENHEIT)) + + def test_temperature_to_convert_to_fahrenheit(self): + self.config.temperature_unit = TEMP_FAHRENHEIT + + self.assertEqual( + (77, TEMP_FAHRENHEIT), + self.config.temperature(25, TEMP_CELCIUS)) + self.assertEqual( + (80, TEMP_FAHRENHEIT), + self.config.temperature(80, TEMP_FAHRENHEIT)) + + def test_as_dict(self): + expected = { + 'latitude': None, + 'longitude': None, + 'temperature_unit': None, + 'location_name': None, + 'time_zone': 'UTC', + 'components': [], + } + + self.assertEqual(expected, self.config.as_dict()) + + +class TestWorkerPool(unittest.TestCase): + def test_exception_during_job(self): + pool = ha.create_worker_pool(1) + + def malicious_job(_): + raise Exception("Test breaking worker pool") + + calls = [] + + def register_call(_): + calls.append(1) + + pool.add_job(ha.JobPriority.EVENT_DEFAULT, (malicious_job, None)) + pool.add_job(ha.JobPriority.EVENT_DEFAULT, (register_call, None)) + pool.block_till_done() + self.assertEqual(1, len(calls)) diff --git a/tests/test_loader.py b/tests/test_loader.py index dd80587b247..67b8e8d11a6 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -10,14 +10,13 @@ import unittest import homeassistant.loader as loader import homeassistant.components.http as http -from helpers import get_test_home_assistant, MockModule +from common import get_test_home_assistant, MockModule class TestLoader(unittest.TestCase): """ Test the loader module. """ def setUp(self): # pylint: disable=invalid-name self.hass = get_test_home_assistant() - loader.prepare(self.hass) def tearDown(self): # pylint: disable=invalid-name """ Stop down stuff we started. """ diff --git a/tests/test_remote.py b/tests/test_remote.py index 7c00cbfd526..31ccad8f7aa 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -8,8 +8,9 @@ Uses port 8125 as a port that nothing runs on """ # pylint: disable=protected-access,too-many-public-methods import unittest +from unittest.mock import patch -import homeassistant as ha +import homeassistant.core as ha import homeassistant.bootstrap as bootstrap import homeassistant.remote as remote import homeassistant.components.http as http @@ -29,7 +30,9 @@ def _url(path=""): return HTTP_BASE_URL + path -def setUpModule(): # pylint: disable=invalid-name +@patch('homeassistant.components.http.util.get_local_ip', + return_value='127.0.0.1') +def setUpModule(mock_get_local_ip): # pylint: disable=invalid-name """ Initalizes a Home Assistant server and Slave instance. """ global hass, slave, master_api, broken_api @@ -51,6 +54,10 @@ def setUpModule(): # pylint: disable=invalid-name # Start slave slave = remote.HomeAssistant(master_api) + bootstrap.setup_component( + slave, http.DOMAIN, + {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, + http.CONF_SERVER_PORT: 8130}}) slave.start() @@ -130,9 +137,12 @@ class TestRemoteMethods(unittest.TestCase): def test_set_state(self): """ Test Python API set_state. """ - self.assertTrue(remote.set_state(master_api, 'test.test', 'set_test')) + hass.states.set('test.test', 'set_test') - self.assertEqual('set_test', hass.states.get('test.test').state) + state = hass.states.get('test.test') + + self.assertIsNotNone(state) + self.assertEqual('set_test', state.state) self.assertFalse(remote.set_state(broken_api, 'test.test', 'set_test')) diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/util/test_color.py b/tests/util/test_color.py new file mode 100644 index 00000000000..6b0d169f516 --- /dev/null +++ b/tests/util/test_color.py @@ -0,0 +1,22 @@ +""" +Tests Home Assistant color util methods. +""" +import unittest +import homeassistant.util.color as color_util + + +class TestColorUtil(unittest.TestCase): + # pylint: disable=invalid-name + def test_color_RGB_to_xy(self): + """ Test color_RGB_to_xy. """ + self.assertEqual((0, 0), color_util.color_RGB_to_xy(0, 0, 0)) + self.assertEqual((0.3127159072215825, 0.3290014805066623), + color_util.color_RGB_to_xy(255, 255, 255)) + + self.assertEqual((0.15001662234042554, 0.060006648936170214), + color_util.color_RGB_to_xy(0, 0, 255)) + + self.assertEqual((0.3, 0.6), color_util.color_RGB_to_xy(0, 255, 0)) + + self.assertEqual((0.6400744994567747, 0.3299705106316933), + color_util.color_RGB_to_xy(255, 0, 0)) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py new file mode 100644 index 00000000000..5deafb58040 --- /dev/null +++ b/tests/util/test_dt.py @@ -0,0 +1,137 @@ +""" +tests.test_util +~~~~~~~~~~~~~~~~~ + +Tests Home Assistant date util methods. +""" +# pylint: disable=too-many-public-methods +import unittest +from datetime import datetime, timedelta + +import homeassistant.util.dt as dt_util + +TEST_TIME_ZONE = 'America/Los_Angeles' + + +class TestDateUtil(unittest.TestCase): + """ Tests util date methods. """ + + def setUp(self): + self.orig_default_time_zone = dt_util.DEFAULT_TIME_ZONE + + def tearDown(self): + dt_util.set_default_time_zone(self.orig_default_time_zone) + + def test_get_time_zone_retrieves_valid_time_zone(self): + """ Test getting a time zone. """ + time_zone = dt_util.get_time_zone(TEST_TIME_ZONE) + + self.assertIsNotNone(time_zone) + self.assertEqual(TEST_TIME_ZONE, time_zone.zone) + + def test_get_time_zone_returns_none_for_garbage_time_zone(self): + """ Test getting a non existing time zone. """ + time_zone = dt_util.get_time_zone("Non existing time zone") + + self.assertIsNone(time_zone) + + def test_set_default_time_zone(self): + """ Test setting default time zone. """ + time_zone = dt_util.get_time_zone(TEST_TIME_ZONE) + + dt_util.set_default_time_zone(time_zone) + + # We cannot compare the timezones directly because of DST + self.assertEqual(time_zone.zone, dt_util.now().tzinfo.zone) + + def test_utcnow(self): + """ Test the UTC now method. """ + self.assertAlmostEqual( + dt_util.utcnow().replace(tzinfo=None), + datetime.utcnow(), + delta=timedelta(seconds=1)) + + def test_now(self): + """ Test the now method. """ + dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE)) + + self.assertAlmostEqual( + dt_util.as_utc(dt_util.now()).replace(tzinfo=None), + datetime.utcnow(), + delta=timedelta(seconds=1)) + + def test_as_utc_with_naive_object(self): + utcnow = datetime.utcnow() + + self.assertEqual(utcnow, + dt_util.as_utc(utcnow).replace(tzinfo=None)) + + def test_as_utc_with_utc_object(self): + utcnow = dt_util.utcnow() + + self.assertEqual(utcnow, dt_util.as_utc(utcnow)) + + def test_as_utc_with_local_object(self): + dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE)) + + localnow = dt_util.now() + + utcnow = dt_util.as_utc(localnow) + + self.assertEqual(localnow, utcnow) + self.assertNotEqual(localnow.tzinfo, utcnow.tzinfo) + + def test_as_local_with_naive_object(self): + now = dt_util.now() + + self.assertAlmostEqual( + now, dt_util.as_local(datetime.utcnow()), + delta=timedelta(seconds=1)) + + def test_as_local_with_local_object(self): + now = dt_util.now() + + self.assertEqual(now, now) + + def test_as_local_with_utc_object(self): + dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE)) + + utcnow = dt_util.utcnow() + localnow = dt_util.as_local(utcnow) + + self.assertEqual(localnow, utcnow) + self.assertNotEqual(localnow.tzinfo, utcnow.tzinfo) + + def test_utc_from_timestamp(self): + """ Test utc_from_timestamp method. """ + self.assertEqual( + datetime(1986, 7, 9, tzinfo=dt_util.UTC), + dt_util.utc_from_timestamp(521251200)) + + def test_datetime_to_str(self): + """ Test datetime_to_str. """ + self.assertEqual( + "12:00:00 09-07-1986", + dt_util.datetime_to_str(datetime(1986, 7, 9, 12, 0, 0))) + + def test_datetime_to_local_str(self): + """ Test datetime_to_local_str. """ + self.assertEqual( + dt_util.datetime_to_str(dt_util.now()), + dt_util.datetime_to_local_str(dt_util.utcnow())) + + def test_str_to_datetime_converts_correctly(self): + """ Test str_to_datetime converts strings. """ + self.assertEqual( + datetime(1986, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC), + dt_util.str_to_datetime("12:00:00 09-07-1986")) + + def test_str_to_datetime_returns_none_for_incorrect_format(self): + """ Test str_to_datetime returns None if incorrect format. """ + self.assertIsNone(dt_util.str_to_datetime("not a datetime string")) + + def test_strip_microseconds(self): + test_time = datetime(2015, 1, 1, microsecond=5000) + + self.assertNotEqual(0, test_time.microsecond) + self.assertEqual(0, dt_util.strip_microseconds(test_time).microsecond) diff --git a/tests/test_util.py b/tests/util/test_init.py similarity index 87% rename from tests/test_util.py rename to tests/util/test_init.py index 038db227e1a..5a4fb44b2d4 100644 --- a/tests/test_util.py +++ b/tests/util/test_init.py @@ -35,17 +35,6 @@ class TestUtil(unittest.TestCase): self.assertEqual("Test_More", util.slugify("Test More")) self.assertEqual("Test_More", util.slugify("Test_(More)")) - def test_datetime_to_str(self): - """ Test datetime_to_str. """ - self.assertEqual("12:00:00 09-07-1986", - util.datetime_to_str(datetime(1986, 7, 9, 12, 0, 0))) - - def test_str_to_datetime(self): - """ Test str_to_datetime. """ - self.assertEqual(datetime(1986, 7, 9, 12, 0, 0), - util.str_to_datetime("12:00:00 09-07-1986")) - self.assertIsNone(util.str_to_datetime("not a datetime string")) - def test_split_entity_id(self): """ Test split_entity_id. """ self.assertEqual(['domain', 'object_id'], @@ -61,21 +50,6 @@ class TestUtil(unittest.TestCase): self.assertEqual("12:00:00 09-07-1986", util.repr_helper(datetime(1986, 7, 9, 12, 0, 0))) - # pylint: disable=invalid-name - def test_color_RGB_to_xy(self): - """ Test color_RGB_to_xy. """ - self.assertEqual((0, 0), util.color_RGB_to_xy(0, 0, 0)) - self.assertEqual((0.3127159072215825, 0.3290014805066623), - util.color_RGB_to_xy(255, 255, 255)) - - self.assertEqual((0.15001662234042554, 0.060006648936170214), - util.color_RGB_to_xy(0, 0, 255)) - - self.assertEqual((0.3, 0.6), util.color_RGB_to_xy(0, 255, 0)) - - self.assertEqual((0.6400744994567747, 0.3299705106316933), - util.color_RGB_to_xy(255, 0, 0)) - def test_convert(self): """ Test convert. """ self.assertEqual(5, util.convert("5", int))