diff --git a/.coveragerc b/.coveragerc index 51d6d81179f..62de8bcc4e1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,6 +3,7 @@ source = homeassistant omit = homeassistant/__main__.py + homeassistant/scripts/*.py # omit pieces of code that rely on external devices being present homeassistant/components/apcupsd.py @@ -87,8 +88,13 @@ omit = homeassistant/components/homematic.py homeassistant/components/*/homematic.py + homeassistant/components/knx.py + homeassistant/components/switch/knx.py + homeassistant/components/binary_sensor/knx.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/nx584.py + homeassistant/components/alarm_control_panel/simplisafe.py homeassistant/components/binary_sensor/arest.py homeassistant/components/binary_sensor/rest.py homeassistant/components/browser.py @@ -120,6 +126,7 @@ omit = homeassistant/components/garage_door/rpi_gpio.py homeassistant/components/hdmi_cec.py homeassistant/components/ifttt.py + homeassistant/components/joaoapps_join.py homeassistant/components/keyboard.py homeassistant/components/light/blinksticklight.py homeassistant/components/light/hue.py @@ -156,6 +163,7 @@ omit = homeassistant/components/notify/gntp.py homeassistant/components/notify/googlevoice.py homeassistant/components/notify/instapush.py + homeassistant/components/notify/joaoapps_join.py homeassistant/components/notify/message_bird.py homeassistant/components/notify/nma.py homeassistant/components/notify/pushbullet.py @@ -185,6 +193,7 @@ omit = homeassistant/components/sensor/glances.py homeassistant/components/sensor/google_travel_time.py homeassistant/components/sensor/gtfs.py + homeassistant/components/sensor/imap.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/neurio_energy.py @@ -209,6 +218,7 @@ omit = homeassistant/components/sensor/twitch.py homeassistant/components/sensor/uber.py homeassistant/components/sensor/worldclock.py + homeassistant/components/sensor/yweather.py homeassistant/components/switch/acer_projector.py homeassistant/components/switch/arest.py homeassistant/components/switch/dlink.py @@ -220,6 +230,7 @@ omit = homeassistant/components/switch/pulseaudio_loopback.py homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py + homeassistant/components/switch/tplink.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wake_on_lan.py homeassistant/components/thermostat/eq3btsmart.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 566b518c2a4..72586cc4f21 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,7 +15,7 @@ If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) -If code communicates with devices: +If code communicates with devices, web services, or a: - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass** - [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]). - [ ] New dependencies are only imported inside functions that use them ([example][ex-import]). @@ -26,8 +26,5 @@ If the code does not interact with devices: - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass** - [ ] Tests have been added to verify that the new code works. -[fork]: http://stackoverflow.com/a/7244456 -[squash]: https://github.com/ginatrapani/todo.txt-android/wiki/Squash-All-Commits-Related-to-a-Single-Issue-into-a-Single-Commit [ex-requir]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L16 [ex-import]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L51 - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 221b46c65ee..8621851ffb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,79 +9,5 @@ The process is straight-forward. - Ensure tests work. - Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant. -Still interested? Then you should read the next sections and get more details. +Still interested? Then you should take a peak at the [developer documentation](https://home-assistant.io/developers/) to 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: - - - Check that all dependencies are included via the `REQUIREMENTS` variable in your platform/component and only imported inside functions that use them. - - Add any new dependencies to `requirements_all.txt` if needed. Use `script/gen_requirements_all.py`. - - Update the `.coveragerc` file to exclude your platform if there are no tests available or your new code uses a 3rd party library for communication with the device/service/sensor. - - Provide some documentation for [home-assistant.io](https://home-assistant.io/). It's OK to just add a docstring with configuration details (sample entry for `configuration.yaml` file and alike) to the file header as a start. Visit the [website documentation](https://home-assistant.io/developers/website/) for further information on contributing to [home-assistant.io](https://github.com/home-assistant/home-assistant.io). - - Make sure all your code passes ``pylint`` and ``flake8`` (PEP8 and some more) validation. To check your repository, run `tox` or `script/lint`. - - Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant. - - Check for comments and suggestions on your Pull Request and keep an eye on the [CI output](https://travis-ci.org/home-assistant/home-assistant/). - -If you add a platform for an existing component, there is usually no need for updating the frontend. Only if you've added a new component that should show up in the frontend, there are more steps needed: - - - Update the file [`home-assistant-icons.html`](https://github.com/home-assistant/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 `home-assistant-icons.html`, you've made changes to the frontend: - - - Run `script/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 - -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. - -A state can have several attributes that will help the frontend in displaying your state: - - - `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/home-assistant/home-assistant/blob/master/homeassistant/components/__init__.py#L25). - -### Proper Visibility Handling - -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/home-assistant/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. - -## Testing your code - -To test your code before submission, used the `tox` tool. - -```bash -> pip install -U tox -> tox -``` - -This will run unit tests against python 3.4 and 3.5 (if both are available locally), as well as run a set of tests which validate `pep8` and `pylint` style of the code. - -You can optionally run tests on only one tox target using the `-e` option to select an environment. - -For instance `tox -e lint` will run the linters only, `tox -e py34` will run unit tests only on python 3.4. - -### 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/README.rst b/README.rst index b63377dbaf0..94255ff1d39 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,7 @@ tutorials and documentation. |screenshot-states| -Examples of devices it can interface it: +Examples of devices Home Assistant can interface with: - Monitoring connected devices to a wireless router: `OpenWrt `__, @@ -61,11 +61,11 @@ Examples of devices it can interface it: - `See full list of supported devices `__ -Built home automation on top of your devices: +Build home automation on top of your devices: - Keep a precise history of every change to the state of your house -- Turn on the lights when people get home after sun set -- Turn on lights slowly during sun set to compensate for less light +- Turn on the lights when people get home after sunset +- Turn on lights slowly during sunset to compensate for less light - Turn off all lights and devices when everybody leaves the house - Offers a `REST API `__ and can interface with MQTT for easy integration with other projects @@ -75,10 +75,10 @@ Built home automation on top of your devices: (NMA) `__, `PushBullet `__, `PushOver `__, `Slack `__, - `Telegram `__, and `Jabber + `Telegram `__, `Join `__, and `Jabber (XMPP) `__ -The system is built modular so support for other devices or actions can +The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture `__ and the `section on creating your own diff --git a/config/configuration.yaml.example b/config/configuration.yaml.example index e7483740bc3..abf4bd5b035 100644 --- a/config/configuration.yaml.example +++ b/config/configuration.yaml.example @@ -7,6 +7,9 @@ homeassistant: latitude: 32.87336 longitude: 117.22743 + # Impacts weather/sunrise data + elevation: 665 + # C for Celsius, F for Fahrenheit temperature_unit: C @@ -22,8 +25,8 @@ http: # Set to 1 to enable development mode # development: 1 +# Enable the frontend frontend: -# enable the frontend light: # platform: hue @@ -33,17 +36,12 @@ wink: access_token: 'YOUR_TOKEN' device_tracker: - # The following types are available: ddwrt, netgear, tomato, luci, - # and nmap_tracker + # The following tracker are available: + # https://home-assistant.io/components/#presence-detection platform: netgear host: 192.168.1.1 username: admin password: PASSWORD - # http_id is needed for Tomato routers only - # http_id: ABCDEFGHH - # For nmap_tracker, only the IP addresses to scan are needed: - # hosts: 192.168.1.1/24 # netmask prefix notation or - # hosts: 192.168.1.1-255 # address range chromecast: @@ -74,24 +72,25 @@ device_sun_light_trigger: # 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) # You can also have groups within groups. +# https://home-assistant.io/components/group/ group: - Home: - - group.living_room - - group.kitchen - living_room: - - light.Bowl - - light.Ceiling - - light.TV_back_light - kitchen: - - light.fan_bulb_1 - - light.fan_bulb_2 - children: - - device_tracker.child_1 - - device_tracker.child_2 + default_view: + view: yes + entities: + - group.awesome_people + - group.climate -process: - # items are which processes to look for: : - xbmc: XBMC.App + kitchen: + name: Kitchen + entities: + - switch.kitchen_pin_3 + upstairs: + name: Kids + icon: mdi:account-multiple + view: yes + entities: + - input_boolean.notify_home + - camera.demo_camera example: @@ -105,6 +104,7 @@ browser: keyboard: +# https://home-assistant.io/getting-started/automation/ automation: - alias: 'Rule 1 Light on in the evening' trigger: @@ -126,7 +126,6 @@ automation: entity_id: group.living_room - alias: 'Rule 2 - Away Mode' - trigger: - platform: state entity_id: group.all_devices @@ -139,6 +138,14 @@ automation: # Sensors need to be added into the configuration.yaml as sensor:, sensor 2:, sensor 3:, etc. # Each sensor label should be unique or your sensors might not load correctly. +# Another way to do is to collect all entries under one "sensor:" +# sensor: +# - platform: mqtt +# name: "MQTT Sensor 1" +# - platform: mqtt +# name: "MQTT Sensor 2" +# +# Details: https://home-assistant.io/getting-started/devices/ sensor: platform: systemmonitor @@ -149,14 +156,6 @@ sensor: arg: '/home' - type: 'disk_use' arg: '/home' - - type: 'disk_free' - arg: '/' - - type: 'memory_use_percent' - - type: 'memory_use' - - type: 'memory_free' - - type: 'processor_use' - - type: 'process' - arg: 'octave-cli' sensor 2: platform: forecast @@ -166,14 +165,6 @@ sensor 2: - precip_type - precip_intensity - temperature - - dew_point - - wind_speed - - wind_bearing - - cloud_cover - - humidity - - pressure - - visibility - - ozone script: # Turns on the bedroom lights and then the living room lights 1 minute later diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 186485973af..d92c434d22b 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -7,7 +7,6 @@ import platform import subprocess import sys import threading -import time from homeassistant.const import ( __version__, @@ -110,22 +109,14 @@ def get_arguments(): type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') - parser.add_argument( - '--install-osx', - action='store_true', - help='Installs as a service on OS X and loads on boot.') - parser.add_argument( - '--uninstall-osx', - action='store_true', - help='Uninstalls from OS X.') - parser.add_argument( - '--restart-osx', - action='store_true', - help='Restarts on OS X.') parser.add_argument( '--runner', action='store_true', help='On restart exit with code {}'.format(RESTART_EXIT_CODE)) + parser.add_argument( + '--script', + nargs=argparse.REMAINDER, + help='Run one of the embedded scripts') if os.name == "posix": parser.add_argument( '--daemon', @@ -196,46 +187,6 @@ def write_pid(pid_file): sys.exit(1) -def install_osx(): - """Setup to run via launchd on OS X.""" - with os.popen('which hass') as inp: - hass_path = inp.read().strip() - - with os.popen('whoami') as inp: - user = inp.read().strip() - - cwd = os.path.dirname(__file__) - template_path = os.path.join(cwd, 'startup', 'launchd.plist') - - with open(template_path, 'r', encoding='utf-8') as inp: - plist = inp.read() - - plist = plist.replace("$HASS_PATH$", hass_path) - plist = plist.replace("$USER$", user) - - path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") - - try: - with open(path, 'w', encoding='utf-8') as outp: - outp.write(plist) - except IOError as err: - print('Unable to write to ' + path, err) - return - - os.popen('launchctl load -w -F ' + path) - - print("Home Assistant has been installed. \ - Open it here: http://localhost:8123") - - -def uninstall_osx(): - """Unload from launchd on OS X.""" - path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") - os.popen('launchctl unload ' + path) - - print("Home Assistant has been uninstalled.") - - def closefds_osx(min_fd, max_fd): """Make sure file descriptors get closed when we restart. @@ -358,23 +309,13 @@ def main(): args = get_arguments() + if args.script is not None: + from homeassistant import scripts + return scripts.run(args.script) + config_dir = os.path.join(os.getcwd(), args.config) ensure_config_path(config_dir) - # OS X launchd functions - if args.install_osx: - install_osx() - return 0 - if args.uninstall_osx: - uninstall_osx() - return 0 - if args.restart_osx: - uninstall_osx() - # A small delay is needed on some systems to let the unload finish. - time.sleep(0.5) - install_osx() - return 0 - # Daemon functions if args.pid_file: check_pid(args.pid_file) diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py new file mode 100644 index 00000000000..a248df5fc21 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -0,0 +1,124 @@ +""" +Interfaces with SimpliSafe alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.simplisafe/ +""" +import logging + +import homeassistant.components.alarm_control_panel as alarm + +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN, + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['https://github.com/w1ll1am23/simplisafe-python/archive/' + '586fede0e85fd69e56e516aaa8e97eb644ca8866.zip#' + 'simplisafe-python==0.0.1'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the SimpliSafe platform.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + if username is None or password is None: + _LOGGER.error('Must specify username and password!') + return False + + add_devices([SimpliSafeAlarm( + config.get('name', "SimpliSafe"), + username, + password, + config.get('code'))]) + + +# pylint: disable=abstract-method +class SimpliSafeAlarm(alarm.AlarmControlPanel): + """Representation a SimpliSafe alarm.""" + + def __init__(self, name, username, password, code): + """Initialize the SimpliSafe alarm.""" + from simplisafe import SimpliSafe + self.simplisafe = SimpliSafe(username, password) + self._name = name + self._code = str(code) if code else None + self._id = self.simplisafe.get_id() + status = self.simplisafe.get_state() + if status == 'Off': + self._state = STATE_ALARM_DISARMED + elif status == 'Home': + self._state = STATE_ALARM_ARMED_HOME + elif status == 'Away': + self._state = STATE_ALARM_ARMED_AWAY + else: + self._state = STATE_UNKNOWN + + @property + def should_poll(self): + """Poll the SimpliSafe API.""" + return True + + @property + def name(self): + """Return the name of the device.""" + if self._name is not None: + return self._name + else: + return 'Alarm {}'.format(self._id) + + @property + def code_format(self): + """One or more characters if code is defined.""" + return None if self._code is None else '.+' + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Update alarm status.""" + self.simplisafe.get_location() + status = self.simplisafe.get_state() + + if status == 'Off': + self._state = STATE_ALARM_DISARMED + elif status == 'Home': + self._state = STATE_ALARM_ARMED_HOME + elif status == 'Away': + self._state = STATE_ALARM_ARMED_AWAY + else: + self._state = STATE_UNKNOWN + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if not self._validate_code(code, 'disarming'): + return + self.simplisafe.set_state('off') + _LOGGER.info('SimpliSafe alarm disarming') + self.update() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if not self._validate_code(code, 'arming home'): + return + self.simplisafe.set_state('home') + _LOGGER.info('SimpliSafe alarm arming home') + self.update() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if not self._validate_code(code, 'arming away'): + return + self.simplisafe.set_state('away') + _LOGGER.info('SimpliSafe alarm arming away') + self.update() + + def _validate_code(self, code, state): + """Validate given code.""" + check = self._code is None or code == self._code + if not check: + _LOGGER.warning('Wrong code entered for %s', state) + return check diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 144de83aa53..6ed257bd809 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Perform the setup for Envisalink sensor devices.""" + """Setup Envisalink binary sensor devices.""" _configured_zones = discovery_info['zones'] for zone_num in _configured_zones: _device_config_data = ZONE_SCHEMA(_configured_zones[zone_num]) @@ -33,7 +33,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): - """Representation of an envisalink Binary Sensor.""" + """Representation of an Envisalink binary sensor.""" # pylint: disable=too-many-arguments def __init__(self, zone_number, zone_name, zone_type, info, controller): diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py new file mode 100644 index 00000000000..e5a36ceb867 --- /dev/null +++ b/homeassistant/components/binary_sensor/knx.py @@ -0,0 +1,24 @@ +""" +Contains functionality to use a KNX group address as a binary. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.knx/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.knx import ( + KNXConfig, KNXGroupAddress) + +DEPENDENCIES = ["knx"] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Setup the KNX binary sensor platform.""" + add_entities([ + KNXSwitch(hass, KNXConfig(config)) + ]) + + +class KNXSwitch(KNXGroupAddress, BinarySensorDevice): + """Representation of a KNX binary sensor device.""" + + pass diff --git a/homeassistant/components/binary_sensor/nx584.py b/homeassistant/components/binary_sensor/nx584.py index 5c4ca78b14e..6a287599e69 100644 --- a/homeassistant/components/binary_sensor/nx584.py +++ b/homeassistant/components/binary_sensor/nx584.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup nx584 sensors.""" + """Setup nx584 binary sensor platform.""" from nx584 import client as nx584_client host = config.get('host', 'localhost:5007') diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index 17f4544a5ea..dc1b7a005c0 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at at https://home-assistant.io/components/sensor.wink/ """ import logging +import json from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.sensor.wink import WinkDevice @@ -12,7 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] # These are the available sensors mapped to binary_sensor class SENSOR_TYPES = { @@ -24,7 +25,7 @@ SENSOR_TYPES = { def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Wink platform.""" + """Setup the Wink binary sensor platform.""" import pywink if discovery_info is None: @@ -42,9 +43,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if sensor.capability() in SENSOR_TYPES: add_devices([WinkBinarySensorDevice(sensor)]) + for key in pywink.get_keys(): + add_devices([WinkBinarySensorDevice(key)]) + class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): - """Representation of a Wink sensor.""" + """Representation of a Wink binary sensor.""" def __init__(self, wink): """Initialize the Wink binary sensor.""" @@ -53,6 +57,14 @@ class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): self._unit_of_measurement = self.wink.UNIT self.capability = self.wink.capability() + def _pubnub_update(self, message, channel): + if 'data' in message: + json_data = json.dumps(message.get('data')) + else: + json_data = message + self.wink.pubnub_update(json.loads(json_data)) + self.update_ha_state() + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/binary_sensor/zigbee.py b/homeassistant/components/binary_sensor/zigbee.py index ac9e542c9d5..7e4139d4680 100644 --- a/homeassistant/components/binary_sensor/zigbee.py +++ b/homeassistant/components/binary_sensor/zigbee.py @@ -12,7 +12,7 @@ DEPENDENCIES = ["zigbee"] def setup_platform(hass, config, add_entities, discovery_info=None): - """Create and add an entity based on the configuration.""" + """Setup the ZigBee binary sensor platform.""" add_entities([ ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config)) ]) diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py index 14268e1c124..0f2a13263aa 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -8,6 +8,7 @@ import logging import datetime import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_point_in_time +from homeassistant.helpers.entity import Entity from homeassistant.components import zwave from homeassistant.components.binary_sensor import ( DOMAIN, @@ -31,7 +32,7 @@ DEVICE_MAPPINGS = { def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Z-Wave platform for sensors.""" + """Setup the Z-Wave platform for binary sensors.""" if discovery_info is None or zwave.NETWORK is None: return @@ -61,7 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ZWaveBinarySensor(value, None)]) -class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): +class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity, Entity): """Representation of a binary sensor within Z-Wave.""" def __init__(self, value, sensor_class): @@ -97,7 +98,7 @@ class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): self.update_ha_state() -class ZWaveTriggerSensor(ZWaveBinarySensor): +class ZWaveTriggerSensor(ZWaveBinarySensor, Entity): """Representation of a stateless sensor within Z-Wave.""" def __init__(self, sensor_value, sensor_class, hass, re_arm_sec=60): diff --git a/homeassistant/components/camera/demo.py b/homeassistant/components/camera/demo.py index e3ab0d6b059..5e451c48b40 100644 --- a/homeassistant/components/camera/demo.py +++ b/homeassistant/components/camera/demo.py @@ -18,7 +18,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DemoCamera(Camera): - """A Demo camera.""" + """The representation of a Demo camera.""" def __init__(self, name): """Initialize demo camera component.""" diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py index 752a28f1bbc..8462d4597dd 100644 --- a/homeassistant/components/camera/netatmo.py +++ b/homeassistant/components/camera/netatmo.py @@ -89,7 +89,7 @@ class WelcomeData(object): """Return all module available on the API as a list.""" self.update() if not self.home: - for home in self.welcomedata.cameras.keys(): + for home in self.welcomedata.cameras: for camera in self.welcomedata.cameras[home].values(): self.camera_names.append(camera['name']) else: diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 254898cea4b..6a5d3a0d93c 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -27,7 +27,7 @@ SERVICE_PROCESS_SCHEMA = vol.Schema({ REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') -REQUIREMENTS = ['fuzzywuzzy==0.10.0'] +REQUIREMENTS = ['fuzzywuzzy==0.11.0'] def setup(hass, config): @@ -67,8 +67,8 @@ def setup(hass, config): }, blocking=True) else: - logger.error( - 'Got unsupported command %s from text %s', command, text) + logger.error('Got unsupported command %s from text %s', + command, text) hass.services.register(DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 07b4343803c..d518587d298 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -377,12 +377,16 @@ def load_config(path, hass, consider_home, home_range): """Load devices from YAML configuration file.""" if not os.path.isfile(path): return [] - return [ - Device(hass, consider_home, home_range, device.get('track', False), - str(dev_id).lower(), str(device.get('mac')).upper(), - device.get('name'), device.get('picture'), - device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) - for dev_id, device in load_yaml_config_file(path).items()] + try: + return [ + Device(hass, consider_home, home_range, device.get('track', False), + str(dev_id).lower(), str(device.get('mac')).upper(), + device.get('name'), device.get('picture'), + device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) + for dev_id, device in load_yaml_config_file(path).items()] + except HomeAssistantError: + # When YAML file could not be loaded/did not contain a dict + return [] def setup_scanner_platform(hass, config, scanner, see_device): diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 725a49308be..ec1d073c436 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -62,8 +62,9 @@ def get_scanner(hass, config): _LOGGER): return None elif CONF_PASSWORD not in config[DOMAIN] and \ + 'ssh_key' not in config[DOMAIN] and \ 'pub_key' not in config[DOMAIN]: - _LOGGER.error("Either a public key or password must be provided") + _LOGGER.error('Either a private key or password must be provided') return None scanner = AsusWrtDeviceScanner(config[DOMAIN]) @@ -83,8 +84,8 @@ class AsusWrtDeviceScanner(object): """Initialize the scanner.""" self.host = config[CONF_HOST] self.username = str(config[CONF_USERNAME]) - self.password = str(config.get(CONF_PASSWORD, "")) - self.pub_key = str(config.get('pub_key', "")) + self.password = str(config.get(CONF_PASSWORD, '')) + self.ssh_key = str(config.get('ssh_key', config.get('pub_key', ''))) self.protocol = config.get('protocol') self.mode = config.get('mode') @@ -120,7 +121,7 @@ class AsusWrtDeviceScanner(object): return False with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info('Checking ARP') data = self.get_asuswrt_data() if not data: return False @@ -138,12 +139,12 @@ class AsusWrtDeviceScanner(object): try: ssh = pxssh.pxssh() - if self.pub_key: - ssh.login(self.host, self.username, ssh_key=self.pub_key) + if self.ssh_key: + ssh.login(self.host, self.username, ssh_key=self.ssh_key) elif self.password: ssh.login(self.host, self.username, self.password) else: - _LOGGER.error('No password or public key specified') + _LOGGER.error('No password or private key specified') return None ssh.sendline(_IP_NEIGH_CMD) ssh.prompt() @@ -195,16 +196,16 @@ class AsusWrtDeviceScanner(object): telnet.write('exit\n'.encode('ascii')) return AsusWrtResult(neighbors, leases_result, arp_result) except EOFError: - _LOGGER.error("Unexpected response from router") + _LOGGER.error('Unexpected response from router') return None except ConnectionRefusedError: - _LOGGER.error("Connection refused by router, is telnet enabled?") + _LOGGER.error('Connection refused by router, is telnet enabled?') return None except socket.gaierror as exc: - _LOGGER.error("Socket exception: %s", exc) + _LOGGER.error('Socket exception: %s', exc) return None except OSError as exc: - _LOGGER.error("OSError: %s", exc) + _LOGGER.error('OSError: %s', exc) return None def get_asuswrt_data(self): @@ -232,7 +233,7 @@ class AsusWrtDeviceScanner(object): match = _WL_REGEX.search(lease.decode('utf-8')) if not match: - _LOGGER.warning("Could not parse wl row: %s", lease) + _LOGGER.warning('Could not parse wl row: %s', lease) continue host = '' @@ -242,7 +243,7 @@ class AsusWrtDeviceScanner(object): if match.group('mac').lower() in arp.decode('utf-8'): arp_match = _ARP_REGEX.search(arp.decode('utf-8')) if not arp_match: - _LOGGER.warning("Could not parse arp row: %s", arp) + _LOGGER.warning('Could not parse arp row: %s', arp) continue devices[arp_match.group('ip')] = { @@ -256,7 +257,7 @@ class AsusWrtDeviceScanner(object): match = _LEASES_REGEX.search(lease.decode('utf-8')) if not match: - _LOGGER.warning("Could not parse lease row: %s", lease) + _LOGGER.warning('Could not parse lease row: %s', lease) continue # For leases where the client doesn't set a hostname, ensure it @@ -275,7 +276,7 @@ class AsusWrtDeviceScanner(object): for neighbor in result.neighbors: match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) if not match: - _LOGGER.warning("Could not parse neighbor row: %s", neighbor) + _LOGGER.warning('Could not parse neighbor row: %s', neighbor) continue if match.group('ip') in devices: devices[match.group('ip')]['status'] = match.group('status') diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 3bff25edc5e..2ae3f76e5e6 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -16,6 +16,7 @@ REQUIREMENTS = ['urllib3', 'unifi==1.2.5'] _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' +CONF_SITE_ID = 'site_id' def get_scanner(hass, config): @@ -32,6 +33,7 @@ def get_scanner(hass, config): host = this_config.get(CONF_HOST, 'localhost') username = this_config.get(CONF_USERNAME) password = this_config.get(CONF_PASSWORD) + site_id = this_config.get(CONF_SITE_ID, 'default') try: port = int(this_config.get(CONF_PORT, 8443)) @@ -40,7 +42,7 @@ def get_scanner(hass, config): return False try: - ctrl = Controller(host, username, password, port, 'v4') + ctrl = Controller(host, username, password, port, 'v4', site_id) except urllib.error.HTTPError as ex: _LOGGER.error('Failed to connect to unifi: %s', ex) return False diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py index 23f9acef12f..ab4312ff20f 100644 --- a/homeassistant/components/envisalink.py +++ b/homeassistant/components/envisalink.py @@ -149,7 +149,7 @@ def setup(hass, base_config): EVL_CONTROLLER.stop() def start_envisalink(event): - """Startup process for the envisalink.""" + """Startup process for the Envisalink.""" EVL_CONTROLLER.start() for _ in range(10): if 'success' in _connect_status: @@ -175,7 +175,7 @@ def setup(hass, base_config): if not _result: return False - # Load sub-components for envisalink + # Load sub-components for Envisalink if _partitions: load_platform(hass, 'alarm_control_panel', 'envisalink', {'partitions': _partitions, @@ -191,7 +191,7 @@ def setup(hass, base_config): class EnvisalinkDevice(Entity): - """Representation of an envisalink devicetity.""" + """Representation of an Envisalink device.""" def __init__(self, name, info, controller): """Initialize the device.""" diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2773c90ba19..2f6ac91f9c3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,9 +1,9 @@ """Handle the frontend for Home Assistant.""" import os -from . import version, mdi_version from homeassistant.components import api from homeassistant.components.http import HomeAssistantView +from . import version, mdi_version DOMAIN = 'frontend' DEPENDENCIES = ['api'] @@ -76,11 +76,17 @@ class IndexView(HomeAssistantView): def get(self, request, entity_id=None): """Serve the index view.""" if self.hass.wsgi.development: - core_url = 'home-assistant-polymer/build/_core_compiled.js' - ui_url = 'home-assistant-polymer/src/home-assistant.html' + core_url = '/static/home-assistant-polymer/build/_core_compiled.js' + ui_url = '/static/home-assistant-polymer/src/home-assistant.html' + map_url = ('/static/home-assistant-polymer/src/layouts/' + 'partial-map.html') + dev_url = ('/static/home-assistant-polymer/src/entry-points/' + 'dev-tools.html') else: - core_url = 'core-{}.js'.format(version.CORE) - ui_url = 'frontend-{}.html'.format(version.UI) + core_url = '/static/core-{}.js'.format(version.CORE) + ui_url = '/static/frontend-{}.html'.format(version.UI) + map_url = '/static/partial-map-{}.html'.format(version.MAP) + dev_url = '/static/dev-tools-{}.html'.format(version.DEV) # auto login if no password was set if self.hass.config.api.api_password is None: @@ -88,14 +94,14 @@ class IndexView(HomeAssistantView): else: auth = 'false' - icons_url = 'mdi-{}.html'.format(mdi_version.VERSION) + icons_url = '/static/mdi-{}.html'.format(mdi_version.VERSION) template = self.templates.get_template('index.html') # pylint is wrong # pylint: disable=no-member resp = template.render( - core_url=core_url, ui_url=ui_url, auth=auth, - icons_url=icons_url, icons=mdi_version.VERSION) + core_url=core_url, ui_url=ui_url, map_url=map_url, auth=auth, + dev_url=dev_url, icons_url=icons_url, icons=mdi_version.VERSION) return self.Response(resp, mimetype='text/html') diff --git a/homeassistant/components/frontend/mdi_version.py b/homeassistant/components/frontend/mdi_version.py index 9bc0c85f94d..baf3042931d 100644 --- a/homeassistant/components/frontend/mdi_version.py +++ b/homeassistant/components/frontend/mdi_version.py @@ -1,2 +1,2 @@ """DO NOT MODIFY. Auto-generated by update_mdi script.""" -VERSION = "9ee3d4466a65bef35c2c8974e91b37c0" +VERSION = "758957b7ea989d6beca60e218ea7f7dd" diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 2c034058780..dddf826018a 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -64,8 +64,12 @@ document .getElementById('ha-init-skeleton') .classList.add('error'); - } - window.noAuth = {{ auth }} + }; + window.noAuth = {{ auth }}; + window.deferredLoading = { + map: '{{ map_url }}', + dev: '{{ dev_url }}', + }; @@ -76,9 +80,9 @@ {# #} - - - + + + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/dev-tools.html.gz b/homeassistant/components/frontend/www_static/dev-tools.html.gz new file mode 100644 index 00000000000..5912606cc33 Binary files /dev/null and b/homeassistant/components/frontend/www_static/dev-tools.html.gz differ diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 08d2cdea3a4..95ae493cabb 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,16 +1,10 @@ - \ No newline at end of file + */.pika-single{z-index:9999;display:block;position:relative;color:#333;background:#fff;border:1px solid #ccc;border-bottom-color:#bbb;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.pika-single:after,.pika-single:before{content:" ";display:table}.pika-single:after{clear:both}.pika-single.is-hidden{display:none}.pika-single.is-bound{position:absolute;box-shadow:0 5px 15px -5px rgba(0,0,0,.5)}.pika-lendar{float:left;width:240px;margin:8px}.pika-title{position:relative;text-align:center}.pika-label{display:inline-block;position:relative;z-index:9999;overflow:hidden;margin:0;padding:5px 3px;font-size:14px;line-height:20px;font-weight:700;background-color:#fff}.pika-title select{cursor:pointer;position:absolute;z-index:9998;margin:0;left:0;top:5px;filter:alpha(opacity=0);opacity:0}.pika-next,.pika-prev{display:block;cursor:pointer;position:relative;outline:0;border:0;padding:0;width:20px;height:30px;text-indent:20px;white-space:nowrap;overflow:hidden;background-color:transparent;background-position:center center;background-repeat:no-repeat;background-size:75% 75%;opacity:.5}.pika-next:hover,.pika-prev:hover{opacity:1}.is-rtl .pika-next,.pika-prev{float:left;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg==)}.is-rtl .pika-prev,.pika-next{float:right;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII=)}.pika-next.is-disabled,.pika-prev.is-disabled{cursor:default;opacity:.2}.pika-select{display:inline-block}.pika-table{width:100%;border-collapse:collapse;border-spacing:0;border:0}.pika-table td,.pika-table th{width:14.285714285714286%;padding:0}.pika-table th{color:#999;font-size:12px;line-height:25px;font-weight:700;text-align:center}.pika-button{cursor:pointer;display:block;box-sizing:border-box;-moz-box-sizing:border-box;outline:0;border:0;margin:0;width:100%;padding:5px;color:#666;font-size:12px;line-height:15px;text-align:right;background:#f5f5f5}.pika-week{font-size:11px;color:#999}.is-today .pika-button{color:#3af;font-weight:700}.is-selected .pika-button{color:#fff;font-weight:700;background:#3af;box-shadow:inset 0 1px 3px #178fe5;border-radius:3px}.is-inrange .pika-button{background:#D5E9F7}.is-startrange .pika-button{color:#fff;background:#6CB31D;box-shadow:none;border-radius:3px}.is-endrange .pika-button{color:#fff;background:#3af;box-shadow:none;border-radius:3px}.is-disabled .pika-button{pointer-events:none;cursor:default;color:#999;opacity:.3}.pika-button:hover{color:#fff;background:#ff8000;box-shadow:none;border-radius:3px}.pika-table abbr{border-bottom:none;cursor:help}} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index fa2fdc437c5..aee6bcde2e4 100644 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and b/homeassistant/components/frontend/www_static/frontend.html.gz differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 1e1a3a1c845..5e7f2fdbe84 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 1e1a3a1c845713508d21d7c1cb87a7ecee6222aa +Subproject commit 5e7f2fdbe849c43ba1c7dd647e5f948894c3118e diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html index 2214389b944..d73b5fb02e6 100644 --- a/homeassistant/components/frontend/www_static/mdi.html +++ b/homeassistant/components/frontend/www_static/mdi.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/mdi.html.gz b/homeassistant/components/frontend/www_static/mdi.html.gz index 963dd1fa60e..e4c0aee023a 100644 Binary files a/homeassistant/components/frontend/www_static/mdi.html.gz and b/homeassistant/components/frontend/www_static/mdi.html.gz differ diff --git a/homeassistant/components/frontend/www_static/partial-map.html b/homeassistant/components/frontend/www_static/partial-map.html new file mode 100644 index 00000000000..0d3aaf12076 --- /dev/null +++ b/homeassistant/components/frontend/www_static/partial-map.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/partial-map.html.gz b/homeassistant/components/frontend/www_static/partial-map.html.gz new file mode 100644 index 00000000000..edcd2f6c62e Binary files /dev/null and b/homeassistant/components/frontend/www_static/partial-map.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 2756f2fde3c..bf7a823bbd0 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -29,7 +29,7 @@ /* eslint-disable quotes, comma-spacing */ -var PrecacheConfig = [["/","70eeeca780a5f23c7632c2876dd1795a"],["/devEvent","70eeeca780a5f23c7632c2876dd1795a"],["/devInfo","70eeeca780a5f23c7632c2876dd1795a"],["/devService","70eeeca780a5f23c7632c2876dd1795a"],["/devState","70eeeca780a5f23c7632c2876dd1795a"],["/devTemplate","70eeeca780a5f23c7632c2876dd1795a"],["/history","70eeeca780a5f23c7632c2876dd1795a"],["/logbook","70eeeca780a5f23c7632c2876dd1795a"],["/map","70eeeca780a5f23c7632c2876dd1795a"],["/states","70eeeca780a5f23c7632c2876dd1795a"],["/static/core-db0bb387f4d3bcace002d62b94baa348.js","f938163a392465dc87af3a0094376621"],["/static/frontend-5b306b7e7d36799b7b67f592cbe94703.html","70eeeca780a5f23c7632c2876dd1795a"],["/static/mdi-9ee3d4466a65bef35c2c8974e91b37c0.html","9a6846935116cd29279c91e0ee0a26d0"],["static/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]]; +var PrecacheConfig = [["/","d2c67846acf9a583c29798c30503cbf1"],["/devEvent","c4cdd84093404ee3fe0896070ebde97f"],["/devInfo","c4cdd84093404ee3fe0896070ebde97f"],["/devService","c4cdd84093404ee3fe0896070ebde97f"],["/devState","c4cdd84093404ee3fe0896070ebde97f"],["/devTemplate","c4cdd84093404ee3fe0896070ebde97f"],["/history","d2c67846acf9a583c29798c30503cbf1"],["/logbook","d2c67846acf9a583c29798c30503cbf1"],["/map","df0c87260b6dd990477cda43a2440b1c"],["/states","d2c67846acf9a583c29798c30503cbf1"],["/static/core-7d80cc0e4dea6bc20fa2889be0b3cd15.js","1f35577e9f32a86a03944e5e8d15eab2"],["/static/dev-tools-b7079ac3121b95b9856e5603a6d8a263.html","4ba7c57b48c9d28a1e0d9d7624b83700"],["/static/frontend-805f8dda70419b26daabc8e8f625127f.html","d8eeb403baf5893de8404beec0135d96"],["/static/mdi-758957b7ea989d6beca60e218ea7f7dd.html","4c32b01a3a5b194630963ff7ec4df36f"],["/static/partial-map-c922306de24140afd14f857f927bf8f0.html","853772ea26ac2f4db0f123e20c1ca160"],["static/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]]; /* eslint-enable quotes, comma-spacing */ var CacheNamePrefix = 'sw-precache-v1--' + (self.registration ? self.registration.scope : '') + '-'; diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 97a7a860214..52a3ab5e420 100644 Binary files a/homeassistant/components/frontend/www_static/service_worker.js.gz and b/homeassistant/components/frontend/www_static/service_worker.js.gz differ diff --git a/homeassistant/components/garage_door/rpi_gpio.py b/homeassistant/components/garage_door/rpi_gpio.py index 6a50ffb408d..536e3177dc4 100644 --- a/homeassistant/components/garage_door/rpi_gpio.py +++ b/homeassistant/components/garage_door/rpi_gpio.py @@ -57,7 +57,7 @@ class RPiGPIOGarageDoor(GarageDoorDevice): self._relay_pin = relay_pin self._state_pin = state_pin rpi_gpio.setup_output(self._relay_pin) - rpi_gpio.setup_input(self._state_pin, 'DOWN') + rpi_gpio.setup_input(self._state_pin, 'UP') rpi_gpio.write_output(self._relay_pin, True) @property diff --git a/homeassistant/components/garage_door/wink.py b/homeassistant/components/garage_door/wink.py index a99f35ecf5b..964711d679d 100644 --- a/homeassistant/components/garage_door/wink.py +++ b/homeassistant/components/garage_door/wink.py @@ -10,7 +10,7 @@ from homeassistant.components.garage_door import GarageDoorDevice from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index c7fdda5fe34..dbd143888f2 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -9,8 +9,8 @@ from collections import defaultdict from datetime import timedelta from itertools import groupby -from homeassistant.components import recorder, script import homeassistant.util.dt as dt_util +from homeassistant.components import recorder, script from homeassistant.components.http import HomeAssistantView DOMAIN = 'history' @@ -27,13 +27,12 @@ def last_5_states(entity_id): """Return the last 5 states for entity_id.""" entity_id = entity_id.lower() - query = """ - SELECT * FROM states WHERE entity_id=? AND - last_changed=last_updated - ORDER BY state_id DESC LIMIT 0, 5 - """ - - return recorder.query_states(query, (entity_id, )) + states = recorder.get_model('States') + return recorder.execute( + recorder.query('States').filter( + (states.entity_id == entity_id) & + (states.last_changed == states.last_updated) + ).order_by(states.state_id.desc()).limit(5)) def get_significant_states(start_time, end_time=None, entity_id=None): @@ -44,48 +43,42 @@ def get_significant_states(start_time, end_time=None, entity_id=None): as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ - where = """ - (domain IN ({}) OR last_changed=last_updated) - AND domain NOT IN ({}) AND last_updated > ? - """.format(",".join("'%s'" % x for x in SIGNIFICANT_DOMAINS), - ",".join("'%s'" % x for x in IGNORE_DOMAINS)) - - data = [start_time] + states = recorder.get_model('States') + query = recorder.query('States').filter( + (states.domain.in_(SIGNIFICANT_DOMAINS) | + (states.last_changed == states.last_updated)) & + ((~states.domain.in_(IGNORE_DOMAINS)) & + (states.last_updated > start_time))) if end_time is not None: - where += "AND last_updated < ? " - data.append(end_time) + query = query.filter(states.last_updated < end_time) if entity_id is not None: - where += "AND entity_id = ? " - data.append(entity_id.lower()) + query = query.filter_by(entity_id=entity_id.lower()) - query = ("SELECT * FROM states WHERE {} " - "ORDER BY entity_id, last_updated ASC").format(where) - - states = (state for state in recorder.query_states(query, data) - if _is_significant(state)) + states = ( + state for state in recorder.execute( + query.order_by(states.entity_id, states.last_updated)) + if _is_significant(state)) return states_to_json(states, start_time, entity_id) def state_changes_during_period(start_time, end_time=None, entity_id=None): """Return states changes during UTC period start_time - end_time.""" - where = "last_changed=last_updated AND last_changed > ? " - data = [start_time] + states = recorder.get_model('States') + query = recorder.query('States').filter( + (states.last_changed == states.last_updated) & + (states.last_changed > start_time)) if end_time is not None: - where += "AND last_changed < ? " - data.append(end_time) + query = query.filter(states.last_updated < end_time) if entity_id is not None: - where += "AND entity_id = ? " - data.append(entity_id.lower()) + query = query.filter_by(entity_id=entity_id.lower()) - query = ("SELECT * FROM states WHERE {} " - "ORDER BY entity_id, last_changed ASC").format(where) - - states = recorder.query_states(query, data) + states = recorder.execute( + query.order_by(states.entity_id, states.last_updated)) return states_to_json(states, start_time, entity_id) @@ -99,24 +92,27 @@ def get_states(utc_point_in_time, entity_ids=None, run=None): if run is None: return [] - where = run.where_after_start_run + "AND created < ? " - where_data = [utc_point_in_time] + from sqlalchemy import and_, func + + states = recorder.get_model('States') + most_recent_state_ids = recorder.query( + func.max(states.state_id).label('max_state_id') + ).filter( + (states.created >= run.start) & + (states.created < utc_point_in_time) + ) if entity_ids is not None: - where += "AND entity_id IN ({}) ".format( - ",".join(['?'] * len(entity_ids))) - where_data.extend(entity_ids) + most_recent_state_ids = most_recent_state_ids.filter( + states.entity_id.in_(entity_ids)) - query = """ - SELECT * FROM states - INNER JOIN ( - SELECT max(state_id) AS max_state_id - FROM states WHERE {} - GROUP BY entity_id) - WHERE state_id = max_state_id - """.format(where) + most_recent_state_ids = most_recent_state_ids.group_by( + states.entity_id).subquery() - return recorder.query_states(query, where_data) + query = recorder.query('States').join(most_recent_state_ids, and_( + states.state_id == most_recent_state_ids.c.max_state_id)) + + return recorder.execute(query) def states_to_json(states, start_time, entity_id): diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index f2d71bb409a..593f7696b65 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -4,71 +4,120 @@ Support for Homematic devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ +import os import time import logging from functools import partial -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN -from homeassistant.helpers import discovery +import voluptuous as vol + +from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, + CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers.entity import Entity +from homeassistant.helpers import discovery +from homeassistant.config import load_yaml_config_file DOMAIN = 'homematic' -REQUIREMENTS = ['pyhomematic==0.1.8'] +REQUIREMENTS = ["pyhomematic==0.1.9"] HOMEMATIC = None HOMEMATIC_LINK_DELAY = 0.5 -DISCOVER_SWITCHES = "homematic.switch" -DISCOVER_LIGHTS = "homematic.light" -DISCOVER_SENSORS = "homematic.sensor" -DISCOVER_BINARY_SENSORS = "homematic.binary_sensor" -DISCOVER_ROLLERSHUTTER = "homematic.rollershutter" -DISCOVER_THERMOSTATS = "homematic.thermostat" +DISCOVER_SWITCHES = 'homematic.switch' +DISCOVER_LIGHTS = 'homematic.light' +DISCOVER_SENSORS = 'homematic.sensor' +DISCOVER_BINARY_SENSORS = 'homematic.binary_sensor' +DISCOVER_ROLLERSHUTTER = 'homematic.rollershutter' +DISCOVER_THERMOSTATS = 'homematic.thermostat' -ATTR_DISCOVER_DEVICES = "devices" -ATTR_PARAM = "param" -ATTR_CHANNEL = "channel" -ATTR_NAME = "name" -ATTR_ADDRESS = "address" +ATTR_DISCOVER_DEVICES = 'devices' +ATTR_PARAM = 'param' +ATTR_CHANNEL = 'channel' +ATTR_NAME = 'name' +ATTR_ADDRESS = 'address' -EVENT_KEYPRESS = "homematic.keypress" +EVENT_KEYPRESS = 'homematic.keypress' +EVENT_IMPULSE = 'homematic.impulse' + +SERVICE_VIRTUALKEY = 'virtualkey' HM_DEVICE_TYPES = { - DISCOVER_SWITCHES: ["Switch", "SwitchPowermeter"], - DISCOVER_LIGHTS: ["Dimmer"], - DISCOVER_SENSORS: ["SwitchPowermeter", "Motion", "MotionV2", - "RemoteMotion", "ThermostatWall", "AreaThermostat", - "RotaryHandleSensor", "WaterSensor"], - DISCOVER_THERMOSTATS: ["Thermostat", "ThermostatWall", "MAXThermostat"], - DISCOVER_BINARY_SENSORS: ["ShutterContact", "Smoke", "SmokeV2", - "Motion", "MotionV2", "RemoteMotion"], - DISCOVER_ROLLERSHUTTER: ["Blind"] + DISCOVER_SWITCHES: ['Switch', 'SwitchPowermeter'], + DISCOVER_LIGHTS: ['Dimmer'], + DISCOVER_SENSORS: ['SwitchPowermeter', 'Motion', 'MotionV2', + 'RemoteMotion', 'ThermostatWall', 'AreaThermostat', + 'RotaryHandleSensor', 'WaterSensor', 'PowermeterGas', + 'LuxSensor'], + DISCOVER_THERMOSTATS: ['Thermostat', 'ThermostatWall', 'MAXThermostat'], + DISCOVER_BINARY_SENSORS: ['ShutterContact', 'Smoke', 'SmokeV2', 'Motion', + 'MotionV2', 'RemoteMotion'], + DISCOVER_ROLLERSHUTTER: ['Blind'] } HM_IGNORE_DISCOVERY_NODE = [ - "ACTUAL_TEMPERATURE" + 'ACTUAL_TEMPERATURE' ] HM_ATTRIBUTE_SUPPORT = { - "LOWBAT": ["Battery", {0: "High", 1: "Low"}], - "ERROR": ["Sabotage", {0: "No", 1: "Yes"}], - "RSSI_DEVICE": ["RSSI", {}], - "VALVE_STATE": ["Valve", {}], - "BATTERY_STATE": ["Battery", {}], - "CONTROL_MODE": ["Mode", {0: "Auto", 1: "Manual", 2: "Away", 3: "Boost"}], - "POWER": ["Power", {}], - "CURRENT": ["Current", {}], - "VOLTAGE": ["Voltage", {}] + 'LOWBAT': ['Battery', {0: 'High', 1: 'Low'}], + 'ERROR': ['Sabotage', {0: 'No', 1: 'Yes'}], + 'RSSI_DEVICE': ['RSSI', {}], + 'VALVE_STATE': ['Valve', {}], + 'BATTERY_STATE': ['Battery', {}], + 'CONTROL_MODE': ['Mode', {0: 'Auto', 1: 'Manual', 2: 'Away', 3: 'Boost'}], + 'POWER': ['Power', {}], + 'CURRENT': ['Current', {}], + 'VOLTAGE': ['Voltage', {}] } HM_PRESS_EVENTS = [ - "PRESS_SHORT", - "PRESS_LONG", - "PRESS_CONT", - "PRESS_LONG_RELEASE" + 'PRESS_SHORT', + 'PRESS_LONG', + 'PRESS_CONT', + 'PRESS_LONG_RELEASE' +] + +HM_IMPULSE_EVENTS = [ + 'SEQUENCE_OK' ] _LOGGER = logging.getLogger(__name__) +CONF_RESOLVENAMES_OPTIONS = [ + 'metadata', + 'json', + 'xml', + False +] + +CONF_LOCAL_IP = 'local_ip' +CONF_LOCAL_PORT = 'local_port' +CONF_REMOTE_IP = 'remote_ip' +CONF_REMOTE_PORT = 'remote_port' +CONF_RESOLVENAMES = 'resolvenames' +CONF_DELAY = 'delay' + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_LOCAL_IP): vol.Coerce(str), + vol.Optional(CONF_LOCAL_PORT, default=8943): + vol.All(vol.Coerce(int), + vol.Range(min=1, max=65535)), + vol.Required(CONF_REMOTE_IP): vol.Coerce(str), + vol.Optional(CONF_REMOTE_PORT, default=2001): + vol.All(vol.Coerce(int), + vol.Range(min=1, max=65535)), + vol.Optional(CONF_RESOLVENAMES, default=False): + vol.In(CONF_RESOLVENAMES_OPTIONS), + vol.Optional(CONF_USERNAME, default="Admin"): vol.Coerce(str), + vol.Optional(CONF_PASSWORD, default=""): vol.Coerce(str), + vol.Optional(CONF_DELAY, default=0.5): vol.Coerce(float) +}) + +SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({ + vol.Required(ATTR_ADDRESS): vol.Coerce(str), + vol.Required(ATTR_CHANNEL): vol.Coerce(int), + vol.Required(ATTR_PARAM): vol.Coerce(str) +}) + # pylint: disable=unused-argument def setup(hass, config): @@ -77,14 +126,14 @@ def setup(hass, config): from pyhomematic import HMConnection - local_ip = config[DOMAIN].get("local_ip", None) - local_port = config[DOMAIN].get("local_port", 8943) - remote_ip = config[DOMAIN].get("remote_ip", None) - remote_port = config[DOMAIN].get("remote_port", 2001) - resolvenames = config[DOMAIN].get("resolvenames", False) - username = config[DOMAIN].get("username", "Admin") - password = config[DOMAIN].get("password", "") - HOMEMATIC_LINK_DELAY = config[DOMAIN].get("delay", 0.5) + local_ip = config[DOMAIN][0].get(CONF_LOCAL_IP) + local_port = config[DOMAIN][0].get(CONF_LOCAL_PORT) + remote_ip = config[DOMAIN][0].get(CONF_REMOTE_IP) + remote_port = config[DOMAIN][0].get(CONF_REMOTE_PORT) + resolvenames = config[DOMAIN][0].get(CONF_RESOLVENAMES) + username = config[DOMAIN][0].get(CONF_USERNAME) + password = config[DOMAIN][0].get(CONF_PASSWORD) + HOMEMATIC_LINK_DELAY = config[DOMAIN][0].get(CONF_DELAY) if remote_ip is None or local_ip is None: _LOGGER.error("Missing remote CCU/Homegear or local address") @@ -109,6 +158,15 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, HOMEMATIC.stop) hass.config.components.append(DOMAIN) + # regeister homematic services + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + hass.services.register(DOMAIN, SERVICE_VIRTUALKEY, + _hm_service_virtualkey, + descriptions[DOMAIN][SERVICE_VIRTUALKEY], + SCHEMA_SERVICE_VIRTUALKEY) + return True @@ -302,7 +360,7 @@ def _hm_event_handler(hass, device, caller, attribute, value): _LOGGER.debug("Event %s for %s channel %i", attribute, hmdevice.NAME, channel) - # a keypress event + # keypress event if attribute in HM_PRESS_EVENTS: hass.bus.fire(EVENT_KEYPRESS, { ATTR_NAME: hmdevice.NAME, @@ -311,9 +369,42 @@ def _hm_event_handler(hass, device, caller, attribute, value): }) return + # impulse event + if attribute in HM_IMPULSE_EVENTS: + hass.bus.fire(EVENT_KEYPRESS, { + ATTR_NAME: hmdevice.NAME, + ATTR_CHANNEL: channel + }) + return + _LOGGER.warning("Event is unknown and not forwarded to HA") +def _hm_service_virtualkey(call): + """Callback for handle virtualkey services.""" + address = call.data.get(ATTR_ADDRESS) + channel = call.data.get(ATTR_CHANNEL) + param = call.data.get(ATTR_PARAM) + + if address not in HOMEMATIC.devices: + _LOGGER.error("%s not found for service virtualkey!", address) + return + hmdevice = HOMEMATIC.devices.get(address) + + # if param exists for this device + if param not in hmdevice.ACTIONNODE: + _LOGGER.error("%s not datapoint in hm device %s", param, address) + return + + # channel exists? + if channel > hmdevice.ELEMENT: + _LOGGER.error("%i is not a channel in hm device %s", channel, address) + return + + # call key + hmdevice.actionNodeData(param, 1, channel) + + class HMDevice(Entity): """The Homematic device base object.""" @@ -465,7 +556,7 @@ class HMDevice(Entity): channel = self._channel # Prepare for subscription try: - if int(channel) > 0: + if int(channel) >= 0: channels_to_sub.update({int(channel): True}) except (ValueError, TypeError): _LOGGER("Invalid channel in metadata from %s", diff --git a/homeassistant/components/joaoapps_join.py b/homeassistant/components/joaoapps_join.py new file mode 100644 index 00000000000..284567b9061 --- /dev/null +++ b/homeassistant/components/joaoapps_join.py @@ -0,0 +1,80 @@ +""" +Component for Joaoapps Join services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/join/ +""" +import logging +import voluptuous as vol +from homeassistant.const import CONF_NAME, CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = [ + 'https://github.com/nkgilley/python-join-api/archive/' + '3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'joaoapps_join' +CONF_DEVICE_ID = 'device_id' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_API_KEY): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +# pylint: disable=too-many-locals +def setup(hass, config): + """Setup Join services.""" + from pyjoin import (get_devices, ring_device, set_wallpaper, send_sms, + send_file, send_url, send_notification) + device_id = config[DOMAIN].get(CONF_DEVICE_ID) + api_key = config[DOMAIN].get(CONF_API_KEY) + name = config[DOMAIN].get(CONF_NAME) + if api_key: + if not get_devices(api_key): + _LOGGER.error("Error connecting to Join, check API key") + return False + + def ring_service(service): + """Service to ring devices.""" + ring_device(device_id, api_key=api_key) + + def set_wallpaper_service(service): + """Service to set wallpaper on devices.""" + set_wallpaper(device_id, url=service.data.get('url'), api_key=api_key) + + def send_file_service(service): + """Service to send files to devices.""" + send_file(device_id, url=service.data.get('url'), api_key=api_key) + + def send_url_service(service): + """Service to open url on devices.""" + send_url(device_id, url=service.data.get('url'), api_key=api_key) + + def send_tasker_service(service): + """Service to open url on devices.""" + send_notification(device_id=device_id, + text=service.data.get('command'), + api_key=api_key) + + def send_sms_service(service): + """Service to send sms from devices.""" + send_sms(device_id=device_id, + sms_number=service.data.get('number'), + sms_text=service.data.get('message'), + api_key=api_key) + + name = name.lower().replace(" ", "_") + "_" if name else "" + hass.services.register(DOMAIN, name + 'ring', ring_service) + hass.services.register(DOMAIN, name + 'set_wallpaper', + set_wallpaper_service) + hass.services.register(DOMAIN, name + 'send_sms', send_sms_service) + hass.services.register(DOMAIN, name + 'send_file', send_file_service) + hass.services.register(DOMAIN, name + 'send_url', send_url_service) + hass.services.register(DOMAIN, name + 'send_tasker', send_tasker_service) + return True diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py new file mode 100644 index 00000000000..8b43c0f2da9 --- /dev/null +++ b/homeassistant/components/knx.py @@ -0,0 +1,295 @@ +""" +Support for KNX components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/knx/ +""" +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.entity import Entity + +DOMAIN = "knx" +REQUIREMENTS = ['knxip==0.3.0'] + +EVENT_KNX_FRAME_RECEIVED = "knx_frame_received" + +CONF_HOST = "host" +CONF_PORT = "port" + +DEFAULT_PORT = "3671" + +KNXTUNNEL = None + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """Setup the connection to the KNX IP interface.""" + global KNXTUNNEL + + from knxip.ip import KNXIPTunnel + from knxip.core import KNXException + + host = config[DOMAIN].get(CONF_HOST, None) + + if host is None: + _LOGGER.debug("Will try to auto-detect KNX/IP gateway") + host = "0.0.0.0" + + try: + port = int(config[DOMAIN].get(CONF_PORT, DEFAULT_PORT)) + except ValueError: + _LOGGER.exception("Can't parse KNX IP interface port") + return False + + KNXTUNNEL = KNXIPTunnel(host, port) + try: + KNXTUNNEL.connect() + except KNXException as ex: + _LOGGER.exception("Can't connect to KNX/IP interface: %s", ex) + KNXTUNNEL = None + return False + + _LOGGER.info("KNX IP tunnel to %s:%i established", host, port) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel) + return True + + +def close_tunnel(_data): + """Close the NKX tunnel connection on shutdown.""" + global KNXTUNNEL + + KNXTUNNEL.disconnect() + KNXTUNNEL = None + + +class KNXConfig(object): + """Handle the fetching of configuration from the config file.""" + + def __init__(self, config): + """Initialize the configuration.""" + from knxip.core import parse_group_address + + self.config = config + self.should_poll = config.get("poll", True) + self._address = parse_group_address(config.get("address")) + if self.config.get("state_address"): + self._state_address = parse_group_address( + self.config.get("state_address")) + else: + self._state_address = None + + @property + def name(self): + """The name given to the entity.""" + return self.config["name"] + + @property + def address(self): + """The address of the device as an integer value. + + 3 types of addresses are supported: + integer - 0-65535 + 2 level - a/b + 3 level - a/b/c + """ + return self._address + + @property + def state_address(self): + """The group address the device sends its current state to. + + Some KNX devices can send the current state to a seperate + group address. This makes send e.g. when an actuator can + be switched but also have a timer functionality. + """ + return self._state_address + + +class KNXGroupAddress(Entity): + """Representation of devices connected to a KNX group address.""" + + def __init__(self, hass, config): + """Initialize the device.""" + self._config = config + self._state = False + self._data = None + _LOGGER.debug("Initalizing KNX group address %s", self.address) + + def handle_knx_message(addr, data): + """Handle an incoming KNX frame. + + Handle an incoming frame and update our status if it contains + information relating to this device. + """ + if (addr == self.state_address) or (addr == self.address): + self._state = data + self.update_ha_state() + + KNXTUNNEL.register_listener(self.address, handle_knx_message) + if self.state_address: + KNXTUNNEL.register_listener(self.state_address, handle_knx_message) + + @property + def name(self): + """The entity's display name.""" + return self._config.name + + @property + def config(self): + """The entity's configuration.""" + return self._config + + @property + def should_poll(self): + """Return the state of the polling, if needed.""" + return self._config.should_poll + + @property + def is_on(self): + """Return True if the value is not 0 is on, else False.""" + if self.should_poll: + self.update() + return self._state != 0 + + @property + def address(self): + """Return the KNX group address.""" + return self._config.address + + @property + def state_address(self): + """Return the KNX group address.""" + return self._config.state_address + + @property + def cache(self): + """The name given to the entity.""" + return self._config.config.get("cache", True) + + def group_write(self, value): + """Write to the group address.""" + KNXTUNNEL.group_write(self.address, [value]) + + def update(self): + """Get the state from KNX bus or cache.""" + from knxip.core import KNXException + + try: + if self.state_address: + res = KNXTUNNEL.group_read(self.state_address, + use_cache=self.cache) + else: + res = KNXTUNNEL.group_read(self.address, + use_cache=self.cache) + + if res: + self._state = res[0] + self._data = res + else: + _LOGGER.debug("Unable to read from KNX address: %s (None)", + self.address) + + except KNXException: + _LOGGER.exception("Unable to read from KNX address: %s", + self.address) + return False + + +class KNXMultiAddressDevice(KNXGroupAddress): + """Representation of devices connected to a multiple KNX group address. + + This is needed for devices like dimmers or shutter actuators as they have + to be controlled by multiple group addresses. + """ + + names = {} + values = {} + + def __init__(self, hass, config, required, optional=None): + """Initialize the device. + + The namelist argument lists the required addresses. E.g. for a dimming + actuators, the namelist might look like: + onoff_address: 0/0/1 + brightness_address: 0/0/2 + """ + from knxip.core import parse_group_address, KNXException + + super().__init__(self, hass, config) + + self.config = config + + # parse required addresses + for name in required: + paramname = name + "_address" + addr = self._config.config.get(paramname) + if addr is None: + _LOGGER.exception("Required KNX group address %s missing", + paramname) + raise KNXException("Group address missing in configuration") + addr = parse_group_address(addr) + self.names[addr] = name + + # parse optional addresses + for name in optional: + paramname = name + "_address" + addr = self._config.config.get(paramname) + if addr: + try: + addr = parse_group_address(addr) + except KNXException: + _LOGGER.exception("Cannot parse group address %s", addr) + self.names[addr] = name + + def handle_frame(frame): + """Handle an incoming KNX frame. + + Handle an incoming frame and update our status if it contains + information relating to this device. + """ + addr = frame.data[0] + + if addr in self.names: + self.values[addr] = frame.data[1] + self.update_ha_state() + + hass.bus.listen(EVENT_KNX_FRAME_RECEIVED, handle_frame) + + def group_write_address(self, name, value): + """Write to the group address with the given name.""" + KNXTUNNEL.group_write(self.address, [value]) + + def has_attribute(self, name): + """Check if the attribute with the given name is defined. + + This is mostly important for optional addresses. + """ + for attributename, dummy_attribute in self.names.items(): + if attributename == name: + return True + return False + + def value(self, name): + """Return the value to a given named attribute.""" + from knxip.core import KNXException + + addr = None + for attributename, attributeaddress in self.names.items(): + if attributename == name: + addr = attributeaddress + + if addr is None: + _LOGGER.exception("Attribute %s undefined", name) + return False + + try: + res = KNXTUNNEL.group_read(addr, use_cache=self.cache) + except KNXException: + _LOGGER.exception("Unable to read from KNX address: %s", + addr) + return False + + return res diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 2b0af395d02..2a87d2e88bb 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -67,12 +67,13 @@ PROP_TO_ATTR = { # Service call validation schemas VALID_TRANSITION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)) +VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)) LIGHT_TURN_ON_SCHEMA = vol.Schema({ ATTR_ENTITY_ID: cv.entity_ids, ATTR_PROFILE: str, ATTR_TRANSITION: VALID_TRANSITION, - ATTR_BRIGHTNESS: cv.byte, + ATTR_BRIGHTNESS: VALID_BRIGHTNESS, ATTR_COLOR_NAME: str, ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple)), diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index ddca6e0a9b5..96095c49a39 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -18,7 +18,7 @@ LIGHT_TEMPS = [240, 380] def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup demo light platform.""" + """Setup the demo light platform.""" add_devices_callback([ DemoLight("Bed Light", False), DemoLight("Ceiling Lights", True, LIGHT_COLORS[0], LIGHT_TEMPS[1]), @@ -27,7 +27,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class DemoLight(Light): - """Provide a demo light.""" + """Represenation of a demo light.""" # pylint: disable=too-many-arguments def __init__(self, name, state, rgb=None, ct=None, brightness=180): diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 614df66b133..1901af8ee4a 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -129,7 +129,7 @@ def setup_bridge(host, hass, add_devices_callback, filename, new_lights = [] api_name = api.get('config').get('name') - if api_name == 'RaspBee-GW': + if api_name in ('RaspBee-GW', 'deCONZ-GW'): bridge_type = 'deconz' else: bridge_type = 'hue' diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 00e059fdc3e..4fb009d23f0 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -14,7 +14,7 @@ from homeassistant.util import color as color_util from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices_callback, discovery_info=None): diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 7c9cb72db26..2aed8d54a8c 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -14,16 +14,27 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, \ from homeassistant.components import zwave from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import HASS_COLOR_MAX, HASS_COLOR_MIN, \ - color_temperature_mired_to_kelvin, color_temperature_to_rgb + color_temperature_mired_to_kelvin, color_temperature_to_rgb, \ + color_rgb_to_rgbw, color_rgbw_to_rgb _LOGGER = logging.getLogger(__name__) +AEOTEC = 0x86 +AEOTEC_ZW098_LED_BULB = 0x62 +AEOTEC_ZW098_LED_BULB_LIGHT = (AEOTEC, AEOTEC_ZW098_LED_BULB) + COLOR_CHANNEL_WARM_WHITE = 0x01 COLOR_CHANNEL_COLD_WHITE = 0x02 COLOR_CHANNEL_RED = 0x04 COLOR_CHANNEL_GREEN = 0x08 COLOR_CHANNEL_BLUE = 0x10 +WORKAROUND_ZW098 = 'zw098' + +DEVICE_MAPPINGS = { + AEOTEC_ZW098_LED_BULB_LIGHT: WORKAROUND_ZW098 +} + # Generate midpoint color temperatures for bulbs that have limited # support for white light colors TEMP_MID_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 2 + HASS_COLOR_MIN @@ -161,6 +172,7 @@ class ZwaveColorLight(ZwaveDimmer): self._color_channels = None self._rgb = None self._ct = None + self._zw098 = None # Here we attempt to find a zwave color value with the same instance # id as the dimmer value. Currently zwave nodes that change colors @@ -182,6 +194,17 @@ class ZwaveColorLight(ZwaveDimmer): if self._value_color_channels is None: raise ValueError("Color Channels not found.") + # Make sure that we have values for the key before converting to int + if (value.node.manufacturer_id.strip() and + value.node.product_id.strip()): + specific_sensor_key = (int(value.node.manufacturer_id, 16), + int(value.node.product_id, 16)) + + if specific_sensor_key in DEVICE_MAPPINGS: + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZW098: + _LOGGER.debug("AEOTEC ZW098 workaround enabled") + self._zw098 = 1 + super().__init__(value) def update_properties(self): @@ -218,11 +241,10 @@ class ZwaveColorLight(ZwaveDimmer): else: cold_white = 0 - # Color temperature. With two white channels, only two color - # temperatures are supported for the bulb. The channel values + # Color temperature. With the AEOTEC ZW098 bulb, only two color + # temperatures are supported. The warm and cold channel values # indicate brightness for warm/cold color temperature. - if (self._color_channels & COLOR_CHANNEL_WARM_WHITE and - self._color_channels & COLOR_CHANNEL_COLD_WHITE): + if self._zw098: if warm_white > 0: self._ct = TEMP_WARM_HASS self._rgb = ct_to_rgb(self._ct) @@ -233,17 +255,11 @@ class ZwaveColorLight(ZwaveDimmer): # RGB color is being used. Just report midpoint. self._ct = TEMP_MID_HASS - # If only warm white is reported 0-255 is color temperature. elif self._color_channels & COLOR_CHANNEL_WARM_WHITE: - self._ct = HASS_COLOR_MIN + (HASS_COLOR_MAX - HASS_COLOR_MIN) * ( - warm_white / 255) - self._rgb = ct_to_rgb(self._ct) + self._rgb = list(color_rgbw_to_rgb(*self._rgb, w=warm_white)) - # If only cold white is reported 0-255 is negative color temperature. elif self._color_channels & COLOR_CHANNEL_COLD_WHITE: - self._ct = HASS_COLOR_MIN + (HASS_COLOR_MAX - HASS_COLOR_MIN) * ( - (255 - cold_white) / 255) - self._rgb = ct_to_rgb(self._ct) + self._rgb = list(color_rgbw_to_rgb(*self._rgb, w=cold_white)) # If no rgb channels supported, report None. if not (self._color_channels & COLOR_CHANNEL_RED or @@ -266,10 +282,10 @@ class ZwaveColorLight(ZwaveDimmer): rgbw = None if ATTR_COLOR_TEMP in kwargs: - # With two white channels, only two color temperatures are - # supported for the bulb. - if (self._color_channels & COLOR_CHANNEL_WARM_WHITE and - self._color_channels & COLOR_CHANNEL_COLD_WHITE): + # Color temperature. With the AEOTEC ZW098 bulb, only two color + # temperatures are supported. The warm and cold channel values + # indicate brightness for warm/cold color temperature. + if self._zw098: if kwargs[ATTR_COLOR_TEMP] > TEMP_MID_HASS: self._ct = TEMP_WARM_HASS rgbw = b'#000000FF00' @@ -277,29 +293,20 @@ class ZwaveColorLight(ZwaveDimmer): self._ct = TEMP_COLD_HASS rgbw = b'#00000000FF' - # If only warm white is reported 0-255 is color temperature - elif self._color_channels & COLOR_CHANNEL_WARM_WHITE: - rgbw = b'#000000' - temp = ( - (kwargs[ATTR_COLOR_TEMP] - HASS_COLOR_MIN) / - (HASS_COLOR_MAX - HASS_COLOR_MIN) * 255) - rgbw += format(int(temp)).encode('utf-8') - - # If only cold white is reported 0-255 is negative color temp - elif self._color_channels & COLOR_CHANNEL_COLD_WHITE: - rgbw = b'#000000' - temp = ( - 255 - (kwargs[ATTR_COLOR_TEMP] - HASS_COLOR_MIN) / - (HASS_COLOR_MAX - HASS_COLOR_MIN) * 255) - rgbw += format(int(temp)).encode('utf-8') - elif ATTR_RGB_COLOR in kwargs: self._rgb = kwargs[ATTR_RGB_COLOR] - - rgbw = b'#' - for colorval in self._rgb: - rgbw += format(colorval, '02x').encode('utf-8') - rgbw += b'0000' + if (not self._zw098 and ( + self._color_channels & COLOR_CHANNEL_WARM_WHITE or + self._color_channels & COLOR_CHANNEL_COLD_WHITE)): + rgbw = b'#' + for colorval in color_rgb_to_rgbw(*self._rgb): + rgbw += format(colorval, '02x').encode('utf-8') + rgbw += b'00' + else: + rgbw = b'#' + for colorval in self._rgb: + rgbw += format(colorval, '02x').encode('utf-8') + rgbw += b'0000' if rgbw is None: _LOGGER.warning("rgbw string was not generated for turn_on") diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index 08cfbc6202f..c9f18c5533e 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -39,7 +39,7 @@ class VerisureDoorlock(LockDevice): @property def name(self): """Return the name of the lock.""" - return 'Lock {}'.format(self._id) + return '{}'.format(hub.lock_status[self._id].location) @property def state(self): diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index c3232e67e03..7b3b5ef0fec 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockDevice from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 82747e73093..c6cec168aed 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -11,27 +11,23 @@ from itertools import groupby import voluptuous as vol +import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util from homeassistant.components import recorder, sun -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, - STATE_NOT_HOME, STATE_OFF, STATE_ON) +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import (EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, + STATE_NOT_HOME, STATE_OFF, STATE_ON) from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.core import State -from homeassistant.helpers.entity import split_entity_id from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv -from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.entity import split_entity_id 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 < ? -""" - _LOGGER = logging.getLogger(__name__) EVENT_LOGBOOK_ENTRY = 'logbook_entry' @@ -98,11 +94,14 @@ class LogbookView(HomeAssistantView): else: start_day = dt_util.start_of_local_day() + start_day = dt_util.as_utc(start_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))) + events = recorder.get_model('Events') + query = recorder.query('Events').filter( + (events.time_fired > start_day) & + (events.time_fired < end_day)) + events = recorder.execute(query) return self.json(humanify(events)) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 25c55f46b7b..57167317553 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -31,6 +31,7 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' +SERVICE_CLEAR_PLAYLIST = 'clear_playlist' ATTR_MEDIA_VOLUME_LEVEL = 'volume_level' ATTR_MEDIA_VOLUME_MUTED = 'is_volume_muted' @@ -75,6 +76,7 @@ SUPPORT_PLAY_MEDIA = 512 SUPPORT_VOLUME_STEP = 1024 SUPPORT_SELECT_SOURCE = 2048 SUPPORT_STOP = 4096 +SUPPORT_CLEAR_PLAYLIST = 8192 # simple services that only take entity_id(s) as optional argument SERVICE_TO_METHOD = { @@ -89,7 +91,8 @@ SERVICE_TO_METHOD = { SERVICE_MEDIA_STOP: 'media_stop', SERVICE_MEDIA_NEXT_TRACK: 'media_next_track', SERVICE_MEDIA_PREVIOUS_TRACK: 'media_previous_track', - SERVICE_SELECT_SOURCE: 'select_source' + SERVICE_SELECT_SOURCE: 'select_source', + SERVICE_CLEAR_PLAYLIST: 'clear_playlist' } ATTR_TO_PROPERTY = [ @@ -272,6 +275,12 @@ def select_source(hass, source, entity_id=None): hass.services.call(DOMAIN, SERVICE_SELECT_SOURCE, data) +def clear_playlist(hass, entity_id=None): + """Send the media player the command for clear playlist.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_CLEAR_PLAYLIST, data) + + def setup(hass, config): """Track states and offer events for media_players.""" component = EntityComponent( @@ -542,6 +551,10 @@ class MediaPlayerDevice(Entity): """Select input source.""" raise NotImplementedError() + def clear_playlist(self): + """Clear players playlist.""" + raise NotImplementedError() + # No need to overwrite these. @property def support_pause(self): @@ -588,6 +601,11 @@ class MediaPlayerDevice(Entity): """Boolean if select source command supported.""" return bool(self.supported_media_commands & SUPPORT_SELECT_SOURCE) + @property + def support_clear_playlist(self): + """Boolean if clear playlist command supported.""" + return bool(self.supported_media_commands & SUPPORT_CLEAR_PLAYLIST) + def toggle(self): """Toggle the power on the media player.""" if self.state in [STATE_OFF, STATE_IDLE]: diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index ef5f7516827..14b0b4c2327 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -17,8 +17,8 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) REQUIREMENTS = [ - 'https://github.com/aparraga/braviarc/archive/0.3.2.zip' - '#braviarc==0.3.2'] + 'https://github.com/aparraga/braviarc/archive/0.3.3.zip' + '#braviarc==0.3.3'] BRAVIA_CONFIG_FILE = 'bravia.conf' CLIENTID_PREFIX = 'HomeAssistant' @@ -220,20 +220,24 @@ class BraviaTVDevice(MediaPlayerDevice): self._refresh_volume() self._refresh_channels() - playing_info = self._braviarc.get_playing_info() - if playing_info is None or len(playing_info) == 0: - self._state = STATE_OFF - else: + power_status = self._braviarc.get_power_status() + if power_status == 'active': self._state = STATE_ON - self._program_name = playing_info.get('programTitle') - self._channel_name = playing_info.get('title') - self._program_media_type = playing_info.get( - 'programMediaType') - self._channel_number = playing_info.get('dispNum') - self._source = playing_info.get('source') - self._content_uri = playing_info.get('uri') - self._duration = playing_info.get('durationSec') - self._start_date_time = playing_info.get('startDateTime') + playing_info = self._braviarc.get_playing_info() + if playing_info is None or len(playing_info) == 0: + self._channel_name = 'App' + else: + self._program_name = playing_info.get('programTitle') + self._channel_name = playing_info.get('title') + self._program_media_type = playing_info.get( + 'programMediaType') + self._channel_number = playing_info.get('dispNum') + self._source = playing_info.get('source') + self._content_uri = playing_info.get('uri') + self._duration = playing_info.get('durationSec') + self._start_date_time = playing_info.get('startDateTime') + else: + self._state = STATE_OFF except Exception as exception_instance: # pylint: disable=broad-except _LOGGER.error(exception_instance) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index ddc5b368d78..d59e6ef77d8 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -8,7 +8,7 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, MediaPlayerDevice) + SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, MediaPlayerDevice) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING @@ -32,7 +32,7 @@ YOUTUBE_PLAYER_SUPPORT = \ MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST NETFLIX_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE @@ -214,12 +214,12 @@ class DemoMusicPlayer(AbstractDemoPlayer): @property def media_title(self): """Return the title of current playing media.""" - return self.tracks[self._cur_track][1] + return self.tracks[self._cur_track][1] if len(self.tracks) > 0 else "" @property def media_artist(self): """Return the artist of current playing media (Music track only).""" - return self.tracks[self._cur_track][0] + return self.tracks[self._cur_track][0] if len(self.tracks) > 0 else "" @property def media_album_name(self): @@ -257,6 +257,13 @@ class DemoMusicPlayer(AbstractDemoPlayer): self._cur_track += 1 self.update_ha_state() + def clear_playlist(self): + """Clear players playlist.""" + self.tracks = [] + self._cur_track = 0 + self._player_state = STATE_OFF + self.update_ha_state() + class DemoTVShowPlayer(AbstractDemoPlayer): """A Demo media player that only supports YouTube.""" diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 19a26862199..3db8e5ace29 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -122,7 +122,11 @@ def setup_plexserver(host, token, hass, add_devices_callback): try: devices = plexserver.clients() except plexapi.exceptions.BadRequest: - _LOGGER.exception("Error listing plex devices") + _LOGGER.exception('Error listing plex devices') + return + except OSError: + _LOGGER.error( + 'Could not connect to plex server at http://%s', host) return new_plex_clients = [] @@ -148,7 +152,7 @@ def setup_plexserver(host, token, hass, add_devices_callback): try: sessions = plexserver.sessions() except plexapi.exceptions.BadRequest: - _LOGGER.exception("Error listing plex sessions") + _LOGGER.exception('Error listing plex sessions') return plex_sessions.clear() @@ -166,7 +170,7 @@ def request_configuration(host, hass, add_devices_callback): # We got an error if this method is called while we are configuring if host in _CONFIGURING: configurator.notify_errors( - _CONFIGURING[host], "Failed to register, please try again.") + _CONFIGURING[host], 'Failed to register, please try again.') return @@ -175,10 +179,10 @@ def request_configuration(host, hass, add_devices_callback): setup_plexserver(host, data.get('token'), hass, add_devices_callback) _CONFIGURING[host] = configurator.request_config( - hass, "Plex Media Server", plex_configuration_callback, + hass, 'Plex Media Server', plex_configuration_callback, description=('Enter the X-Plex-Token'), - description_image="/static/images/config_plex_mediaserver.png", - submit_caption="Confirm", + description_image='/static/images/config_plex_mediaserver.png', + submit_caption='Confirm', fields=[{'id': 'token', 'name': 'X-Plex-Token', 'type': ''}] ) @@ -201,7 +205,7 @@ class PlexClient(MediaPlayerDevice): @property def unique_id(self): """Return the id of this plex client.""" - return "{}.{}".format( + return '{}.{}'.format( self.__class__, self.device.machineIdentifier or self.device.name) @property diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 6ff1ae1510f..8644a2c9fda 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -15,8 +15,8 @@ from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME) REQUIREMENTS = [ - 'https://github.com/bah2830/python-roku/archive/3.1.1.zip' - '#python-roku==3.1.1'] + 'https://github.com/bah2830/python-roku/archive/3.1.2.zip' + '#roku==3.1.2'] KNOWN_HOSTS = [] DEFAULT_PORT = 8060 @@ -45,8 +45,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rokus = [] for host in hosts: - rokus.append(RokuDevice(host)) - KNOWN_HOSTS.append(host) + new_roku = RokuDevice(host) + + if new_roku.name is None: + _LOGGER.error("Unable to initialize roku at %s", host) + else: + rokus.append(RokuDevice(host)) + KNOWN_HOSTS.append(host) add_devices(rokus) @@ -61,6 +66,11 @@ class RokuDevice(MediaPlayerDevice): from roku import Roku self.roku = Roku(host) + self.roku_name = None + self.ip_address = host + self.channels = [] + self.current_app = None + self.update() def update(self): @@ -78,7 +88,7 @@ class RokuDevice(MediaPlayerDevice): self.current_app = None except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout): - self.current_app = None + _LOGGER.error("Unable to connect to roku at %s", self.ip_address) def get_source_list(self): """Get the list of applications to be used as sources.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 9ab831bdbb4..421010fc1a9 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -146,6 +146,14 @@ select_source: description: Name of the source to switch to. Platform dependent. example: 'video1' +clear_playlist: + description: Send the media player the command to clear players playlist. + + fields: + entity_id: + description: Name(s) of entites to change source on + example: 'media_player.living_room_chromecast' + sonos_group_players: description: Send Sonos media player the command for grouping all players into one (party mode). diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 7d0cd12175a..b4ad1c8d388 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -12,7 +12,8 @@ from os import path from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_CLEAR_PLAYLIST, + SUPPORT_SELECT_SOURCE, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_OFF) from homeassistant.config import load_yaml_config_file @@ -31,13 +32,17 @@ _REQUESTS_LOGGER.setLevel(logging.ERROR) SUPPORT_SONOS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA |\ - SUPPORT_SEEK + SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST | SUPPORT_SELECT_SOURCE SERVICE_GROUP_PLAYERS = 'sonos_group_players' SERVICE_UNJOIN = 'sonos_unjoin' SERVICE_SNAPSHOT = 'sonos_snapshot' SERVICE_RESTORE = 'sonos_restore' +SUPPORT_SOURCE_LINEIN = 'Line-in' +SUPPORT_SOURCE_TV = 'TV' +SUPPORT_SOURCE_RADIO = 'Radio' + # pylint: disable=unused-argument, too-many-locals def setup_platform(hass, config, add_devices, discovery_info=None): @@ -162,12 +167,12 @@ class SonosDevice(MediaPlayerDevice): # pylint: disable=too-many-arguments def __init__(self, hass, player): """Initialize the Sonos device.""" + from soco.snapshot import Snapshot + self.hass = hass self.volume_increment = 5 - super(SonosDevice, self).__init__() self._player = player self.update() - from soco.snapshot import Snapshot self.soco_snapshot = Snapshot(self._player) @property @@ -261,6 +266,10 @@ class SonosDevice(MediaPlayerDevice): @property def media_title(self): """Title of current playing media.""" + if self._player.is_playing_line_in: + return SUPPORT_SOURCE_LINEIN + if self._player.is_playing_tv: + return SUPPORT_SOURCE_TV if 'artist' in self._trackinfo and 'title' in self._trackinfo: return '{artist} - {title}'.format( artist=self._trackinfo['artist'], @@ -290,6 +299,36 @@ class SonosDevice(MediaPlayerDevice): """Mute (true) or unmute (false) media player.""" self._player.mute = mute + def select_source(self, source): + """Select input source.""" + if source == SUPPORT_SOURCE_LINEIN: + self._player.switch_to_line_in() + elif source == SUPPORT_SOURCE_TV: + self._player.switch_to_tv() + + @property + def source_list(self): + """List of available input sources.""" + source = [] + + # generate list of supported device + source.append(SUPPORT_SOURCE_LINEIN) + source.append(SUPPORT_SOURCE_TV) + source.append(SUPPORT_SOURCE_RADIO) + + return source + + @property + def source(self): + """Name of the current input source.""" + if self._player.is_playing_line_in: + return SUPPORT_SOURCE_LINEIN + if self._player.is_playing_tv: + return SUPPORT_SOURCE_TV + if self._player.is_playing_radio: + return SUPPORT_SOURCE_RADIO + return None + @only_if_coordinator def turn_off(self): """Turn off media player.""" @@ -320,6 +359,11 @@ class SonosDevice(MediaPlayerDevice): """Send seek command.""" self._player.seek(str(datetime.timedelta(seconds=int(position)))) + @only_if_coordinator + def clear_playlist(self): + """Clear players playlist.""" + self._player.clear_queue() + @only_if_coordinator def turn_on(self): """Turn the media player on.""" diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 8bfdeebf85d..e5b9f0321b1 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -17,8 +17,9 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SUPPORTED_MEDIA_COMMANDS, DOMAIN, SERVICE_PLAY_MEDIA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, ATTR_INPUT_SOURCE, - SERVICE_SELECT_SOURCE, MediaPlayerDevice) + SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, + ATTR_INPUT_SOURCE, SERVICE_SELECT_SOURCE, SERVICE_CLEAR_PLAYLIST, + MediaPlayerDevice) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, CONF_NAME, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, @@ -346,9 +347,12 @@ class UniversalMediaPlayer(MediaPlayerDevice): ATTR_MEDIA_VOLUME_MUTED in self._attrs: flags |= SUPPORT_VOLUME_MUTE - if SUPPORT_SELECT_SOURCE in self._cmds: + if SERVICE_SELECT_SOURCE in self._cmds: flags |= SUPPORT_SELECT_SOURCE + if SERVICE_CLEAR_PLAYLIST in self._cmds: + flags |= SUPPORT_CLEAR_PLAYLIST + return flags @property @@ -424,6 +428,10 @@ class UniversalMediaPlayer(MediaPlayerDevice): data = {ATTR_INPUT_SOURCE: source} self._call_service(SERVICE_SELECT_SOURCE, data) + def clear_playlist(self): + """Clear players playlist.""" + self._call_service(SERVICE_CLEAR_PLAYLIST) + def update(self): """Update state in HA.""" for child_name in self._children: diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 4dacfcb350e..5b1c103a1bf 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -38,7 +38,7 @@ NOTIFY_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_MESSAGE): cv.template, vol.Optional(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, vol.Optional(ATTR_TARGET): cv.string, - vol.Optional(ATTR_DATA): dict, # nobody seems to be using this (yet) + vol.Optional(ATTR_DATA): dict, }) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/joaoapps_join.py b/homeassistant/components/notify/joaoapps_join.py new file mode 100644 index 00000000000..67ecd493a06 --- /dev/null +++ b/homeassistant/components/notify/joaoapps_join.py @@ -0,0 +1,62 @@ +""" +Join platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.join/ +""" +import logging +import voluptuous as vol +from homeassistant.components.notify import ( + ATTR_DATA, ATTR_TITLE, BaseNotificationService) +from homeassistant.const import CONF_PLATFORM, CONF_NAME, CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = [ + 'https://github.com/nkgilley/python-join-api/archive/' + '3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'joaoapps_join', + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_API_KEY): cv.string +}) + + +# pylint: disable=unused-variable +def get_service(hass, config): + """Get the Join notification service.""" + device_id = config.get(CONF_DEVICE_ID) + api_key = config.get(CONF_API_KEY) + if api_key: + from pyjoin import get_devices + if not get_devices(api_key): + _LOGGER.error("Error connecting to Join, check API key") + return False + return JoinNotificationService(device_id, api_key) + + +# pylint: disable=too-few-public-methods +class JoinNotificationService(BaseNotificationService): + """Implement the notification service for Join.""" + + def __init__(self, device_id, api_key=None): + """Initialize the service.""" + self._device_id = device_id + self._api_key = api_key + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + from pyjoin import send_notification + title = kwargs.get(ATTR_TITLE) + data = kwargs.get(ATTR_DATA) or {} + send_notification(device_id=self._device_id, + text=message, + title=title, + icon=data.get('icon'), + smallicon=data.get('smallicon'), + api_key=self._api_key) diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 3b2734e2674..419a796199a 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -13,3 +13,7 @@ notify: target: description: Target of the notification. Optional depending on the platform example: platform specific + + data: + description: Extended information for notification. Optional depending on the platform + example: platform specific diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 5257c965cd6..07c4ecd0a71 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -10,7 +10,7 @@ from homeassistant.components.notify import DOMAIN, BaseNotificationService from homeassistant.const import CONF_API_KEY from homeassistant.helpers import validate_config -REQUIREMENTS = ['slacker==0.9.17'] +REQUIREMENTS = ['slacker==0.9.21'] _LOGGER = logging.getLogger(__name__) @@ -30,8 +30,7 @@ def get_service(hass, config): config[CONF_API_KEY]) except slacker.Error: - _LOGGER.exception( - "Slack authentication failed") + _LOGGER.exception("Slack authentication failed") return None diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 9173964c275..bab891b024c 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -4,17 +4,27 @@ Telegram platform for notify component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.telegram/ """ +import io import logging import urllib +import requests +from requests.auth import HTTPBasicAuth from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_DATA, DOMAIN, BaseNotificationService) from homeassistant.const import CONF_API_KEY from homeassistant.helpers import validate_config _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-telegram-bot==4.2.1'] +REQUIREMENTS = ['python-telegram-bot==4.3.3'] + +ATTR_PHOTO = "photo" +ATTR_FILE = "file" +ATTR_URL = "url" +ATTR_CAPTION = "caption" +ATTR_USERNAME = "username" +ATTR_PASSWORD = "password" def get_service(hass, config): @@ -54,9 +64,51 @@ class TelegramNotificationService(BaseNotificationService): import telegram title = kwargs.get(ATTR_TITLE) + data = kwargs.get(ATTR_DATA, {}) + # send message try: self.bot.sendMessage(chat_id=self._chat_id, text=title + " " + message) except telegram.error.TelegramError: _LOGGER.exception("Error sending message.") + return + + # send photo + if ATTR_PHOTO in data: + # if not a list + if not isinstance(data[ATTR_PHOTO], list): + photos = [data[ATTR_PHOTO]] + else: + photos = data[ATTR_PHOTO] + + try: + for photo_data in photos: + caption = photo_data.get(ATTR_CAPTION, None) + + # file is a url + if ATTR_URL in photo_data: + # use http authenticate + if ATTR_USERNAME in photo_data and\ + ATTR_PASSWORD in photo_data: + req = requests.get( + photo_data[ATTR_URL], + auth=HTTPBasicAuth(photo_data[ATTR_USERNAME], + photo_data[ATTR_PASSWORD]) + ) + else: + req = requests.get(photo_data[ATTR_URL]) + file_id = io.BytesIO(req.content) + elif ATTR_FILE in photo_data: + file_id = open(photo_data[ATTR_FILE], "rb") + else: + _LOGGER.error("No url or path is set for photo!") + continue + + self.bot.sendPhoto(chat_id=self._chat_id, + photo=file_id, caption=caption) + + except (OSError, IOError, telegram.error.TelegramError, + urllib.error.HTTPError): + _LOGGER.exception("Error sending photo.") + return diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification.py index 66a634616fa..cf030aee9b8 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification.py @@ -4,6 +4,7 @@ A component which is collecting configuration errors. For more details about this component, please refer to the documentation at https://home-assistant.io/components/persistent_notification/ """ +import os import logging import voluptuous as vol @@ -12,6 +13,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import template, config_validation as cv from homeassistant.helpers.entity import generate_entity_id from homeassistant.util import slugify +from homeassistant.config import load_yaml_config_file DOMAIN = 'persistent_notification' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -33,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) def create(hass, message, title=None, notification_id=None): - """Turn all or specified light off.""" + """Generate a notification.""" data = { key: value for key, value in [ (ATTR_TITLE, title), @@ -74,7 +76,10 @@ def setup(hass, config): hass.states.set(entity_id, message, attr) - hass.services.register(DOMAIN, SERVICE_CREATE, create_service, {}, + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.services.register(DOMAIN, SERVICE_CREATE, create_service, + descriptions[DOMAIN][SERVICE_CREATE], SCHEMA_SERVICE_CREATE) return True diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py deleted file mode 100644 index 0c7454ad694..00000000000 --- a/homeassistant/components/recorder.py +++ /dev/null @@ -1,529 +0,0 @@ -""" -Support for recording details. - -Component that records all events and state changes. Allows other components -to query this database. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/recorder/ -""" -import atexit -import json -import logging -import queue -import sqlite3 -import threading -from datetime import date, datetime, timedelta -import voluptuous as vol - -import homeassistant.util.dt as dt_util -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, - EVENT_TIME_CHANGED, MATCH_ALL) -from homeassistant.core import Event, EventOrigin, State -from homeassistant.remote import JSONEncoder -from homeassistant.helpers.event import track_point_in_utc_time - -DOMAIN = "recorder" - -DB_FILE = 'home-assistant.db' - -RETURN_ROWCOUNT = "rowcount" -RETURN_LASTROWID = "lastrowid" -RETURN_ONE_ROW = "one_row" - -CONF_PURGE_DAYS = "purge_days" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_PURGE_DAYS): vol.All(vol.Coerce(int), - vol.Range(min=1)), - }) -}, extra=vol.ALLOW_EXTRA) - - -_INSTANCE = None -_LOGGER = logging.getLogger(__name__) - - -def query(sql_query, arguments=None): - """Query the database.""" - _verify_instance() - - return _INSTANCE.query(sql_query, arguments) - - -def query_states(state_query, arguments=None): - """Query the database and return a list of states.""" - return [ - row for row in - (row_to_state(row) for row in query(state_query, arguments)) - if row is not None] - - -def query_events(event_query, arguments=None): - """Query the database and return a list of states.""" - return [ - row for row in - (row_to_event(row) for row in query(event_query, arguments)) - if row is not None] - - -def row_to_state(row): - """Convert a database row to a state.""" - try: - return State( - row[1], row[2], json.loads(row[3]), - dt_util.utc_from_timestamp(row[4]), - dt_util.utc_from_timestamp(row[5])) - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting row to state: %s", row) - return None - - -def row_to_event(row): - """Convert a databse row to an event.""" - try: - return Event(row[1], json.loads(row[2]), EventOrigin(row[3]), - dt_util.utc_from_timestamp(row[5])) - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting row to event: %s", row) - return None - - -def run_information(point_in_time=None): - """Return information about current run. - - There is also the run that covers point_in_time. - """ - _verify_instance() - - if point_in_time is None or point_in_time > _INSTANCE.recording_start: - return RecorderRun() - - run = _INSTANCE.query( - "SELECT * FROM recorder_runs WHERE start?", - (point_in_time, point_in_time), return_value=RETURN_ONE_ROW) - - return RecorderRun(run) if run else None - - -def setup(hass, config): - """Setup the recorder.""" - # pylint: disable=global-statement - global _INSTANCE - purge_days = config.get(DOMAIN, {}).get(CONF_PURGE_DAYS) - _INSTANCE = Recorder(hass, purge_days=purge_days) - - return True - - -class RecorderRun(object): - """Representation of a recorder run.""" - - def __init__(self, row=None): - """Initialize the recorder run.""" - self.end = None - - if row is None: - self.start = _INSTANCE.recording_start - self.closed_incorrect = False - else: - self.start = dt_util.utc_from_timestamp(row[1]) - - if row[2] is not None: - self.end = dt_util.utc_from_timestamp(row[2]) - - self.closed_incorrect = bool(row[3]) - - def entity_ids(self, point_in_time=None): - """Return the entity ids that existed in this run. - - Specify point_in_time if you want to know which existed at that point - in time inside the run. - """ - where = self.where_after_start_run - where_data = [] - - if point_in_time is not None or self.end is not None: - where += "AND created < ? " - where_data.append(point_in_time or self.end) - - return [row[0] for row in query( - "SELECT entity_id FROM states WHERE {}" - "GROUP BY entity_id".format(where), where_data)] - - @property - def where_after_start_run(self): - """Return SQL WHERE clause. - - Selection of the rows created after the start of the run. - """ - return "created >= {} ".format(_adapt_datetime(self.start)) - - @property - def where_limit_to_run(self): - """Return a SQL WHERE clause. - - For limiting the results to this run. - """ - where = self.where_after_start_run - - if self.end is not None: - where += "AND created < {} ".format(_adapt_datetime(self.end)) - - return where - - -class Recorder(threading.Thread): - """A threaded recorder class.""" - - # pylint: disable=too-many-instance-attributes - def __init__(self, hass, purge_days): - """Initialize the recorder.""" - threading.Thread.__init__(self) - - self.hass = hass - self.purge_days = purge_days - self.conn = None - self.queue = queue.Queue() - self.quit_object = object() - self.lock = threading.Lock() - self.recording_start = dt_util.utcnow() - self.utc_offset = dt_util.now().utcoffset().total_seconds() - self.db_path = self.hass.config.path(DB_FILE) - - def start_recording(event): - """Start recording.""" - self.start() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_recording) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) - hass.bus.listen(MATCH_ALL, self.event_listener) - - def run(self): - """Start processing events to save.""" - self._setup_connection() - self._setup_run() - if self.purge_days is not None: - track_point_in_utc_time(self.hass, - lambda now: self._purge_old_data(), - dt_util.utcnow() + timedelta(minutes=5)) - - while True: - event = self.queue.get() - - 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 - - event_id = 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): - """Listen for new events and put them in the process queue.""" - self.queue.put(event) - - def shutdown(self, event): - """Tell the recorder to shut down.""" - self.queue.put(self.quit_object) - self.block_till_done() - - def record_state(self, entity_id, state, event_id): - """Save a state to the database.""" - now = dt_util.utcnow() - - # State got deleted - if state is None: - state_state = '' - state_domain = '' - state_attr = '{}' - last_changed = last_updated = now - else: - state_domain = state.domain - state_state = state.state - state_attr = json.dumps(dict(state.attributes)) - last_changed = state.last_changed - last_updated = state.last_updated - - info = ( - entity_id, state_domain, state_state, state_attr, - last_changed, last_updated, - now, self.utc_offset, event_id) - - self.query( - """ - INSERT INTO states ( - entity_id, domain, state, attributes, last_changed, last_updated, - 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), dt_util.utcnow(), event.time_fired, - self.utc_offset - ) - - return self.query( - "INSERT INTO events (" - "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.""" - try: - with self.conn, self.lock: - _LOGGER.debug("Running query %s", sql_query) - - cur = self.conn.cursor() - - if data is not None: - cur.execute(sql_query, data) - else: - cur.execute(sql_query) - - if return_value == RETURN_ROWCOUNT: - return cur.rowcount - elif return_value == RETURN_LASTROWID: - return cur.lastrowid - elif return_value == RETURN_ONE_ROW: - return cur.fetchone() - else: - return cur.fetchall() - - except (sqlite3.IntegrityError, sqlite3.OperationalError, - sqlite3.ProgrammingError): - _LOGGER.exception( - "Error querying the database using: %s", sql_query) - return [] - - def block_till_done(self): - """Block till all events processed.""" - self.queue.join() - - def _setup_connection(self): - """Ensure database is ready to fly.""" - self.conn = sqlite3.connect(self.db_path, check_same_thread=False) - self.conn.row_factory = sqlite3.Row - - # Make sure the database is closed whenever Python exits - # without the STOP event being fired. - 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. - cur = self.conn.cursor() - - def save_migration(migration_id): - """Save and commit a migration to the database.""" - cur.execute('INSERT INTO schema_version VALUES (?, ?)', - (migration_id, dt_util.utcnow())) - self.conn.commit() - _LOGGER.info("Database migrated to version %d", migration_id) - - try: - cur.execute('SELECT max(migration_id) FROM schema_version;') - migration_id = cur.fetchone()[0] or 0 - - except sqlite3.OperationalError: - # The table does not exist. - cur.execute('CREATE TABLE schema_version (' - 'migration_id integer primary key, performed integer)') - migration_id = 0 - - if migration_id < 1: - cur.execute(""" - CREATE TABLE recorder_runs ( - run_id integer primary key, - start integer, - end integer, - closed_incorrect integer default 0, - created integer) - """) - - cur.execute(""" - CREATE TABLE events ( - event_id integer primary key, - event_type text, - event_data text, - origin text, - created integer) - """) - cur.execute( - 'CREATE INDEX events__event_type ON events(event_type)') - - cur.execute(""" - CREATE TABLE states ( - state_id integer primary key, - entity_id text, - state text, - attributes text, - last_changed integer, - last_updated integer, - created integer) - """) - cur.execute('CREATE INDEX states__entity_id ON states(entity_id)') - - save_migration(1) - - if migration_id < 2: - cur.execute(""" - ALTER TABLE events - ADD COLUMN time_fired integer - """) - - cur.execute('UPDATE events SET time_fired=created') - - save_migration(2) - - if migration_id < 3: - utc_offset = self.utc_offset - - cur.execute(""" - ALTER TABLE recorder_runs - ADD COLUMN utc_offset integer - """) - - cur.execute(""" - ALTER TABLE events - ADD COLUMN utc_offset integer - """) - - cur.execute(""" - ALTER TABLE states - ADD COLUMN utc_offset integer - """) - - cur.execute("UPDATE recorder_runs SET utc_offset=?", [utc_offset]) - cur.execute("UPDATE events SET utc_offset=?", [utc_offset]) - cur.execute("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. - cur.execute( - """UPDATE recorder_runs SET utc_offset=? - WHERE utc_offset IS NULL""", [self.utc_offset]) - - cur.execute(""" - ALTER TABLE states - ADD COLUMN event_id integer - """) - - save_migration(4) - - if migration_id < 5: - # Add domain so that thermostat graphs look right. - try: - cur.execute(""" - ALTER TABLE states - ADD COLUMN domain text - """) - except sqlite3.OperationalError: - # We had a bug in this migration for a while on dev. - # Without this, dev-users will have to throw away their db. - pass - - # TravisCI has Python compiled against an old version of SQLite3 - # which misses the instr method. - self.conn.create_function( - "instr", 2, - lambda string, substring: string.find(substring) + 1) - - # Populate domain with defaults. - cur.execute(""" - UPDATE states - set domain=substr(entity_id, 0, instr(entity_id, '.')) - """) - - # Add indexes we are going to use a lot on selects. - cur.execute(""" - CREATE INDEX states__state_changes ON - states (last_changed, last_updated, entity_id)""") - cur.execute(""" - CREATE INDEX states__significant_changes ON - states (domain, last_updated, entity_id)""") - save_migration(5) - - def _close_connection(self): - """Close connection to the database.""" - _LOGGER.info("Closing database") - atexit.unregister(self._close_connection) - self.conn.close() - - def _setup_run(self): - """Log the start of the current run.""" - if self.query("""UPDATE recorder_runs SET end=?, closed_incorrect=1 - WHERE end IS NULL""", (self.recording_start, ), - return_value=RETURN_ROWCOUNT): - - _LOGGER.warning("Found unfinished sessions") - - self.query( - """INSERT INTO recorder_runs (start, created, utc_offset) - VALUES (?, ?, ?)""", - (self.recording_start, dt_util.utcnow(), self.utc_offset)) - - def _close_run(self): - """Save end time for current run.""" - self.query( - "UPDATE recorder_runs SET end=? WHERE start=?", - (dt_util.utcnow(), self.recording_start)) - - def _purge_old_data(self): - """Purge events and states older than purge_days ago.""" - if not self.purge_days or self.purge_days < 1: - _LOGGER.debug("purge_days set to %s, will not purge any old data.", - self.purge_days) - return - - purge_before = dt_util.utcnow() - timedelta(days=self.purge_days) - - _LOGGER.info("Purging events created before %s", purge_before) - deleted_rows = self.query( - sql_query="DELETE FROM events WHERE created < ?;", - data=(int(purge_before.timestamp()),), - return_value=RETURN_ROWCOUNT) - _LOGGER.debug("Deleted %s events", deleted_rows) - - _LOGGER.info("Purging states created before %s", purge_before) - deleted_rows = self.query( - sql_query="DELETE FROM states WHERE created < ?;", - data=(int(purge_before.timestamp()),), - return_value=RETURN_ROWCOUNT) - _LOGGER.debug("Deleted %s states", deleted_rows) - - # Execute sqlite vacuum command to free up space on disk - self.query("VACUUM;") - - -def _adapt_datetime(datetimestamp): - """Turn a datetime into an integer for in the DB.""" - return dt_util.as_utc(datetimestamp).timestamp() - - -def _verify_instance(): - """Throw error if recorder not initialized.""" - if _INSTANCE is None: - raise RuntimeError("Recorder not initialized.") diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py new file mode 100644 index 00000000000..b52bce47c17 --- /dev/null +++ b/homeassistant/components/recorder/__init__.py @@ -0,0 +1,337 @@ +""" +Support for recording details. + +Component that records all events and state changes. Allows other components +to query this database. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/recorder/ +""" +import logging +import queue +import threading +import time +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.util.dt as dt_util +from homeassistant.const import (EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, + EVENT_TIME_CHANGED, MATCH_ALL) +from homeassistant.helpers.event import track_point_in_utc_time + +DOMAIN = "recorder" + +REQUIREMENTS = ['sqlalchemy==1.0.14'] + +DEFAULT_URL = "sqlite:///{hass_config_path}" +DEFAULT_DB_FILE = "home-assistant_v2.db" + +CONF_DB_URL = "db_url" +CONF_PURGE_DAYS = "purge_days" + +RETRIES = 3 +CONNECT_RETRY_WAIT = 10 +QUERY_RETRY_WAIT = 0.1 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_PURGE_DAYS): vol.All(vol.Coerce(int), + vol.Range(min=1)), + vol.Optional(CONF_DB_URL): vol.Url(''), + }) +}, extra=vol.ALLOW_EXTRA) + +_INSTANCE = None +_LOGGER = logging.getLogger(__name__) + +# These classes will be populated during setup() +# pylint: disable=invalid-name +Session = None + + +def execute(q): + """Query the database and convert the objects to HA native form. + + This method also retries a few times in the case of stale connections. + """ + import sqlalchemy.exc + for _ in range(0, RETRIES): + try: + return [ + row for row in + (row.to_native() for row in q) + if row is not None] + except sqlalchemy.exc.SQLAlchemyError as e: + log_error(e, retry_wait=QUERY_RETRY_WAIT, rollback=True) + return [] + + +def run_information(point_in_time=None): + """Return information about current run. + + There is also the run that covers point_in_time. + """ + _verify_instance() + + recorder_runs = get_model('RecorderRuns') + if point_in_time is None or point_in_time > _INSTANCE.recording_start: + return recorder_runs( + end=None, + start=_INSTANCE.recording_start, + closed_incorrect=False) + + return query('RecorderRuns').filter( + (recorder_runs.start < point_in_time) & + (recorder_runs.end > point_in_time)).first() + + +def setup(hass, config): + """Setup the recorder.""" + # pylint: disable=global-statement + # pylint: disable=too-many-locals + global _INSTANCE + purge_days = config.get(DOMAIN, {}).get(CONF_PURGE_DAYS) + + db_url = config.get(DOMAIN, {}).get(CONF_DB_URL, None) + if not db_url: + db_url = DEFAULT_URL.format( + hass_config_path=hass.config.path(DEFAULT_DB_FILE)) + + _INSTANCE = Recorder(hass, purge_days=purge_days, uri=db_url) + + return True + + +def query(model_name, *args): + """Helper to return a query handle.""" + if isinstance(model_name, str): + return Session().query(get_model(model_name), *args) + return Session().query(model_name, *args) + + +def get_model(model_name): + """Get a model class.""" + from homeassistant.components.recorder import models + + return getattr(models, model_name) + + +def log_error(e, retry_wait=0, rollback=True, + message="Error during query: %s"): + """Log about SQLAlchemy errors in a sane manner.""" + import sqlalchemy.exc + if not isinstance(e, sqlalchemy.exc.OperationalError): + _LOGGER.exception(e) + else: + _LOGGER.error(message, str(e)) + if rollback: + Session().rollback() + if retry_wait: + _LOGGER.info("Retrying failed query in %s seconds", QUERY_RETRY_WAIT) + time.sleep(retry_wait) + + +class Recorder(threading.Thread): + """A threaded recorder class.""" + + # pylint: disable=too-many-instance-attributes + def __init__(self, hass, purge_days, uri): + """Initialize the recorder.""" + threading.Thread.__init__(self) + + self.hass = hass + self.purge_days = purge_days + self.queue = queue.Queue() + self.quit_object = object() + self.recording_start = dt_util.utcnow() + self.db_url = uri + self.db_ready = threading.Event() + self.engine = None + self._run = None + + def start_recording(event): + """Start recording.""" + self.start() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_recording) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + hass.bus.listen(MATCH_ALL, self.event_listener) + + def run(self): + """Start processing events to save.""" + from homeassistant.components.recorder.models import Events, States + import sqlalchemy.exc + + global _INSTANCE + + while True: + try: + self._setup_connection() + self._setup_run() + break + except sqlalchemy.exc.SQLAlchemyError as e: + log_error(e, retry_wait=CONNECT_RETRY_WAIT, rollback=False, + message="Error during connection setup: %s") + + if self.purge_days is not None: + track_point_in_utc_time(self.hass, + lambda now: self._purge_old_data(), + dt_util.utcnow() + timedelta(minutes=5)) + + while True: + event = self.queue.get() + + if event == self.quit_object: + self._close_run() + self._close_connection() + _INSTANCE = None + self.queue.task_done() + return + + elif event.event_type == EVENT_TIME_CHANGED: + self.queue.task_done() + continue + + session = Session() + dbevent = Events.from_event(event) + session.add(dbevent) + + for _ in range(0, RETRIES): + try: + session.commit() + break + except sqlalchemy.exc.OperationalError as e: + log_error(e, retry_wait=QUERY_RETRY_WAIT, + rollback=True) + + if event.event_type != EVENT_STATE_CHANGED: + self.queue.task_done() + continue + + session = Session() + dbstate = States.from_event(event) + + for _ in range(0, RETRIES): + try: + dbstate.event_id = dbevent.event_id + session.add(dbstate) + session.commit() + break + except sqlalchemy.exc.OperationalError as e: + log_error(e, retry_wait=QUERY_RETRY_WAIT, + rollback=True) + + self.queue.task_done() + + def event_listener(self, event): + """Listen for new events and put them in the process queue.""" + self.queue.put(event) + + def shutdown(self, event): + """Tell the recorder to shut down.""" + self.queue.put(self.quit_object) + self.queue.join() + + def block_till_done(self): + """Block till all events processed.""" + self.queue.join() + + def block_till_db_ready(self): + """Block until the database session is ready.""" + self.db_ready.wait() + + def _setup_connection(self): + """Ensure database is ready to fly.""" + # pylint: disable=global-statement + global Session + + import homeassistant.components.recorder.models as models + from sqlalchemy import create_engine + from sqlalchemy.orm import scoped_session + from sqlalchemy.orm import sessionmaker + + if self.db_url == 'sqlite://' or ':memory:' in self.db_url: + from sqlalchemy.pool import StaticPool + self.engine = create_engine( + 'sqlite://', + connect_args={'check_same_thread': False}, + poolclass=StaticPool) + else: + self.engine = create_engine(self.db_url, echo=False) + + models.Base.metadata.create_all(self.engine) + session_factory = sessionmaker(bind=self.engine) + Session = scoped_session(session_factory) + self.db_ready.set() + + def _close_connection(self): + """Close the connection.""" + global Session + self.engine.dispose() + self.engine = None + Session = None + + def _setup_run(self): + """Log the start of the current run.""" + recorder_runs = get_model('RecorderRuns') + for run in query('RecorderRuns').filter_by(end=None): + run.closed_incorrect = True + run.end = self.recording_start + _LOGGER.warning("Ended unfinished session (id=%s from %s)", + run.run_id, run.start) + Session().add(run) + + _LOGGER.warning("Found unfinished sessions") + + self._run = recorder_runs( + start=self.recording_start, + created=dt_util.utcnow() + ) + session = Session() + session.add(self._run) + session.commit() + + def _close_run(self): + """Save end time for current run.""" + self._run.end = dt_util.utcnow() + session = Session() + session.add(self._run) + session.commit() + self._run = None + + def _purge_old_data(self): + """Purge events and states older than purge_days ago.""" + from homeassistant.components.recorder.models import Events, States + + if not self.purge_days or self.purge_days < 1: + _LOGGER.debug("purge_days set to %s, will not purge any old data.", + self.purge_days) + return + + purge_before = dt_util.utcnow() - timedelta(days=self.purge_days) + + _LOGGER.info("Purging events created before %s", purge_before) + deleted_rows = Session().query(Events).filter( + (Events.created < purge_before)).delete(synchronize_session=False) + _LOGGER.debug("Deleted %s events", deleted_rows) + + _LOGGER.info("Purging states created before %s", purge_before) + deleted_rows = Session().query(States).filter( + (States.created < purge_before)).delete(synchronize_session=False) + _LOGGER.debug("Deleted %s states", deleted_rows) + + Session().commit() + Session().expire_all() + + # Execute sqlite vacuum command to free up space on disk + if self.engine.driver == 'sqlite': + _LOGGER.info("Vacuuming SQLite to free space") + self.engine.execute("VACUUM") + + +def _verify_instance(): + """Throw error if recorder not initialized.""" + if _INSTANCE is None: + raise RuntimeError("Recorder not initialized.") diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py new file mode 100644 index 00000000000..554e2f47d08 --- /dev/null +++ b/homeassistant/components/recorder/models.py @@ -0,0 +1,162 @@ +"""Models for SQLAlchemy.""" + +import json +from datetime import datetime +import logging + +from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Index, Integer, + String, Text, distinct) +from sqlalchemy.ext.declarative import declarative_base + +import homeassistant.util.dt as dt_util +from homeassistant.core import Event, EventOrigin, State +from homeassistant.remote import JSONEncoder +from homeassistant.helpers.entity import split_entity_id + +# SQLAlchemy Schema +# pylint: disable=invalid-name +Base = declarative_base() + +_LOGGER = logging.getLogger(__name__) + + +class Events(Base): + # pylint: disable=too-few-public-methods + """Event history data.""" + + __tablename__ = 'events' + event_id = Column(Integer, primary_key=True) + event_type = Column(String(32), index=True) + event_data = Column(Text) + origin = Column(String(32)) + time_fired = Column(DateTime(timezone=True)) + created = Column(DateTime(timezone=True), default=datetime.utcnow) + + @staticmethod + def from_event(event): + """Create an event database object from a native event.""" + return Events(event_type=event.event_type, + event_data=json.dumps(event.data, cls=JSONEncoder), + origin=str(event.origin), + time_fired=event.time_fired) + + def to_native(self): + """Convert to a natve HA Event.""" + try: + return Event( + self.event_type, + json.loads(self.event_data), + EventOrigin(self.origin), + _process_timestamp(self.time_fired) + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting to event: %s", self) + return None + + +class States(Base): + # pylint: disable=too-few-public-methods + """State change history.""" + + __tablename__ = 'states' + state_id = Column(Integer, primary_key=True) + domain = Column(String(64)) + entity_id = Column(String(64)) + state = Column(String(255)) + attributes = Column(Text) + event_id = Column(Integer, ForeignKey('events.event_id')) + last_changed = Column(DateTime(timezone=True), default=datetime.utcnow) + last_updated = Column(DateTime(timezone=True), default=datetime.utcnow) + created = Column(DateTime(timezone=True), default=datetime.utcnow) + + __table_args__ = (Index('states__state_changes', + 'last_changed', 'last_updated', 'entity_id'), + Index('states__significant_changes', + 'domain', 'last_updated', 'entity_id'), ) + + @staticmethod + def from_event(event): + """Create object from a state_changed event.""" + entity_id = event.data['entity_id'] + state = event.data.get('new_state') + + dbstate = States(entity_id=entity_id) + + # State got deleted + if state is None: + dbstate.state = '' + dbstate.domain = split_entity_id(entity_id)[0] + dbstate.attributes = '{}' + dbstate.last_changed = event.time_fired + dbstate.last_updated = event.time_fired + else: + dbstate.domain = state.domain + dbstate.state = state.state + dbstate.attributes = json.dumps(dict(state.attributes)) + dbstate.last_changed = state.last_changed + dbstate.last_updated = state.last_updated + + return dbstate + + def to_native(self): + """Convert to an HA state object.""" + try: + return State( + self.entity_id, self.state, + json.loads(self.attributes), + _process_timestamp(self.last_changed), + _process_timestamp(self.last_updated) + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self) + return None + + +class RecorderRuns(Base): + # pylint: disable=too-few-public-methods + """Representation of recorder run.""" + + __tablename__ = 'recorder_runs' + run_id = Column(Integer, primary_key=True) + start = Column(DateTime(timezone=True), default=datetime.utcnow) + end = Column(DateTime(timezone=True)) + closed_incorrect = Column(Boolean, default=False) + created = Column(DateTime(timezone=True), default=datetime.utcnow) + + def entity_ids(self, point_in_time=None): + """Return the entity ids that existed in this run. + + Specify point_in_time if you want to know which existed at that point + in time inside the run. + """ + from sqlalchemy.orm.session import Session + + session = Session.object_session(self) + + assert session is not None, 'RecorderRuns need to be persisted' + + query = session.query(distinct(States.entity_id)).filter( + States.last_updated >= self.start) + + if point_in_time is not None: + query = query.filter(States.last_updated < point_in_time) + elif self.end is not None: + query = query.filter(States.last_updated < self.end) + + return [row[0] for row in query] + + def to_native(self): + """Return self, native format is this model.""" + return self + + +def _process_timestamp(ts): + """Process a timestamp into datetime object.""" + if ts is None: + return None + elif ts.tzinfo is None: + return dt_util.UTC.localize(ts) + else: + return dt_util.as_utc(ts) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 3699671635e..e5ffba44d40 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity from homeassistant.const import (ATTR_ENTITY_ID, TEMP_CELSIUS) -REQUIREMENTS = ['pyRFXtrx==0.8.0'] +REQUIREMENTS = ['pyRFXtrx==0.9.0'] DOMAIN = "rfxtrx" @@ -40,6 +40,7 @@ DATA_TYPES = OrderedDict([ ('Rain rate', ''), ('Energy usage', 'W'), ('Total usage', 'W'), + ('Sound', ''), ('Sensor Status', ''), ('Unknown', '')]) @@ -65,6 +66,9 @@ def _valid_device(value, device_type): key = device.get('packetid') device.pop('packetid') + if not len(key) % 2 == 0: + key = '0' + key + if get_rfx_object(key) is None: raise vol.Invalid('Rfxtrx device {} is invalid: ' 'Invalid device id for {}'.format(key, value)) @@ -159,7 +163,11 @@ def get_rfx_object(packetid): """Return the RFXObject with the packetid.""" import RFXtrx as rfxtrxmod - binarypacket = bytearray.fromhex(packetid) + try: + binarypacket = bytearray.fromhex(packetid) + except ValueError: + return None + pkt = rfxtrxmod.lowlevel.parse(binarypacket) if pkt is None: return None diff --git a/homeassistant/components/rollershutter/wink.py b/homeassistant/components/rollershutter/wink.py index 750e7cf654b..27bd90e0275 100644 --- a/homeassistant/components/rollershutter/wink.py +++ b/homeassistant/components/rollershutter/wink.py @@ -10,7 +10,7 @@ from homeassistant.components.rollershutter import RollershutterDevice from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/sensor/apcupsd.py b/homeassistant/components/sensor/apcupsd.py index b625e22691c..4ae82cba602 100644 --- a/homeassistant/components/sensor/apcupsd.py +++ b/homeassistant/components/sensor/apcupsd.py @@ -11,33 +11,109 @@ from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity import Entity DEPENDENCIES = [apcupsd.DOMAIN] -DEFAULT_NAME = "UPS Status" + +SENSOR_PREFIX = 'UPS ' +SENSOR_TYPES = { + 'alarmdel': ['Alarm Delay', '', 'mdi:alarm'], + 'ambtemp': ['Ambient Temperature', '', 'mdi:thermometer'], + 'apc': ['Status Data', '', 'mdi:information-outline'], + 'apcmodel': ['Model', '', 'mdi:information-outline'], + 'badbatts': ['Bad Batteries', '', 'mdi:information-outline'], + 'battdate': ['Battery Replaced', '', 'mdi:calendar-clock'], + 'battstat': ['Battery Status', '', 'mdi:information-outline'], + 'battv': ['Battery Voltage', 'V', 'mdi:flash'], + 'bcharge': ['Battery', '%', 'mdi:battery'], + 'cable': ['Cable Type', '', 'mdi:ethernet-cable'], + 'cumonbatt': ['Total Time on Battery', '', 'mdi:timer'], + 'date': ['Status Date', '', 'mdi:calendar-clock'], + 'dipsw': ['Dip Switch Settings', '', 'mdi:information-outline'], + 'dlowbatt': ['Low Battery Signal', '', 'mdi:clock-alert'], + 'driver': ['Driver', '', 'mdi:information-outline'], + 'dshutd': ['Shutdown Delay', '', 'mdi:timer'], + 'dwake': ['Wake Delay', '', 'mdi:timer'], + 'endapc': ['Date and Time', '', 'mdi:calendar-clock'], + 'extbatts': ['External Batteries', '', 'mdi:information-outline'], + 'firmware': ['Firmware Version', '', 'mdi:information-outline'], + 'hitrans': ['Transfer High', 'V', 'mdi:flash'], + 'hostname': ['Hostname', '', 'mdi:information-outline'], + 'humidity': ['Ambient Humidity', '%', 'mdi:water-percent'], + 'itemp': ['Internal Temperature', TEMP_CELSIUS, 'mdi:thermometer'], + 'lastxfer': ['Last Transfer', '', 'mdi:transfer'], + 'linefail': ['Input Voltage Status', '', 'mdi:information-outline'], + 'linefreq': ['Line Frequency', 'Hz', 'mdi:information-outline'], + 'linev': ['Input Voltage', 'V', 'mdi:flash'], + 'loadpct': ['Load', '%', 'mdi:gauge'], + 'lotrans': ['Transfer Low', 'V', 'mdi:flash'], + 'mandate': ['Manufacture Date', '', 'mdi:calendar'], + 'masterupd': ['Master Update', '', 'mdi:information-outline'], + 'maxlinev': ['Input Voltage High', 'V', 'mdi:flash'], + 'maxtime': ['Battery Timeout', '', 'mdi:timer-off'], + 'mbattchg': ['Battery Shutdown', '%', 'mdi:battery-alert'], + 'minlinev': ['Input Voltage Low', 'V', 'mdi:flash'], + 'mintimel': ['Shutdown Time', '', 'mdi:timer'], + 'model': ['Model', '', 'mdi:information-outline'], + 'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'], + 'nominv': ['Nominal Input Voltage', 'V', 'mdi:flash'], + 'nomoutv': ['Nominal Output Voltage', 'V', 'mdi:flash'], + 'nompower': ['Nominal Output Power', 'W', 'mdi:flash'], + 'numxfers': ['Transfer Count', '', 'mdi:counter'], + 'outputv': ['Output Voltage', 'V', 'mdi:flash'], + 'reg1': ['Register 1 Fault', '', 'mdi:information-outline'], + 'reg2': ['Register 2 Fault', '', 'mdi:information-outline'], + 'reg3': ['Register 3 Fault', '', 'mdi:information-outline'], + 'retpct': ['Restore Requirement', '%', 'mdi:battery-alert'], + 'selftest': ['Last Self Test', '', 'mdi:calendar-clock'], + 'sense': ['Sensitivity', '', 'mdi:information-outline'], + 'serialno': ['Serial Number', '', 'mdi:information-outline'], + 'starttime': ['Startup Time', '', 'mdi:calendar-clock'], + 'statflag': ['Status Flag', '', 'mdi:information-outline'], + 'status': ['Status', '', 'mdi:information-outline'], + 'stesti': ['Self Test Interval', '', 'mdi:information-outline'], + 'timeleft': ['Time Left', '', 'mdi:clock-alert'], + 'tonbatt': ['Time on Battery', '', 'mdi:timer'], + 'upsmode': ['Mode', '', 'mdi:information-outline'], + 'upsname': ['Name', '', 'mdi:information-outline'], + 'version': ['Daemon Info', '', 'mdi:information-outline'], + 'xoffbat': ['Transfer from Battery', '', 'mdi:transfer'], + 'xoffbatt': ['Transfer from Battery', '', 'mdi:transfer'], + 'xonbatt': ['Transfer to Battery', '', 'mdi:transfer'], +} + SPECIFIC_UNITS = { - "ITEMP": TEMP_CELSIUS + 'ITEMP': TEMP_CELSIUS +} +INFERRED_UNITS = { + ' Minutes': 'min', + ' Seconds': 'sec', + ' Percent': '%', + ' Volts': 'V', + ' Watts': 'W', + ' Hz': 'Hz', + ' C': TEMP_CELSIUS, } _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): - """Setup the APCUPSd sensor.""" - typ = config.get(apcupsd.CONF_TYPE) - if typ is None: - _LOGGER.error( - "You must include a '%s' when configuring an APCUPSd sensor.", - apcupsd.CONF_TYPE) - return False - typ = typ.upper() + """Setup the APCUPSd sensors.""" + entities = [] - if typ not in apcupsd.DATA.status: - _LOGGER.error( - "Specified '%s' of '%s' does not appear in the APCUPSd status " - "output.", apcupsd.CONF_TYPE, typ) - return False + for resource in config['resources']: + sensor_type = resource.lower() - add_entities(( - Sensor(config, apcupsd.DATA, unit=SPECIFIC_UNITS.get(typ)), - )) + if sensor_type not in SENSOR_TYPES: + SENSOR_TYPES[sensor_type] = [ + sensor_type.title(), '', 'mdi:information-outline'] + + if sensor_type.upper() not in apcupsd.DATA.status: + _LOGGER.warning( + 'Sensor type: "%s" does not appear in the APCUPSd status ' + 'output.', sensor_type) + + entities.append(APCUPSdSensor(apcupsd.DATA, sensor_type)) + + add_entities(entities) def infer_unit(value): @@ -49,25 +125,31 @@ def infer_unit(value): from apcaccess.status import ALL_UNITS for unit in ALL_UNITS: if value.endswith(unit): - return value[:-len(unit)], unit + return value[:-len(unit)], INFERRED_UNITS.get(unit, unit.strip()) return value, None -class Sensor(Entity): +class APCUPSdSensor(Entity): """Representation of a sensor entity for APCUPSd status values.""" - def __init__(self, config, data, unit=None): + def __init__(self, data, sensor_type): """Initialize the sensor.""" - self._config = config - self._unit = unit self._data = data + self.type = sensor_type + self._name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] + self._unit = SENSOR_TYPES[sensor_type][1] self._inferred_unit = None self.update() @property def name(self): """Return the name of the UPS sensor.""" - return self._config.get("name", DEFAULT_NAME) + return self._name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self.type][2] @property def state(self): @@ -77,11 +159,15 @@ class Sensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self._unit is None: + if not self._unit: return self._inferred_unit return self._unit def update(self): """Get the latest status and use it to update our sensor state.""" - key = self._config[apcupsd.CONF_TYPE].upper() - self._state, self._inferred_unit = infer_unit(self._data.status[key]) + if self.type.upper() not in self._data.status: + self._state = None + self._inferred_unit = None + else: + self._state, self._inferred_unit = infer_unit( + self._data.status[self.type.upper()]) diff --git a/homeassistant/components/sensor/deutsche_bahn.py b/homeassistant/components/sensor/deutsche_bahn.py index 99b96b971c9..2dc589271e9 100644 --- a/homeassistant/components/sensor/deutsche_bahn.py +++ b/homeassistant/components/sensor/deutsche_bahn.py @@ -13,6 +13,7 @@ from homeassistant.const import (CONF_PLATFORM) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util REQUIREMENTS = ['schiene==0.17'] @@ -88,6 +89,7 @@ class SchieneData(object): def __init__(self, start, goal): """Initialize the sensor.""" import schiene + self.start = start self.goal = goal self.schiene = schiene.Schiene() @@ -96,7 +98,8 @@ class SchieneData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the connection data.""" - self.connections = self.schiene.connections(self.start, self.goal) + self.connections = self.schiene.connections( + self.start, self.goal, dt_util.as_local(dt_util.utcnow())) for con in self.connections: # Detail info is not useful. Having a more consistent interface diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py index cd71673b99f..dcb8cc201d7 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -30,7 +30,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class EnvisalinkSensor(EnvisalinkDevice): - """Representation of an envisalink keypad.""" + """Representation of an Envisalink keypad.""" def __init__(self, partition_name, partition_number, info, controller): """Initialize the sensor.""" diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 1f12f9f5a88..4add83a87ba 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -10,14 +10,18 @@ import logging import voluptuous as vol from homeassistant.helpers.entity import Entity -from homeassistant.const import CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT, + EVENT_HOMEASSISTANT_START, ATTR_LATITUDE, ATTR_LONGITUDE) + from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.location as location import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['googlemaps==2.4.3'] +REQUIREMENTS = ['googlemaps==2.4.4'] # Return cached results if last update was less then this time ago MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -65,6 +69,8 @@ PLATFORM_SCHEMA = vol.Schema({ })) }) +TRACKABLE_DOMAINS = ["device_tracker", "sensor", "zone"] + def convert_time_to_utc(timestr): """Take a string like 08:00:00 and convert it to a unix timestamp.""" @@ -78,36 +84,44 @@ def convert_time_to_utc(timestr): def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the travel time platform.""" # pylint: disable=too-many-locals - options = config.get(CONF_OPTIONS) + def run_setup(event): + """Delay the setup until home assistant is fully initialized. - if options.get('units') is None: - if hass.config.temperature_unit is TEMP_CELSIUS: - options['units'] = 'metric' - elif hass.config.temperature_unit is TEMP_FAHRENHEIT: - options['units'] = 'imperial' + This allows any entities to be created already + """ + options = config.get(CONF_OPTIONS) - travel_mode = config.get(CONF_TRAVEL_MODE) - mode = options.get(CONF_MODE) + if options.get('units') is None: + if hass.config.temperature_unit is TEMP_CELSIUS: + options['units'] = 'metric' + elif hass.config.temperature_unit is TEMP_FAHRENHEIT: + options['units'] = 'imperial' - if travel_mode is not None: - wstr = ("Google Travel Time: travel_mode is deprecated, please add " - "mode to the options dictionary instead!") - _LOGGER.warning(wstr) - if mode is None: - options[CONF_MODE] = travel_mode + travel_mode = config.get(CONF_TRAVEL_MODE) + mode = options.get(CONF_MODE) - titled_mode = options.get(CONF_MODE).title() - formatted_name = "Google Travel Time - {}".format(titled_mode) - name = config.get(CONF_NAME, formatted_name) - api_key = config.get(CONF_API_KEY) - origin = config.get(CONF_ORIGIN) - destination = config.get(CONF_DESTINATION) + if travel_mode is not None: + wstr = ("Google Travel Time: travel_mode is deprecated, please " + "add mode to the options dictionary instead!") + _LOGGER.warning(wstr) + if mode is None: + options[CONF_MODE] = travel_mode - sensor = GoogleTravelTimeSensor(name, api_key, origin, destination, - options) + titled_mode = options.get(CONF_MODE).title() + formatted_name = "Google Travel Time - {}".format(titled_mode) + name = config.get(CONF_NAME, formatted_name) + api_key = config.get(CONF_API_KEY) + origin = config.get(CONF_ORIGIN) + destination = config.get(CONF_DESTINATION) - if sensor.valid_api_connection: - add_devices_callback([sensor]) + sensor = GoogleTravelTimeSensor(hass, name, api_key, origin, + destination, options) + + if sensor.valid_api_connection: + add_devices_callback([sensor]) + + # Wait until start event is sent to load this component. + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) # pylint: disable=too-many-instance-attributes @@ -115,15 +129,25 @@ class GoogleTravelTimeSensor(Entity): """Representation of a tavel time sensor.""" # pylint: disable=too-many-arguments - def __init__(self, name, api_key, origin, destination, options): + def __init__(self, hass, name, api_key, origin, destination, options): """Initialize the sensor.""" + self._hass = hass self._name = name self._options = options - self._origin = origin - self._destination = destination self._matrix = None self.valid_api_connection = True + # Check if location is a trackable entity + if origin.split('.', 1)[0] in TRACKABLE_DOMAINS: + self._origin_entity_id = origin + else: + self._origin = origin + + if destination.split('.', 1)[0] in TRACKABLE_DOMAINS: + self._destination_entity_id = destination + else: + self._destination = destination + import googlemaps self._client = googlemaps.Client(api_key, timeout=10) try: @@ -136,6 +160,9 @@ class GoogleTravelTimeSensor(Entity): @property def state(self): """Return the state of the sensor.""" + if self._matrix is None: + return None + _data = self._matrix['rows'][0]['elements'][0] if 'duration_in_traffic' in _data: return round(_data['duration_in_traffic']['value']/60) @@ -151,6 +178,9 @@ class GoogleTravelTimeSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" + if self._matrix is None: + return None + res = self._matrix.copy() res.update(self._options) del res['rows'] @@ -186,6 +216,64 @@ class GoogleTravelTimeSensor(Entity): elif atime is not None: options_copy['arrival_time'] = atime - self._matrix = self._client.distance_matrix(self._origin, - self._destination, - **options_copy) + # Convert device_trackers to google friendly location + if hasattr(self, '_origin_entity_id'): + self._origin = self._get_location_from_entity( + self._origin_entity_id + ) + + if hasattr(self, '_destination_entity_id'): + self._destination = self._get_location_from_entity( + self._destination_entity_id + ) + + self._destination = self._resolve_zone(self._destination) + self._origin = self._resolve_zone(self._origin) + + if self._destination is not None and self._origin is not None: + self._matrix = self._client.distance_matrix(self._origin, + self._destination, + **options_copy) + + def _get_location_from_entity(self, entity_id): + """Get the location from the entity state or attributes.""" + entity = self._hass.states.get(entity_id) + + if entity is None: + _LOGGER.error("Unable to find entity %s", entity_id) + self.valid_api_connection = False + return None + + # Check if device is in a zone + zone_entity = self._hass.states.get("zone.%s" % entity.state) + if location.has_location(zone_entity): + _LOGGER.debug( + "%s is in %s, getting zone location.", + entity_id, zone_entity.entity_id + ) + return self._get_location_from_attributes(zone_entity) + + # If zone was not found in state then use the state as the location + if entity_id.startswith("sensor."): + return entity.state + + # For everything else look for location attributes + if location.has_location(entity): + return self._get_location_from_attributes(entity) + + # When everything fails just return nothing + return None + + @staticmethod + def _get_location_from_attributes(entity): + """Get the lat/long string from an entities attributes.""" + attr = entity.attributes + return "%s,%s" % (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + + def _resolve_zone(self, friendly_name): + entities = self._hass.states.all() + for entity in entities: + if entity.domain == 'zone' and entity.name == friendly_name: + return self._get_location_from_attributes(entity) + + return friendly_name diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index efd22b6cd69..66526408567 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -28,7 +28,10 @@ HM_UNIT_HA_CAST = { "POWER": "W", "CURRENT": "mA", "VOLTAGE": "V", - "ENERGY_COUNTER": "Wh" + "ENERGY_COUNTER": "Wh", + "GAS_POWER": "m3", + "GAS_ENERGY_COUNTER": "m3", + "LUX": "lux" } diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py new file mode 100644 index 00000000000..2d5a1484d14 --- /dev/null +++ b/homeassistant/components/sensor/imap.py @@ -0,0 +1,105 @@ +""" +IMAP sensor support. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.imap/ +""" +import logging +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:email-outline' + +CONF_USER = "user" +CONF_PASSWORD = "password" +CONF_SERVER = "server" +CONF_PORT = "port" +CONF_NAME = "name" + +DEFAULT_PORT = 993 + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required('platform'): 'imap', + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_USER): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_SERVER): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): + vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the IMAP platform.""" + sensor = ImapSensor(config.get(CONF_NAME, None), + config.get(CONF_USER), + config.get(CONF_PASSWORD), + config.get(CONF_SERVER), + config.get(CONF_PORT, DEFAULT_PORT)) + + if sensor.connection: + add_devices([sensor]) + else: + return False + + +class ImapSensor(Entity): + """Representation of an IMAP sensor.""" + + # pylint: disable=too-many-arguments + def __init__(self, name, user, password, server, port): + """Initialize the sensor.""" + self._name = name or user + self._user = user + self._password = password + self._server = server + self._port = port + self._unread_count = 0 + self.connection = self._login() + self.update() + + def _login(self): + """Login and return an IMAP connection.""" + import imaplib + try: + connection = imaplib.IMAP4_SSL(self._server, self._port) + connection.login(self._user, self._password) + return connection + except imaplib.IMAP4.error: + _LOGGER.error("Failed to login to %s.", self._server) + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the number of unread emails.""" + return self._unread_count + + def update(self): + """Check the number of unread emails.""" + import imaplib + try: + self.connection.select() + self._unread_count = len(self.connection.search( + None, 'UnSeen')[1][0].split()) + except imaplib.IMAP4.abort: + _LOGGER.info("Connection to %s lost, attempting to reconnect", + self._server) + try: + self._login() + self.update() + except imaplib.IMAP4.error: + _LOGGER.error("Failed to reconnect.") + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 2abfacc05e3..3394e69da8d 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "loopenergy" -REQUIREMENTS = ['pyloopenergy==0.0.13'] +REQUIREMENTS = ['pyloopenergy==0.0.14'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index c6b2e5aa86c..b58a375755c 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -15,7 +15,7 @@ DEPENDENCIES = [] def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the mysensors platform for sensors.""" + """Setup the MySensors platform for sensors.""" # Only act if loaded via mysensors by discovery event. # Otherwise gateway is not setup. if discovery_info is None: @@ -72,7 +72,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class MySensorsSensor(mysensors.MySensorsDeviceEntity, Entity): - """Represent the value of a MySensors Sensor child node.""" + """Representation of a MySensors Sensor child node.""" @property def state(self): diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 37afea8d393..060f3bd57e8 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyowm==2.3.1'] +REQUIREMENTS = ['pyowm==2.3.2'] _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { 'weather': ['Condition', None], @@ -127,9 +127,9 @@ class OpenWeatherMapSensor(Entity): else: self._state = round(data.get_temperature()['temp'], 1) elif self.type == 'wind_speed': - self._state = data.get_wind()['speed'] + self._state = round(data.get_wind()['speed'], 1) elif self.type == 'humidity': - self._state = data.get_humidity() + self._state = round(data.get_humidity(), 1) elif self.type == 'pressure': self._state = round(data.get_pressure()['press'], 0) elif self.type == 'clouds': diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index 49912acb621..cee54644629 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -41,12 +41,13 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sub_sensors = {} data_types = entity_info[ATTR_DATA_TYPE] if len(data_types) == 0: + data_type = "Unknown" for data_type in DATA_TYPES: if data_type in event.values: data_types = [data_type] break for _data_type in data_types: - new_sensor = RfxtrxSensor(event, entity_info[ATTR_NAME], + new_sensor = RfxtrxSensor(None, entity_info[ATTR_NAME], _data_type) sensors.append(new_sensor) sub_sensors[_data_type] = new_sensor @@ -109,7 +110,7 @@ class RfxtrxSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self.data_type: + if self.event: return self.event.values[self.data_type] return None @@ -121,7 +122,8 @@ class RfxtrxSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - return self.event.values + if self.event: + return self.event.values @property def unit_of_measurement(self): diff --git a/homeassistant/components/sensor/swiss_hydrological_data.py b/homeassistant/components/sensor/swiss_hydrological_data.py index 2589bd44955..5c224f30d37 100644 --- a/homeassistant/components/sensor/swiss_hydrological_data.py +++ b/homeassistant/components/sensor/swiss_hydrological_data.py @@ -5,26 +5,28 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_hydrological_data/ """ import logging -import collections from datetime import timedelta import voluptuous as vol import requests -from homeassistant.const import (TEMP_CELSIUS, CONF_PLATFORM, CONF_NAME) +from homeassistant.const import (TEMP_CELSIUS, CONF_PLATFORM, CONF_NAME, + STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['beautifulsoup4==4.4.1'] +REQUIREMENTS = ['xmltodict==0.10.2'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'http://www.hydrodaten.admin.ch/en/' +_RESOURCE = 'http://www.hydrodata.ch/xml/SMS.xml' DEFAULT_NAME = 'Water temperature' CONF_STATION = 'station' ICON = 'mdi:cup-water' +ATTR_LOCATION = 'Location' +ATTR_UPDATE = 'Update' ATTR_DISCHARGE = 'Discharge' ATTR_WATERLEVEL = 'Level' ATTR_DISCHARGE_MEAN = 'Discharge mean' @@ -37,30 +39,25 @@ ATTR_TEMPERATURE_MAX = 'Temperature max' PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'swiss_hydrological_data', vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_STATION): cv.string, + vol.Required(CONF_STATION): vol.Coerce(int), }) -HydroData = collections.namedtuple( - "HydrologicalData", - ['discharge', 'waterlevel', 'temperature', 'discharge_mean', - 'waterlevel_mean', 'temperature_mean', 'discharge_max', 'waterlevel_max', - 'temperature_max']) - # Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Swiss hydrological sensor.""" + import xmltodict + station = config.get(CONF_STATION) name = config.get(CONF_NAME, DEFAULT_NAME) try: - response = requests.get('{}/{}.html'.format(_RESOURCE, station), - timeout=5) - if not response.ok: - _LOGGER.error('The given station does not seem to exist: %s', - station) + response = requests.get(_RESOURCE, timeout=5) + if any(str(station) == location.get('@StrNr') for location in + xmltodict.parse(response.text)['AKT_Data']['MesPar']) is False: + _LOGGER.error('The given station does not exist: %s', station) return False except requests.exceptions.ConnectionError: _LOGGER.error('The URL is not accessible') @@ -89,27 +86,47 @@ class SwissHydrologicalDataSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + if self._state is not STATE_UNKNOWN: + return self._unit_of_measurement + else: + return None @property def state(self): """Return the state of the sensor.""" - return self._state + try: + return round(float(self._state), 1) + except ValueError: + return STATE_UNKNOWN @property def device_state_attributes(self): """Return the state attributes.""" + attributes = {} if self.data.measurings is not None: - return { - ATTR_DISCHARGE: self.data.measurings.discharge, - ATTR_WATERLEVEL: self.data.measurings.waterlevel, - ATTR_DISCHARGE_MEAN: self.data.measurings.discharge_mean, - ATTR_WATERLEVEL_MEAN: self.data.measurings.waterlevel_mean, - ATTR_TEMPERATURE_MEAN: self.data.measurings.temperature_mean, - ATTR_DISCHARGE_MAX: self.data.measurings.discharge_max, - ATTR_WATERLEVEL_MAX: self.data.measurings.waterlevel_max, - ATTR_TEMPERATURE_MAX: self.data.measurings.temperature_max, - } + if '02' in self.data.measurings: + attributes[ATTR_WATERLEVEL] = self.data.measurings['02'][ + 'current'] + attributes[ATTR_WATERLEVEL_MEAN] = self.data.measurings['02'][ + 'mean'] + attributes[ATTR_WATERLEVEL_MAX] = self.data.measurings['02'][ + 'max'] + if '03' in self.data.measurings: + attributes[ATTR_TEMPERATURE_MEAN] = self.data.measurings['03'][ + 'mean'] + attributes[ATTR_TEMPERATURE_MAX] = self.data.measurings['03'][ + 'max'] + if '10' in self.data.measurings: + attributes[ATTR_DISCHARGE] = self.data.measurings['10'][ + 'current'] + attributes[ATTR_DISCHARGE_MEAN] = self.data.measurings['10'][ + 'current'] + attributes[ATTR_DISCHARGE_MAX] = self.data.measurings['10'][ + 'max'] + + attributes[ATTR_LOCATION] = self.data.measurings['location'] + attributes[ATTR_UPDATE] = self.data.measurings['update_time'] + return attributes @property def icon(self): @@ -121,7 +138,10 @@ class SwissHydrologicalDataSensor(Entity): """Get the latest data and update the states.""" self.data.update() if self.data.measurings is not None: - self._state = self.data.measurings.temperature + if '03' not in self.data.measurings: + self._state = STATE_UNKNOWN + else: + self._state = self.data.measurings['03']['current'] # pylint: disable=too-few-public-methods @@ -135,29 +155,34 @@ class HydrologicalData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Get the latest data from hydrodaten.admin.ch.""" - from bs4 import BeautifulSoup + """Get the latest data from hydrodata.ch.""" + import xmltodict + details = {} try: - response = requests.get('{}/{}.html'.format(_RESOURCE, - self.station), - timeout=5) + response = requests.get(_RESOURCE, timeout=5) except requests.exceptions.ConnectionError: - _LOGGER.error('Unable to retrieve data') - response = None + _LOGGER.error('Unable to retrieve data from %s', _RESOURCE) try: - tables = BeautifulSoup(response.content, - 'html.parser').findChildren('table') - rows = tables[0].findChildren(['th', 'tr']) + stations = xmltodict.parse(response.text)['AKT_Data']['MesPar'] + # Water level: Typ="02", temperature: Typ="03", discharge: Typ="10" + for station in stations: + if str(self.station) != station.get('@StrNr'): + continue + for data in ['02', '03', '10']: + if data != station.get('@Typ'): + continue + values = station.get('Wert') + if values is not None: + details[data] = { + 'current': values[0], + 'max': list(values[4].items())[1][1], + 'mean': list(values[3].items())[1][1]} - details = [] + details['location'] = station.get('Name') + details['update_time'] = station.get('Zeit') - for row in rows: - cells = row.findChildren('td') - for cell in cells: - details.append(cell.string) - - self.measurings = HydroData._make(details) + self.measurings = details except AttributeError: self.measurings = None diff --git a/homeassistant/components/sensor/tellstick.py b/homeassistant/components/sensor/tellstick.py index 1439dd00ee1..e6caf5bfd96 100644 --- a/homeassistant/components/sensor/tellstick.py +++ b/homeassistant/components/sensor/tellstick.py @@ -64,7 +64,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): continue sensor_name = str(ts_sensor.id) - for datatype in sensor_value_descriptions.keys(): + for datatype in sensor_value_descriptions: if datatype & datatype_mask and ts_sensor.has_value(datatype): sensor_info = sensor_value_descriptions[datatype] diff --git a/homeassistant/components/sensor/thinkingcleaner.py b/homeassistant/components/sensor/thinkingcleaner.py index f956ec5037f..3683fb15bf2 100644 --- a/homeassistant/components/sensor/thinkingcleaner.py +++ b/homeassistant/components/sensor/thinkingcleaner.py @@ -68,7 +68,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for device in devices: - for type_name in SENSOR_TYPES.keys(): + for type_name in SENSOR_TYPES: dev.append(ThinkingCleanerSensor(device, type_name, update_devices)) diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index 611c0d44b3d..a27f8ca4def 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -11,9 +11,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['uber_rides==0.2.1'] +REQUIREMENTS = ["uber_rides==0.2.4"] -ICON = 'mdi:taxi' +ICON = "mdi:taxi" # Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -21,35 +21,35 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Uber sensor.""" - if None in (config.get('start_latitude'), config.get('start_longitude')): + if None in (config.get("start_latitude"), config.get("start_longitude")): _LOGGER.error( "You must set start latitude and longitude to use the Uber sensor!" ) return False - if config.get('server_token') is None: + if config.get("server_token") is None: _LOGGER.error("You must set a server_token to use the Uber sensor!") return False from uber_rides.session import Session - session = Session(server_token=config.get('server_token')) + session = Session(server_token=config.get("server_token")) - wanted_product_ids = config.get('product_ids') + wanted_product_ids = config.get("product_ids") dev = [] - timeandpriceest = UberEstimate(session, config['start_latitude'], - config['start_longitude'], - config.get('end_latitude'), - config.get('end_longitude')) + timeandpriceest = UberEstimate(session, config["start_latitude"], + config["start_longitude"], + config.get("end_latitude"), + config.get("end_longitude")) for product_id, product in timeandpriceest.products.items(): if (wanted_product_ids is not None) and \ (product_id not in wanted_product_ids): continue - dev.append(UberSensor('time', timeandpriceest, product_id, product)) - is_metered = (product['price_details']['estimate'] == "Metered") - if 'price_details' in product and is_metered is False: - dev.append(UberSensor('price', timeandpriceest, + dev.append(UberSensor("time", timeandpriceest, product_id, product)) + if (product.get("price_details") is not None) and \ + product["price_details"]["estimate"] is not "Metered": + dev.append(UberSensor("price", timeandpriceest, product_id, product)) add_devices(dev) @@ -64,30 +64,30 @@ class UberSensor(Entity): self._product_id = product_id self._product = product self._sensortype = sensorType - self._name = "{} {}".format(self._product['display_name'], + self._name = "{} {}".format(self._product["display_name"], self._sensortype) if self._sensortype == "time": self._unit_of_measurement = "min" - time_estimate = self._product.get('time_estimate_seconds', 0) + time_estimate = self._product.get("time_estimate_seconds", 0) self._state = int(time_estimate / 60) elif self._sensortype == "price": - if 'price_details' in self._product: - price_details = self._product['price_details'] - self._unit_of_measurement = price_details.get('currency_code', - 'N/A') - if 'low_estimate' in price_details: - statekey = 'minimum' + if self._product.get("price_details") is not None: + price_details = self._product["price_details"] + self._unit_of_measurement = price_details.get("currency_code") + if price_details.get("low_estimate") is not None: + statekey = "minimum" else: - statekey = 'low_estimate' + statekey = "low_estimate" self._state = int(price_details.get(statekey, 0)) else: - self._unit_of_measurement = 'N/A' self._state = 0 self.update() @property def name(self): """Return the name of the sensor.""" + if "uber" not in self._name.lower(): + self._name = "Uber{}".format(self._name) return self._name @property @@ -103,43 +103,41 @@ class UberSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - time_estimate = self._product.get('time_estimate_seconds', 'N/A') + time_estimate = self._product.get("time_estimate_seconds") params = { - 'Product ID': self._product['product_id'], - 'Product short description': self._product['short_description'], - 'Product display name': self._product['display_name'], - 'Product description': self._product['description'], - 'Pickup time estimate (in seconds)': time_estimate, - 'Trip duration (in seconds)': self._product.get('duration', 'N/A'), - 'Vehicle Capacity': self._product['capacity'] + "Product ID": self._product["product_id"], + "Product short description": self._product["short_description"], + "Product display name": self._product["display_name"], + "Product description": self._product["description"], + "Pickup time estimate (in seconds)": time_estimate, + "Trip duration (in seconds)": self._product.get("duration"), + "Vehicle Capacity": self._product["capacity"] } - if 'price_details' in self._product: - price_details = self._product['price_details'] - distance_key = 'Trip distance (in {}s)'.format(price_details[ - 'distance_unit']) - distance_val = self._product.get('distance') - params['Minimum price'] = price_details['minimum'], - params['Cost per minute'] = price_details['cost_per_minute'], - params['Distance units'] = price_details['distance_unit'], - params['Cancellation fee'] = price_details['cancellation_fee'], - params['Cost per distance'] = price_details['cost_per_distance'], - params['Base price'] = price_details['base'], - params['Price estimate'] = price_details.get('estimate', 'N/A'), - params['Price currency code'] = price_details.get('currency_code'), - params['High price estimate'] = price_details.get('high_estimate', - 'N/A'), - params['Low price estimate'] = price_details.get('low_estimate', - 'N/A'), - params['Surge multiplier'] = price_details.get('surge_multiplier', - 'N/A') + if self._product.get("price_details") is not None: + price_details = self._product["price_details"] + dunit = price_details.get("distance_unit") + distance_key = "Trip distance (in {}s)".format(dunit) + distance_val = self._product.get("distance") + params["Cost per minute"] = price_details.get("cost_per_minute") + params["Distance units"] = price_details.get("distance_unit") + params["Cancellation fee"] = price_details.get("cancellation_fee") + cpd = price_details.get("cost_per_distance") + params["Cost per distance"] = cpd + params["Base price"] = price_details.get("base") + params["Minimum price"] = price_details.get("minimum") + params["Price estimate"] = price_details.get("estimate") + params["Price currency code"] = price_details.get("currency_code") + params["High price estimate"] = price_details.get("high_estimate") + params["Low price estimate"] = price_details.get("low_estimate") + params["Surge multiplier"] = price_details.get("surge_multiplier") else: - distance_key = 'Trip distance (in miles)' - distance_val = self._product.get('distance', 'N/A') + distance_key = "Trip distance (in miles)" + distance_val = self._product.get("distance") params[distance_key] = distance_val - return params + return {k: v for k, v in params.items() if v is not None} @property def icon(self): @@ -152,13 +150,13 @@ class UberSensor(Entity): self.data.update() self._product = self.data.products[self._product_id] if self._sensortype == "time": - time_estimate = self._product.get('time_estimate_seconds', 0) + time_estimate = self._product.get("time_estimate_seconds", 0) self._state = int(time_estimate / 60) elif self._sensortype == "price": - price_details = self._product.get('price_details') + price_details = self._product.get("price_details") if price_details is not None: - min_price = price_details.get('minimum') - self._state = int(price_details.get('low_estimate', min_price)) + min_price = price_details.get("minimum") + self._state = int(price_details.get("low_estimate", min_price)) else: self._state = 0 @@ -190,40 +188,39 @@ class UberEstimate(object): products_response = client.get_products( self.start_latitude, self.start_longitude) - products = products_response.json.get('products') + products = products_response.json.get("products") for product in products: - self.products[product['product_id']] = product + self.products[product["product_id"]] = product if self.end_latitude is not None and self.end_longitude is not None: price_response = client.get_price_estimates( - self.start_latitude, - self.start_longitude, - self.end_latitude, - self.end_longitude) + self.start_latitude, self.start_longitude, + self.end_latitude, self.end_longitude) - prices = price_response.json.get('prices', []) + prices = price_response.json.get("prices", []) for price in prices: - product = self.products[price['product_id']] + product = self.products[price["product_id"]] + product["duration"] = price.get("duration", "0") + product["distance"] = price.get("distance", "0") price_details = product.get("price_details") - product["duration"] = price.get('duration', '0') - product["distance"] = price.get('distance', '0') - if price_details is not None: - price_details["estimate"] = price.get('estimate', - '0') - price_details["high_estimate"] = price.get('high_estimate', - '0') - price_details["low_estimate"] = price.get('low_estimate', - '0') - surge_multiplier = price.get('surge_multiplier', '0') - price_details["surge_multiplier"] = surge_multiplier + if product.get("price_details") is None: + price_details = {} + price_details["estimate"] = price.get("estimate", "0") + price_details["high_estimate"] = price.get("high_estimate", + "0") + price_details["low_estimate"] = price.get("low_estimate", "0") + price_details["currency_code"] = price.get("currency_code") + surge_multiplier = price.get("surge_multiplier", "0") + price_details["surge_multiplier"] = surge_multiplier + product["price_details"] = price_details estimate_response = client.get_pickup_time_estimates( self.start_latitude, self.start_longitude) - estimates = estimate_response.json.get('times') + estimates = estimate_response.json.get("times") for estimate in estimates: - self.products[estimate['product_id']][ - "time_estimate_seconds"] = estimate.get('estimate', '0') + self.products[estimate["product_id"]][ + "time_estimate_seconds"] = estimate.get("estimate", "0") diff --git a/homeassistant/components/sensor/wink.py b/homeassistant/components/sensor/wink.py index 31bfba7b3bd..2b081a1934f 100644 --- a/homeassistant/components/sensor/wink.py +++ b/homeassistant/components/sensor/wink.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.components.wink import WinkDevice from homeassistant.loader import get_component -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] SENSOR_TYPES = ['temperature', 'humidity'] diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index ddfbc68d974..3407838899e 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -18,8 +18,7 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['xmltodict'] +REQUIREMENTS = ['xmltodict==0.10.2'] # Sensor types are defined like so: SENSOR_TYPES = { diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py new file mode 100644 index 00000000000..ffeb09dc92d --- /dev/null +++ b/homeassistant/components/sensor/yweather.py @@ -0,0 +1,189 @@ +""" +Support for the Yahoo! Weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.yweather/ +""" +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.const import (CONF_PLATFORM, TEMP_CELSIUS, + CONF_MONITORED_CONDITIONS, STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ["yahooweather==0.4"] + +SENSOR_TYPES = { + 'weather_current': ['Current', None], + 'weather': ['Condition', None], + 'temperature': ['Temperature', "temperature"], + 'temp_min': ['Temperature', "temperature"], + 'temp_max': ['Temperature', "temperature"], + 'wind_speed': ['Wind speed', "speed"], + 'humidity': ['Humidity', "%"], + 'pressure': ['Pressure', "pressure"], + 'visibility': ['Visibility', "distance"], +} + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "yweather", + vol.Optional("woeid"): vol.Coerce(str), + vol.Optional("forecast"): vol.Coerce(int), + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + [vol.In(SENSOR_TYPES.keys())], +}) + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Yahoo! weather sensor.""" + from yahooweather import get_woeid, UNIT_C, UNIT_F + + unit = hass.config.temperature_unit + woeid = config.get("woeid", None) + forecast = config.get("forecast", 0) + + # convert unit + yunit = UNIT_C if unit == TEMP_CELSIUS else UNIT_F + + # for print HA style temp + SENSOR_TYPES["temperature"][1] = unit + SENSOR_TYPES["temp_min"][1] = unit + SENSOR_TYPES["temp_max"][1] = unit + + # if not exists a customer woeid / calc from HA + if woeid is None: + woeid = get_woeid(hass.config.latitude, hass.config.longitude) + # receive a error? + if woeid is None: + _LOGGER.critical("Can't retrieve WOEID from yahoo!") + return False + + # create api object + yahoo_api = YahooWeatherData(woeid, yunit) + + # if update is false, it will never work... + if not yahoo_api.update(): + _LOGGER.critical("Can't retrieve weather data from yahoo!") + return False + + # check if forecast support by API + if forecast >= len(yahoo_api.yahoo.Forecast): + _LOGGER.error("Yahoo! only support %d days forcast!", + len(yahoo_api.yahoo.Forecast)) + return False + + dev = [] + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append(YahooWeatherSensor(yahoo_api, forecast, variable)) + + add_devices(dev) + + +# pylint: disable=too-many-instance-attributes +class YahooWeatherSensor(Entity): + """Implementation of an Yahoo! weather sensor.""" + + def __init__(self, weather_data, forecast, sensor_type): + """Initialize the sensor.""" + self._client = 'Weather' + self._name = SENSOR_TYPES[sensor_type][0] + self._type = sensor_type + self._state = STATE_UNKNOWN + self._unit = SENSOR_TYPES[sensor_type][1] + self._data = weather_data + self._forecast = forecast + self._code = None + + # update data + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self._client, self._name) + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._data.yahoo.Units.get(self._unit, self._unit) + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + if self._code is None or "weather" not in self._type: + return None + + return self._data.yahoo.getWeatherImage(self._code) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'about': "Weather forecast delivered by Yahoo! Inc. are provided" + " free of charge for use by individuals and non-profit" + " organizations for personal, non-commercial uses." + } + + def update(self): + """Get the latest data from Yahoo! and updates the states.""" + self._data.update() + + # default code for weather image + self._code = self._data.yahoo.Now["code"] + + # read data + if self._type == "weather_current": + self._state = self._data.yahoo.Now["text"] + elif self._type == "weather": + self._code = self._data.yahoo.Forecast[self._forecast]["code"] + self._state = self._data.yahoo.Forecast[self._forecast]["text"] + elif self._type == "temperature": + self._state = self._data.yahoo.Now["temp"] + elif self._type == "temp_min": + self._code = self._data.yahoo.Forecast[self._forecast]["code"] + self._state = self._data.yahoo.Forecast[self._forecast]["low"] + elif self._type == "temp_max": + self._code = self._data.yahoo.Forecast[self._forecast]["code"] + self._state = self._data.yahoo.Forecast[self._forecast]["high"] + elif self._type == "wind_speed": + self._state = self._data.yahoo.Wind["speed"] + elif self._type == "humidity": + self._state = self._data.yahoo.Atmosphere["humidity"] + elif self._type == "pressure": + self._state = self._data.yahoo.Atmosphere["pressure"] + elif self._type == "visibility": + self._state = self._data.yahoo.Atmosphere["visibility"] + + +# pylint: disable=too-few-public-methods +class YahooWeatherData(object): + """Handle yahoo api object and limit updates.""" + + def __init__(self, woeid, temp_unit): + """Initialize the data object.""" + from yahooweather import YahooWeather + + # init yahoo api object + self._yahoo = YahooWeather(woeid, temp_unit) + + @property + def yahoo(self): + """Return yahoo api object.""" + return self._yahoo + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from yahoo. True is success.""" + return self._yahoo.updateWeather() diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index 849e68d0a7f..8748ef86572 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -66,7 +66,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): value.type == zwave.TYPE_DECIMAL): add_devices([ZWaveMultilevelSensor(value)]) - elif value.command_class == zwave.COMMAND_CLASS_ALARM: + elif (value.command_class == zwave.COMMAND_CLASS_ALARM or + value.command_class == zwave.COMMAND_CLASS_SENSOR_ALARM): add_devices([ZWaveAlarmSensor(value)]) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml new file mode 100644 index 00000000000..80dcc0f54be --- /dev/null +++ b/homeassistant/components/services.yaml @@ -0,0 +1,33 @@ +persistent_notification: + create: + description: Show a notification in the frontend + + fields: + message: + description: Message body of the notification. [Templates accepted] + example: Please check your configuration.yaml. + + title: + description: Optional title for your notification. [Optional, Templates accepted] + example: Test notification + + notification_id: + description: Target ID of the notification, will replace a notification with the same Id. [Optional] + example: 1234 + +homematic: + virtualkey: + description: Press a virtual key from CCU/Homegear or simulate keypress + + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote + example: BidCoS-RF + + channel: + description: Channel for calling a keypress + example: 1 + + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT + example: PRESS_LONG diff --git a/homeassistant/components/statsd.py b/homeassistant/components/statsd.py index 8c0be48da35..f2db14cceb7 100644 --- a/homeassistant/components/statsd.py +++ b/homeassistant/components/statsd.py @@ -20,17 +20,17 @@ DEFAULT_PORT = 8125 DEFAULT_PREFIX = 'hass' DEFAULT_RATE = 1 -REQUIREMENTS = ['python-statsd==1.7.2'] +REQUIREMENTS = ['statsd==3.2.1'] CONF_HOST = 'host' CONF_PORT = 'port' CONF_PREFIX = 'prefix' CONF_RATE = 'rate' +CONF_ATTR = 'log_attributes' def setup(hass, config): """Setup the StatsD component.""" - from statsd.compat import NUM_TYPES import statsd conf = config[DOMAIN] @@ -39,16 +39,14 @@ def setup(hass, config): port = util.convert(conf.get(CONF_PORT), int, DEFAULT_PORT) sample_rate = util.convert(conf.get(CONF_RATE), int, DEFAULT_RATE) prefix = util.convert(conf.get(CONF_PREFIX), str, DEFAULT_PREFIX) + show_attribute_flag = conf.get(CONF_ATTR, False) - statsd_connection = statsd.Connection( + statsd_client = statsd.StatsClient( host=host, port=port, - sample_rate=sample_rate, - disabled=False + prefix=prefix ) - meter = statsd.Gauge(prefix, statsd_connection) - def statsd_event_listener(event): """Listen for new messages on the bus and sends them to StatsD.""" state = event.data.get('new_state') @@ -61,11 +59,28 @@ def setup(hass, config): except ValueError: return - if not isinstance(_state, NUM_TYPES): - return + states = dict(state.attributes) _LOGGER.debug('Sending %s.%s', state.entity_id, _state) - meter.send(state.entity_id, _state) + + if show_attribute_flag is True: + statsd_client.gauge( + "%s.state" % state.entity_id, + _state, + sample_rate + ) + + # Send attribute values + for key, value in states.items(): + if isinstance(value, (float, int)): + stat = "%s.%s" % (state.entity_id, key.replace(' ', '_')) + statsd_client.gauge(stat, value, sample_rate) + + else: + statsd_client.gauge(state.entity_id, _state, sample_rate) + + # Increment the count + statsd_client.incr(state.entity_id, rate=sample_rate) hass.bus.listen(EVENT_STATE_CHANGED, statsd_event_listener) diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index 7a1f3498f18..5fb7fad909d 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -133,7 +133,7 @@ class AcerSwitch(SwitchDevice): else: self._available = False - for key in self._attributes.keys(): + for key in self._attributes: msg = CMD_DICT.get(key, None) if msg: awns = self._write_read_format(msg) diff --git a/homeassistant/components/switch/demo.py b/homeassistant/components/switch/demo.py index fad7b5dfaf1..f867473d441 100644 --- a/homeassistant/components/switch/demo.py +++ b/homeassistant/components/switch/demo.py @@ -18,10 +18,10 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class DemoSwitch(SwitchDevice): - """represenation of a demo switch.""" + """Representation of a demo switch.""" def __init__(self, name, state, icon, assumed): - """Initialize the Deom switch.""" + """Initialize the Demo switch.""" self._name = name or DEVICE_DEFAULT_NAME self._state = state self._icon = icon diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 9a4580d6110..d222ad1764b 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -4,7 +4,7 @@ Flux for Home-Assistant. The idea was taken from https://github.com/KpaBap/hue-flux/ For more details about this component, please refer to the documentation at -https://home-assistant.io/components/switch/flux/ +https://home-assistant.io/components/switch.flux/ """ from datetime import time import logging @@ -62,7 +62,7 @@ def set_lights_xy(hass, lights, x_val, y_val, brightness): # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the demo switches.""" + """Setup the Flux switches.""" name = config.get(CONF_NAME) lights = config.get(CONF_LIGHTS) start_time = config.get(CONF_START_TIME) @@ -85,7 +85,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # pylint: disable=too-many-instance-attributes class FluxSwitch(SwitchDevice): - """Flux switch.""" + """Representation of a Flux switch.""" # pylint: disable=too-many-arguments def __init__(self, name, hass, state, lights, start_time, stop_time, diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py new file mode 100644 index 00000000000..4ed24e68d4d --- /dev/null +++ b/homeassistant/components/switch/knx.py @@ -0,0 +1,42 @@ +""" +Support KNX switching actuators. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.knx/ +""" +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.knx import ( + KNXConfig, KNXGroupAddress) + +DEPENDENCIES = ["knx"] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Setup the KNX switch platform.""" + add_entities([ + KNXSwitch(hass, KNXConfig(config)) + ]) + + +class KNXSwitch(KNXGroupAddress, SwitchDevice): + """Representation of a KNX switch device.""" + + def turn_on(self, **kwargs): + """Turn the switch on. + + This sends a value 0 to the group address of the device + """ + self.group_write(1) + self._state = [1] + if not self.should_poll: + self.update_ha_state() + + def turn_off(self, **kwargs): + """Turn the switch off. + + This sends a value 1 to the group address of the device + """ + self.group_write(0) + self._state = [0] + if not self.should_poll: + self.update_ha_state() diff --git a/homeassistant/components/switch/mystrom.py b/homeassistant/components/switch/mystrom.py index c2e819bd223..867392e4661 100644 --- a/homeassistant/components/switch/mystrom.py +++ b/homeassistant/components/switch/mystrom.py @@ -6,36 +6,38 @@ https://home-assistant.io/components/switch.mystrom/ """ import logging -import requests +import voluptuous as vol +from homeassistant.const import (CONF_PLATFORM, CONF_NAME, CONF_HOST) +import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice +REQUIREMENTS = ['python-mystrom==0.3.6'] + DEFAULT_NAME = 'myStrom Switch' _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'mystrom', + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return myStrom switch.""" - host = config.get('host') + from pymystrom import MyStromPlug, exceptions - if host is None: - _LOGGER.error('Missing required variable: host') - return False - - resource = 'http://{}'.format(host) + host = config.get(CONF_HOST) try: - requests.get(resource, timeout=10) - except requests.exceptions.ConnectionError: - _LOGGER.error("No route to device %s. " - "Please check the IP address in the configuration file", - host) + MyStromPlug(host).get_status() + except exceptions.MyStromConnectionError: + _LOGGER.error("No route to device '%s'", host) return False - add_devices([MyStromSwitch( - config.get('name', DEFAULT_NAME), - resource)]) + add_devices([MyStromSwitch(config.get('name', DEFAULT_NAME), host)]) class MyStromSwitch(SwitchDevice): @@ -43,10 +45,13 @@ class MyStromSwitch(SwitchDevice): def __init__(self, name, resource): """Initialize the myStrom switch.""" - self._state = False + from pymystrom import MyStromPlug + self._name = name self._resource = resource - self.consumption = 0 + self.data = {} + self.plug = MyStromPlug(self._resource) + self.update() @property def name(self): @@ -56,45 +61,37 @@ class MyStromSwitch(SwitchDevice): @property def is_on(self): """Return true if switch is on.""" - return self._state + return bool(self.data['relay']) @property def current_power_mwh(self): - """Return the urrent power consumption in mWh.""" - return self.consumption + """Return the current power consumption in mWh.""" + return round(self.data['power'], 2) def turn_on(self, **kwargs): """Turn the switch on.""" + from pymystrom import exceptions try: - request = requests.get('{}/relay'.format(self._resource), - params={'state': '1'}, - timeout=10) - if request.status_code == 200: - self._state = True - except requests.exceptions.ConnectionError: - _LOGGER.error("Can't turn on %s. Is device offline?", + self.plug.set_relay_on() + except exceptions.MyStromConnectionError: + _LOGGER.error("No route to device '%s'. Is device offline?", self._resource) def turn_off(self, **kwargs): """Turn the switch off.""" + from pymystrom import exceptions try: - request = requests.get('{}/relay'.format(self._resource), - params={'state': '0'}, - timeout=10) - if request.status_code == 200: - self._state = False - except requests.exceptions.ConnectionError: - _LOGGER.error("Can't turn on %s. Is device offline?", + self.plug.set_relay_off() + except exceptions.MyStromConnectionError: + _LOGGER.error("No route to device '%s'. Is device offline?", self._resource) def update(self): - """Get the latest data from REST API and update the state.""" + """Get the latest data from the device and update the data.""" + from pymystrom import exceptions try: - request = requests.get('{}/report'.format(self._resource), - timeout=10) - data = request.json() - self._state = bool(data['relay']) - self.consumption = data['power'] - except requests.exceptions.ConnectionError: + self.data = self.plug.get_status() + except exceptions.MyStromConnectionError: + self.data = {'power': 0, 'relay': False} _LOGGER.error("No route to device '%s'. Is device offline?", self._resource) diff --git a/homeassistant/components/switch/thinkingcleaner.py b/homeassistant/components/switch/thinkingcleaner.py index 46adc5a7052..f577b29d2d5 100644 --- a/homeassistant/components/switch/thinkingcleaner.py +++ b/homeassistant/components/switch/thinkingcleaner.py @@ -47,7 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for device in devices: - for type_name in SWITCH_TYPES.keys(): + for type_name in SWITCH_TYPES: dev.append(ThinkingCleanerSwitch(device, type_name, update_devices)) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py new file mode 100644 index 00000000000..a1de3621b9a --- /dev/null +++ b/homeassistant/components/switch/tplink.py @@ -0,0 +1,52 @@ +""" +Support for TPLink HS100/HS110 smart switch. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.tplink/ +""" +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ( + CONF_HOST, CONF_NAME) + +# constants +DEVICE_DEFAULT_NAME = 'HS100' +REQUIREMENTS = ['https://github.com/gadgetreactor/pyHS100/archive/' + 'master.zip#pyHS100==0.1.2'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup the TPLink switch platform.""" + from pyHS100.pyHS100 import SmartPlug + host = config.get(CONF_HOST) + name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + + add_devices_callback([SmartPlugSwitch(SmartPlug(host), + name)]) + + +class SmartPlugSwitch(SwitchDevice): + """Representation of a TPLink Smart Plug switch.""" + + def __init__(self, smartplug, name): + """Initialize the switch.""" + self.smartplug = smartplug + self._name = name + + @property + def name(self): + """Return the name of the Smart Plug, if any.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self.smartplug.state == 'ON' + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.smartplug.state = 'ON' + + def turn_off(self): + """Turn the switch off.""" + self.smartplug.state = 'OFF' diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py index f2943058452..1bd0a46fb78 100644 --- a/homeassistant/components/switch/verisure.py +++ b/homeassistant/components/switch/verisure.py @@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Verisure platform.""" + """Setup the Verisure switch platform.""" if not int(hub.config.get('smartplugs', '1')): return False diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index bb2183677ea..4f18d7d9456 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -10,7 +10,7 @@ from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers.entity import ToggleEntity -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/switch/zigbee.py b/homeassistant/components/switch/zigbee.py index 1c88b4f78a8..4588be139a2 100644 --- a/homeassistant/components/switch/zigbee.py +++ b/homeassistant/components/switch/zigbee.py @@ -12,7 +12,7 @@ DEPENDENCIES = ["zigbee"] def setup_platform(hass, config, add_entities, discovery_info=None): - """Create and add an entity based on the configuration.""" + """Setup the ZigBee switch platform.""" add_entities([ ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config)) ]) diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index f59d830f4c0..c1ad3d36f2d 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -1,5 +1,5 @@ """ -Zwave platform that handles simple binary switches. +Z-Wave platform that handles simple binary switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.zwave/ @@ -12,7 +12,7 @@ from homeassistant.components import zwave # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Find and return Z-Wave switches.""" + """Setup the Z-Wave platform.""" if discovery_info is None or zwave.NETWORK is None: return diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index 8d811c3a5cc..535a98567d8 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -33,6 +33,7 @@ SERVICE_SET_HVAC_MODE = "set_hvac_mode" STATE_HEAT = "heat" STATE_COOL = "cool" STATE_IDLE = "idle" +STATE_AUTO = 'auto' ATTR_CURRENT_TEMPERATURE = "current_temperature" ATTR_AWAY_MODE = "away_mode" diff --git a/homeassistant/components/thermostat/radiotherm.py b/homeassistant/components/thermostat/radiotherm.py index 963ef1a9a7f..c4031fde940 100644 --- a/homeassistant/components/thermostat/radiotherm.py +++ b/homeassistant/components/thermostat/radiotherm.py @@ -9,7 +9,8 @@ import logging from urllib.error import URLError from homeassistant.components.thermostat import ( - STATE_COOL, STATE_HEAT, STATE_IDLE, ThermostatDevice) + STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_OFF, + ThermostatDevice) from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT REQUIREMENTS = ['radiotherm==1.2'] @@ -122,3 +123,14 @@ class RadioThermostat(ThermostatDevice): now = datetime.datetime.now() self.device.time = {'day': now.weekday(), 'hour': now.hour, 'minute': now.minute} + + def set_hvac_mode(self, mode): + """Set HVAC mode (auto, cool, heat, off).""" + if mode == STATE_OFF: + self.device.tmode = 0 + elif mode == STATE_AUTO: + self.device.tmode = 3 + elif mode == STATE_COOL: + self.device.t_cool = self._target_temperature + elif mode == STATE_HEAT: + self.device.t_heat = self._target_temperature diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index de65d3d664a..7be4c4d5cfe 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity DOMAIN = "wink" -REQUIREMENTS = ['python-wink==0.7.8', 'pubnub==3.7.6'] +REQUIREMENTS = ['python-wink==0.7.10', 'pubnub==3.8.2'] SUBSCRIPTION_HANDLER = None CHANNELS = [] @@ -29,7 +29,8 @@ def setup(hass, config): from pubnub import Pubnub pywink.set_bearer_token(config[DOMAIN][CONF_ACCESS_TOKEN]) global SUBSCRIPTION_HANDLER - SUBSCRIPTION_HANDLER = Pubnub("N/A", pywink.get_subscription_key()) + SUBSCRIPTION_HANDLER = Pubnub("N/A", pywink.get_subscription_key(), + ssl_on=True) SUBSCRIPTION_HANDLER.set_heartbeat(120) # Load components for the devices in the Wink that we support @@ -58,7 +59,7 @@ class WinkDevice(Entity): self.wink = wink self._battery = self.wink.battery_level if self.wink.pubnub_channel in CHANNELS: - pubnub = Pubnub("N/A", self.wink.pubnub_key) + pubnub = Pubnub("N/A", self.wink.pubnub_key, ssl_on=True) pubnub.set_heartbeat(120) pubnub.subscribe(self.wink.pubnub_channel, self._pubnub_update, diff --git a/homeassistant/components/zeroconf.py b/homeassistant/components/zeroconf.py index 0d9cd02aa1c..3974f7b3220 100644 --- a/homeassistant/components/zeroconf.py +++ b/homeassistant/components/zeroconf.py @@ -11,7 +11,7 @@ import socket from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -REQUIREMENTS = ["zeroconf==0.17.5"] +REQUIREMENTS = ["zeroconf==0.17.6"] DEPENDENCIES = ["api"] diff --git a/homeassistant/components/zwave.py b/homeassistant/components/zwave.py index f8959f33033..f5d6e597406 100644 --- a/homeassistant/components/zwave.py +++ b/homeassistant/components/zwave.py @@ -51,8 +51,10 @@ COMMAND_CLASS_DOOR_LOCK = 98 COMMAND_CLASS_THERMOSTAT_SETPOINT = 67 COMMAND_CLASS_THERMOSTAT_FAN_MODE = 68 COMMAND_CLASS_BATTERY = 128 +COMMAND_CLASS_SENSOR_ALARM = 156 GENERIC_COMMAND_CLASS_WHATEVER = None +GENERIC_COMMAND_CLASS_NOTIFICATION_SENSOR = 7 GENERIC_COMMAND_CLASS_MULTILEVEL_SWITCH = 17 GENERIC_COMMAND_CLASS_BINARY_SWITCH = 16 GENERIC_COMMAND_CLASS_ENTRY_CONTROL = 64 @@ -92,7 +94,8 @@ DISCOVERY_COMPONENTS = [ [SPECIFIC_DEVICE_CLASS_WHATEVER], [COMMAND_CLASS_SENSOR_MULTILEVEL, COMMAND_CLASS_METER, - COMMAND_CLASS_ALARM], + COMMAND_CLASS_ALARM, + COMMAND_CLASS_SENSOR_ALARM], TYPE_WHATEVER, GENRE_USER), ('light', @@ -110,7 +113,8 @@ DISCOVERY_COMPONENTS = [ GENRE_USER), ('binary_sensor', [GENERIC_COMMAND_CLASS_BINARY_SENSOR, - GENERIC_COMMAND_CLASS_MULTILEVEL_SENSOR], + GENERIC_COMMAND_CLASS_MULTILEVEL_SENSOR, + GENERIC_COMMAND_CLASS_NOTIFICATION_SENSOR], [SPECIFIC_DEVICE_CLASS_WHATEVER], [COMMAND_CLASS_SENSOR_BINARY], TYPE_BOOL, @@ -353,11 +357,11 @@ def setup(hass, config): def add_node(service): """Switch into inclusion mode.""" - NETWORK.controller.begin_command_add_device() + NETWORK.controller.add_node() def remove_node(service): """Switch into exclusion mode.""" - NETWORK.controller.begin_command_remove_device() + NETWORK.controller.remove_node() def heal_network(service): """Heal the network.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 55e97f67c7e..bf0cbdba23b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -65,7 +65,7 @@ CORE_CONFIG_SCHEMA = vol.Schema({ CONF_NAME: vol.Coerce(str), CONF_LATITUDE: cv.latitude, CONF_LONGITUDE: cv.longitude, - CONF_ELEVATION: vol.Coerce(float), + CONF_ELEVATION: vol.Coerce(int), CONF_TEMPERATURE_UNIT: cv.temperature_unit, CONF_TIME_ZONE: cv.time_zone, vol.Required(CONF_CUSTOMIZE, diff --git a/homeassistant/const.py b/homeassistant/const.py index 5c9d9067225..50718be7804 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" -__version__ = "0.23.1" +__version__ = "0.24.0.dev0" REQUIRED_PYTHON_VER = (3, 4) PLATFORM_FORMAT = '{}.{}' diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py new file mode 100644 index 00000000000..a0e16efc7d1 --- /dev/null +++ b/homeassistant/scripts/__init__.py @@ -0,0 +1,22 @@ +"""Home Assistant command line scripts.""" +import importlib +import os + + +def run(args): + """Run a script.""" + scripts = [fil[:-3] for fil in os.listdir(os.path.dirname(__file__)) + if fil.endswith('.py') and fil != '__init__.py'] + + if not args: + print('Please specify a script to run.') + print('Available scripts:', ', '.join(scripts)) + return 1 + + if args[0] not in scripts: + print('Invalid script specified.') + print('Available scripts:', ', '.join(scripts)) + return 1 + + script = importlib.import_module('homeassistant.scripts.' + args[0]) + return script.run(args[1:]) diff --git a/homeassistant/scripts/db_migrator.py b/homeassistant/scripts/db_migrator.py new file mode 100644 index 00000000000..dfe31be7684 --- /dev/null +++ b/homeassistant/scripts/db_migrator.py @@ -0,0 +1,191 @@ +"""Script to convert an old-format home-assistant.db to a new format one.""" + +import argparse +import os.path +import sqlite3 +import sys +try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker +except ImportError: + print('Fatal Error: SQLAlchemy is missing. Install it with ' + '"pip3 install SQLAlchemy" before running this script') + sys.exit(1) +from homeassistant.components.recorder import models +import homeassistant.config as config_util +import homeassistant.util.dt as dt_util + + +def ts_to_dt(timestamp): + """Turn a datetime into an integer for in the DB.""" + if timestamp is None: + return None + return dt_util.utc_from_timestamp(timestamp) + + +# Based on code at +# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console +# pylint: disable=too-many-arguments +def print_progress(iteration, total, prefix='', suffix='', decimals=2, + bar_length=68): + """Print progress bar. + + Call in a loop to create terminal progress bar + @params: + iteration - Required : current iteration (Int) + total - Required : total iterations (Int) + prefix - Optional : prefix string (Str) + suffix - Optional : suffix string (Str) + decimals - Optional : number of decimals in percent complete (Int) + barLength - Optional : character length of bar (Int) + """ + filled_length = int(round(bar_length * iteration / float(total))) + percents = round(100.00 * (iteration / float(total)), decimals) + line = '#' * filled_length + '-' * (bar_length - filled_length) + sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line, + percents, '%', suffix)) + sys.stdout.flush() + if iteration == total: + print("\n") + + +def run(args): + """The actual script body.""" + # pylint: disable=too-many-locals,invalid-name,too-many-statements + parser = argparse.ArgumentParser( + description="Migrate legacy DB to SQLAlchemy format.") + parser.add_argument( + '-c', '--config', + metavar='path_to_config_dir', + default=config_util.get_default_config_dir(), + help="Directory that contains the Home Assistant configuration") + parser.add_argument( + '-a', '--append', + action='store_true', + default=False, + help="Append to existing new format SQLite database") + parser.add_argument( + '--uri', + type=str, + help="Connect to URI and import (implies --append)" + "eg: mysql://localhost/homeassistant") + parser.add_argument( + '--script', + choices=['db_migrator']) + + args = parser.parse_args() + + config_dir = os.path.join(os.getcwd(), args.config) + + # Test if configuration directory exists + if not os.path.isdir(config_dir): + if config_dir != config_util.get_default_config_dir(): + print(('Fatal Error: Specified configuration directory does ' + 'not exist {} ').format(config_dir)) + return 1 + else: + config_dir = config_util.get_default_config_dir() + + src_db = '{}/home-assistant.db'.format(config_dir) + dst_db = '{}/home-assistant_v2.db'.format(config_dir) + + if not os.path.exists(src_db): + print("Fatal Error: Old format database '{}' does not exist".format( + src_db)) + return 1 + if not args.uri and (os.path.exists(dst_db) and not args.append): + print("Fatal Error: New format database '{}' exists already - " + "Remove it or use --append".format(dst_db)) + print("Note: --append must maintain an ID mapping and is much slower" + "and requires sufficient memory to track all event IDs") + return 1 + + conn = sqlite3.connect(src_db) + uri = args.uri or "sqlite:///{}".format(dst_db) + + engine = create_engine(uri, echo=False) + models.Base.metadata.create_all(engine) + session_factory = sessionmaker(bind=engine) + session = session_factory() + + append = args.append or args.uri + + c = conn.cursor() + c.execute("SELECT count(*) FROM recorder_runs") + num_rows = c.fetchone()[0] + print("Converting {} recorder_runs".format(num_rows)) + c.close() + + c = conn.cursor() + n = 0 + for row in c.execute("SELECT * FROM recorder_runs"): + n += 1 + session.add(models.RecorderRuns( + start=ts_to_dt(row[1]), + end=ts_to_dt(row[2]), + closed_incorrect=row[3], + created=ts_to_dt(row[4]) + )) + if n % 1000 == 0: + session.commit() + print_progress(n, num_rows) + print_progress(n, num_rows) + session.commit() + c.close() + + c = conn.cursor() + c.execute("SELECT count(*) FROM events") + num_rows = c.fetchone()[0] + print("Converting {} events".format(num_rows)) + c.close() + + id_mapping = {} + + c = conn.cursor() + n = 0 + for row in c.execute("SELECT * FROM events"): + n += 1 + o = models.Events( + event_type=row[1], + event_data=row[2], + origin=row[3], + created=ts_to_dt(row[4]), + time_fired=ts_to_dt(row[5]), + ) + session.add(o) + if append: + session.flush() + id_mapping[row[0]] = o.event_id + if n % 1000 == 0: + session.commit() + print_progress(n, num_rows) + print_progress(n, num_rows) + session.commit() + c.close() + + c = conn.cursor() + c.execute("SELECT count(*) FROM states") + num_rows = c.fetchone()[0] + print("Converting {} states".format(num_rows)) + c.close() + + c = conn.cursor() + n = 0 + for row in c.execute("SELECT * FROM states"): + n += 1 + session.add(models.States( + entity_id=row[1], + state=row[2], + attributes=row[3], + last_changed=ts_to_dt(row[4]), + last_updated=ts_to_dt(row[5]), + event_id=id_mapping.get(row[6], row[6]), + domain=row[7] + )) + if n % 1000 == 0: + session.commit() + print_progress(n, num_rows) + print_progress(n, num_rows) + session.commit() + c.close() + return 0 diff --git a/homeassistant/scripts/macos.py b/homeassistant/scripts/macos.py new file mode 100644 index 00000000000..e16d1f6c272 --- /dev/null +++ b/homeassistant/scripts/macos.py @@ -0,0 +1,64 @@ +"""Script to install/uninstall HA into OS X.""" +import os +import time + + +def install_osx(): + """Setup to run via launchd on OS X.""" + with os.popen('which hass') as inp: + hass_path = inp.read().strip() + + with os.popen('whoami') as inp: + user = inp.read().strip() + + cwd = os.path.dirname(__file__) + template_path = os.path.join(cwd, 'startup', 'launchd.plist') + + with open(template_path, 'r', encoding='utf-8') as inp: + plist = inp.read() + + plist = plist.replace("$HASS_PATH$", hass_path) + plist = plist.replace("$USER$", user) + + path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") + + try: + with open(path, 'w', encoding='utf-8') as outp: + outp.write(plist) + except IOError as err: + print('Unable to write to ' + path, err) + return + + os.popen('launchctl load -w -F ' + path) + + print("Home Assistant has been installed. \ + Open it here: http://localhost:8123") + + +def uninstall_osx(): + """Unload from launchd on OS X.""" + path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") + os.popen('launchctl unload ' + path) + + print("Home Assistant has been uninstalled.") + + +def run(args): + """Handle OSX commandline script.""" + commands = 'install', 'uninstall', 'restart' + if not args or args[0] not in commands: + print('Invalid command. Available commands:', ', '.join(commands)) + return 1 + + if args[0] == 'install': + install_osx() + return 0 + elif args[0] == 'uninstall': + uninstall_osx() + return 0 + elif args[0] == 'restart': + uninstall_osx() + # A small delay is needed on some systems to let the unload finish. + time.sleep(0.5) + install_osx() + return 0 diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 1f702a50193..dd504b57065 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -72,7 +72,6 @@ def color_RGB_to_xy(R, G, B): # taken from # https://github.com/benknight/hue-python-rgb-converter/blob/master/rgb_cie.py # Copyright (c) 2014 Benjamin Knight / MIT License. -# pylint: disable=bad-builtin def color_xy_brightness_to_RGB(vX, vY, brightness): """Convert from XYZ to RGB.""" brightness /= 255. @@ -112,6 +111,39 @@ def color_xy_brightness_to_RGB(vX, vY, brightness): return (r, g, b) +def _match_max_scale(input_colors, output_colors): + """Match the maximum value of the output to the input.""" + max_in = max(input_colors) + max_out = max(output_colors) + if max_out == 0: + factor = 0 + else: + factor = max_in / max_out + return tuple(int(round(i * factor)) for i in output_colors) + + +def color_rgb_to_rgbw(r, g, b): + """Convert an rgb color to an rgbw representation.""" + # Calculate the white channel as the minimum of input rgb channels. + # Subtract the white portion from the remaining rgb channels. + w = min(r, g, b) + rgbw = (r - w, g - w, b - w, w) + + # Match the output maximum value to the input. This ensures the full + # channel range is used. + return _match_max_scale((r, g, b), rgbw) + + +def color_rgbw_to_rgb(r, g, b, w): + """Convert an rgbw color to an rgb representation.""" + # Add the white channel back into the rgb channels. + rgb = (r + w, g + w, b + w) + + # Match the output maximum value to the input. This ensures the the + # output doesn't overflow. + return _match_max_scale((r, g, b, w), rgb) + + def rgb_hex_to_rgb_list(hex_string): """Return an RGB color value list from a hex color string.""" return [int(hex_string[i:i + len(hex_string) // 3], 16) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index a875087fed6..b8b7a691859 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -92,8 +92,7 @@ def start_of_local_day(dt_or_d=None): 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) + return DEFAULT_TIME_ZONE.localize(dt.datetime.combine(dt_or_d, dt.time())) # Copyright (c) Django Software Foundation and individual contributors. diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 0e6ec01f26e..feafdc2c6ff 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -14,6 +14,7 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) _SECRET_NAMESPACE = 'homeassistant' +_SECRET_YAML = 'secrets.yaml' # pylint: disable=too-many-ancestors @@ -51,7 +52,7 @@ def _include_yaml(loader, node): def _include_dir_named_yaml(loader, node): - """Load multiple files from dir as a dict.""" + """Load multiple files from directory as a dictionary.""" mapping = OrderedDict() files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') for fname in glob.glob(files): @@ -61,10 +62,12 @@ def _include_dir_named_yaml(loader, node): def _include_dir_merge_named_yaml(loader, node): - """Load multiple files from dir as a merged dict.""" + """Load multiple files from directory as a merged dictionary.""" mapping = OrderedDict() files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') for fname in glob.glob(files): + if os.path.basename(fname) == _SECRET_YAML: + continue loaded_yaml = load_yaml(fname) if isinstance(loaded_yaml, dict): mapping.update(loaded_yaml) @@ -72,16 +75,19 @@ def _include_dir_merge_named_yaml(loader, node): def _include_dir_list_yaml(loader, node): - """Load multiple files from dir as a list.""" + """Load multiple files from directory as a list.""" files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') - return [load_yaml(f) for f in glob.glob(files)] + return [load_yaml(f) for f in glob.glob(files) + if os.path.basename(f) != _SECRET_YAML] def _include_dir_merge_list_yaml(loader, node): - """Load multiple files from dir as a merged list.""" + """Load multiple files from directory as a merged list.""" files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') merged_list = [] for fname in glob.glob(files): + if os.path.basename(fname) == _SECRET_YAML: + continue loaded_yaml = load_yaml(fname) if isinstance(loaded_yaml, list): merged_list.extend(loaded_yaml) @@ -89,7 +95,7 @@ def _include_dir_merge_list_yaml(loader, node): def _ordered_dict(loader, node): - """Load YAML mappings into an ordered dict to preserve key order.""" + """Load YAML mappings into an ordered dictionary to preserve key order.""" loader.flatten_mapping(node) nodes = loader.construct_pairs(node) @@ -127,11 +133,11 @@ def _env_var_yaml(loader, node): # pylint: disable=protected-access def _secret_yaml(loader, node): """Load secrets and embed it into the configuration YAML.""" - # Create secret cache on loader and load secret.yaml + # Create secret cache on loader and load secrets.yaml if not hasattr(loader, '_SECRET_CACHE'): loader._SECRET_CACHE = {} - secret_path = os.path.join(os.path.dirname(loader.name), 'secrets.yaml') + secret_path = os.path.join(os.path.dirname(loader.name), _SECRET_YAML) if secret_path not in loader._SECRET_CACHE: if os.path.isfile(secret_path): loader._SECRET_CACHE[secret_path] = load_yaml(secret_path) @@ -152,7 +158,13 @@ def _secret_yaml(loader, node): if secrets is not None and node.value in secrets: _LOGGER.debug('Secret %s retrieved from secrets.yaml.', node.value) return secrets[node.value] - elif keyring: + for sname, sdict in loader._SECRET_CACHE.items(): + if node.value in sdict: + _LOGGER.debug('Secret %s retrieved from secrets.yaml in other ' + 'folder %s', node.value, sname) + return sdict[node.value] + + if keyring: # do ome keyring stuff pwd = keyring.get_password(_SECRET_NAMESPACE, node.value) if pwd: diff --git a/requirements_all.txt b/requirements_all.txt index 9c17a02cd03..ff8783a54b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,7 @@ pytz>=2016.4 pip>=7.0.0 jinja2>=2.8 voluptuous==0.8.9 +sqlalchemy==1.0.14 # homeassistant.components.isy994 PyISY==1.0.6 @@ -30,9 +31,6 @@ apcaccess==0.0.4 # homeassistant.components.sun astral==1.2 -# homeassistant.components.sensor.swiss_hydrological_data -beautifulsoup4==4.4.1 - # homeassistant.components.light.blinksticklight blinkstick==1.1.7 @@ -79,13 +77,13 @@ fixerio==0.1.1 freesms==0.1.0 # homeassistant.components.conversation -fuzzywuzzy==0.10.0 +fuzzywuzzy==0.11.0 # homeassistant.components.notify.gntp gntp==1.0.3 # homeassistant.components.sensor.google_travel_time -googlemaps==2.4.3 +googlemaps==2.4.4 # homeassistant.components.mqtt.server hbmqtt==0.7.1 @@ -114,10 +112,10 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1 # homeassistant.components.media_player.braviatv -https://github.com/aparraga/braviarc/archive/0.3.2.zip#braviarc==0.3.2 +https://github.com/aparraga/braviarc/archive/0.3.3.zip#braviarc==0.3.3 # homeassistant.components.media_player.roku -https://github.com/bah2830/python-roku/archive/3.1.1.zip#python-roku==3.1.1 +https://github.com/bah2830/python-roku/archive/3.1.2.zip#roku==3.1.2 # homeassistant.components.modbus https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 @@ -128,6 +126,9 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. # homeassistant.components.device_tracker.fritz # https://github.com/deisi/fritzconnection/archive/b5c14515e1c8e2652b06b6316a7f3913df942841.zip#fritzconnection==0.4.6 +# homeassistant.components.switch.tplink +https://github.com/gadgetreactor/pyHS100/archive/master.zip#pyHS100==0.1.2 + # homeassistant.components.netatmo https://github.com/jabesq/netatmo-api-python/archive/v0.5.0.zip#lnetatmo==0.5.0 @@ -140,6 +141,10 @@ https://github.com/kellerza/pyqwikswitch/archive/v0.4.zip#pyqwikswitch==0.4 # homeassistant.components.ecobee https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6 +# homeassistant.components.joaoapps_join +# homeassistant.components.notify.joaoapps_join +https://github.com/nkgilley/python-join-api/archive/3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1 + # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 @@ -158,6 +163,9 @@ https://github.com/theolind/pymysensors/archive/cc5d0b325e13c2b623fa934f69eea7cd # homeassistant.components.notify.googlevoice https://github.com/w1ll1am23/pygooglevoice-sms/archive/7c5ee9969b97a7992fc86a753fe9f20e3ffa3f7c.zip#pygooglevoice-sms==0.0.1 +# homeassistant.components.alarm_control_panel.simplisafe +https://github.com/w1ll1am23/simplisafe-python/archive/586fede0e85fd69e56e516aaa8e97eb644ca8866.zip#simplisafe-python==0.0.1 + # homeassistant.components.media_player.lg_netcast https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 @@ -170,6 +178,9 @@ insteon_hub==0.4.5 # homeassistant.components.media_player.kodi jsonrpc-requests==0.3 +# homeassistant.components.knx +knxip==0.3.0 + # homeassistant.components.light.lifx liffylights==0.9.4 @@ -227,7 +238,7 @@ psutil==4.3.0 # homeassistant.components.rollershutter.wink # homeassistant.components.sensor.wink # homeassistant.components.switch.wink -pubnub==3.7.6 +pubnub==3.8.2 # homeassistant.components.notify.pushbullet pushbullet.py==0.10.0 @@ -239,7 +250,7 @@ pushetta==1.0.15 py-cpuinfo==0.2.3 # homeassistant.components.rfxtrx -pyRFXtrx==0.8.0 +pyRFXtrx==0.9.0 # homeassistant.components.notify.xmpp pyasn1-modules==0.0.8 @@ -267,7 +278,7 @@ pyenvisalink==1.0 pyfttt==0.3 # homeassistant.components.homematic -pyhomematic==0.1.8 +pyhomematic==0.1.9 # homeassistant.components.device_tracker.icloud pyicloud==0.8.3 @@ -276,7 +287,7 @@ pyicloud==0.8.3 pylast==1.6.0 # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.13 +pyloopenergy==0.0.14 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 @@ -289,7 +300,7 @@ pynetio==0.1.6 pynx584==0.2 # homeassistant.components.sensor.openweathermap -pyowm==2.3.1 +pyowm==2.3.2 # homeassistant.components.switch.acer_projector pyserial<=3.0 @@ -307,6 +318,9 @@ python-forecastio==1.3.4 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 +# homeassistant.components.switch.mystrom +python-mystrom==0.3.6 + # homeassistant.components.nest python-nest==2.9.2 @@ -316,11 +330,8 @@ python-nmap==0.6.0 # homeassistant.components.notify.pushover python-pushover==0.2 -# homeassistant.components.statsd -python-statsd==1.7.2 - # homeassistant.components.notify.telegram -python-telegram-bot==4.2.1 +python-telegram-bot==4.3.3 # homeassistant.components.sensor.twitch python-twitch==1.2.0 @@ -333,7 +344,7 @@ python-twitch==1.2.0 # homeassistant.components.rollershutter.wink # homeassistant.components.sensor.wink # homeassistant.components.switch.wink -python-wink==0.7.8 +python-wink==0.7.10 # homeassistant.components.keyboard pyuserinput==0.1.9 @@ -366,7 +377,7 @@ scsgate==0.1.0 sendgrid>=1.6.0,<1.7.0 # homeassistant.components.notify.slack -slacker==0.9.17 +slacker==0.9.21 # homeassistant.components.notify.xmpp sleekxmpp==1.3.1 @@ -380,9 +391,15 @@ somecomfort==0.2.1 # homeassistant.components.sensor.speedtest speedtest-cli==0.3.4 +# homeassistant.components.recorder +sqlalchemy==1.0.14 + # homeassistant.components.http static3==0.7.0 +# homeassistant.components.statsd +statsd==3.2.1 + # homeassistant.components.sensor.steam_online steamodd==4.21 @@ -401,7 +418,7 @@ transmissionrpc==0.11 twilio==5.4.0 # homeassistant.components.sensor.uber -uber_rides==0.2.1 +uber_rides==0.2.4 # homeassistant.components.device_tracker.unifi unifi==1.2.5 @@ -424,8 +441,12 @@ websocket-client==0.37.0 # homeassistant.components.zigbee xbee-helper==0.0.7 +# homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.yr -xmltodict +xmltodict==0.10.2 + +# homeassistant.components.sensor.yweather +yahooweather==0.4 # homeassistant.components.zeroconf -zeroconf==0.17.5 +zeroconf==0.17.6 diff --git a/script/build_frontend b/script/build_frontend index 4765e7f8b7b..7b9dad05e79 100755 --- a/script/build_frontend +++ b/script/build_frontend @@ -2,14 +2,16 @@ cd "$(dirname "$0")/.." -cd homeassistant/components/frontend/www_static/home-assistant-polymer/home-assistant-js/ -npm run prod -cd .. +cd homeassistant/components/frontend/www_static/home-assistant-polymer npm run frontend_prod cp bower_components/webcomponentsjs/webcomponents-lite.min.js .. cp build/frontend.html .. gzip build/frontend.html -c -k -9 > ../frontend.html.gz +cp build/partial-map.html .. +gzip build/partial-map.html -c -k -9 > ../partial-map.html.gz +cp build/dev-tools.html .. +gzip build/dev-tools.html -c -k -9 > ../dev-tools.html.gz cp build/_core_compiled.js ../core.js gzip build/_core_compiled.js -c -k -9 > ../core.js.gz @@ -23,9 +25,13 @@ echo '"""DO NOT MODIFY. Auto-generated by build_frontend script."""' > version.p if [ $(command -v md5) ]; then echo 'CORE = "'`md5 -q www_static/core.js`'"' >> version.py echo 'UI = "'`md5 -q www_static/frontend.html`'"' >> version.py + echo 'MAP = "'`md5 -q www_static/partial-map.html`'"' >> version.py + echo 'DEV = "'`md5 -q www_static/dev-tools.html`'"' >> version.py elif [ $(command -v md5sum) ]; then echo 'CORE = "'`md5sum www_static/core.js | cut -c-32`'"' >> version.py echo 'UI = "'`md5sum www_static/frontend.html | cut -c-32`'"' >> version.py + echo 'MAP = "'`md5sum www_static/partial-map.html | cut -c-32`'"' >> version.py + echo 'DEV = "'`md5sum www_static/dev-tools.html | cut -c-32`'"' >> version.py else echo 'Could not find an MD5 utility' fi diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 872d13bab75..1fae3b92600 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -6,7 +6,7 @@ import pkgutil import re import sys -COMMENT_REQUIREMENTS = [ +COMMENT_REQUIREMENTS = ( 'RPi.GPIO', 'rpi-rf', 'Adafruit_Python_DHT', @@ -14,7 +14,11 @@ COMMENT_REQUIREMENTS = [ 'pybluez', 'bluepy', 'python-lirc', -] +) + +IGNORE_PACKAGES = ( + 'homeassistant.components.recorder.models', +) def explore_module(package, explore_children): @@ -59,7 +63,8 @@ def gather_modules(): try: module = importlib.import_module(package) except ImportError: - errors.append(package) + if package not in IGNORE_PACKAGES: + errors.append(package) continue if not getattr(module, 'REQUIREMENTS', None): diff --git a/setup.py b/setup.py index fbce912c3d6..9eae8962cf1 100755 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ REQUIRES = [ 'pip>=7.0.0', 'jinja2>=2.8', 'voluptuous==0.8.9', + 'sqlalchemy==1.0.14', ] setup( diff --git a/tests/__init__.py b/tests/__init__.py index a931604fdce..2c44763f234 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,9 +1,13 @@ """Setup some common test helper things.""" import functools +import logging from homeassistant import util from homeassistant.util import location +logging.basicConfig() +logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + def test_real(func): """Force a function to require a keyword _test_real to be passed in.""" diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index e96757831a7..388fe7db415 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import patch from datetime import datetime, timedelta import os +import tempfile from homeassistant.loader import get_component import homeassistant.util.dt as dt_util @@ -45,6 +46,18 @@ class TestComponentsDeviceTracker(unittest.TestCase): self.assertFalse(device_tracker.is_on(self.hass, entity_id)) + def test_reading_broken_yaml_config(self): + """Test when known devices contains invalid data.""" + with tempfile.NamedTemporaryFile() as fp: + # file is empty + assert device_tracker.load_config(fp.name, None, False, 0) == [] + + fp.write('100'.encode('utf-8')) + fp.flush() + + # file contains a non-dict format + assert device_tracker.load_config(fp.name, None, False, 0) == [] + def test_reading_yaml_config(self): """Test the rendering of the YAML configuration.""" dev_id = 'test' diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index 913097d17f1..e3f64cc84c3 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -24,7 +24,7 @@ class TestUnifiScanner(unittest.TestCase): result = unifi.get_scanner(None, config) self.assertEqual(unifi.UnifiScanner.return_value, result) mock_ctrl.assert_called_once_with('localhost', 'foo', 'password', - 8443, 'v4') + 8443, 'v4', 'default') mock_scanner.assert_called_once_with(mock_ctrl.return_value) @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') @@ -37,12 +37,13 @@ class TestUnifiScanner(unittest.TestCase): CONF_PASSWORD: 'password', CONF_HOST: 'myhost', 'port': 123, + 'site_id': 'abcdef01', } } result = unifi.get_scanner(None, config) self.assertEqual(unifi.UnifiScanner.return_value, result) mock_ctrl.assert_called_once_with('myhost', 'foo', 'password', - 123, 'v4') + 123, 'v4', 'abcdef01') mock_scanner.assert_called_once_with(mock_ctrl.return_value) @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index 82133a1d484..aa7350ff930 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -38,6 +38,15 @@ class TestDemoMediaPlayer(unittest.TestCase): state = self.hass.states.get(entity_id) assert 'xbox' == state.attributes.get('source') + def test_clear_playlist(self): + """Test clear playlist.""" + assert mp.setup(self.hass, {'media_player': {'platform': 'demo'}}) + assert self.hass.states.is_state(entity_id, 'playing') + + mp.clear_playlist(self.hass, entity_id) + self.hass.pool.block_till_done() + assert self.hass.states.is_state(entity_id, 'off') + def test_volume_services(self): """Test the volume service.""" assert mp.setup(self.hass, {'media_player': {'platform': 'demo'}}) diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index a6de09a3eb1..b7018945551 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -25,6 +25,7 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): self._media_title = None self._supported_media_commands = 0 self._source = None + self._tracks = 12 self.service_calls = { 'turn_on': mock_service( @@ -59,6 +60,9 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): 'select_source': mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE), + 'clear_playlist': mock_service( + hass, media_player.DOMAIN, + media_player.SERVICE_CLEAR_PLAYLIST), } @property @@ -114,6 +118,10 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): """Set the input source.""" self._state = source + def clear_playlist(self): + """Clear players playlist.""" + self._tracks = 0 + class TestMediaPlayer(unittest.TestCase): """Test the media_player module.""" @@ -510,6 +518,10 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual( 1, len(self.mock_mp_2.service_calls['select_source'])) + ump.clear_playlist() + self.assertEqual( + 1, len(self.mock_mp_2.service_calls['clear_playlist'])) + def test_service_call_to_command(self): """Test service call to command.""" config = self.config_children_only diff --git a/tests/components/mqtt/__init__.py b/tests/components/mqtt/__init__.py new file mode 100644 index 00000000000..d5906361541 --- /dev/null +++ b/tests/components/mqtt/__init__.py @@ -0,0 +1 @@ +"""Tests for MQTT component.""" diff --git a/tests/components/recorder/__init__.py b/tests/components/recorder/__init__.py new file mode 100644 index 00000000000..fca6a655ba4 --- /dev/null +++ b/tests/components/recorder/__init__.py @@ -0,0 +1 @@ +"""Tests for Recorder component.""" diff --git a/tests/components/test_recorder.py b/tests/components/recorder/test_init.py similarity index 63% rename from tests/components/test_recorder.py rename to tests/components/recorder/test_init.py index 0577ab27889..7519443f1e4 100644 --- a/tests/components/test_recorder.py +++ b/tests/components/recorder/test_init.py @@ -1,8 +1,8 @@ """The tests for the Recorder component.""" # pylint: disable=too-many-public-methods,protected-access import unittest -import time import json +from datetime import datetime, timedelta from unittest.mock import patch from homeassistant.const import MATCH_ALL @@ -17,24 +17,29 @@ class TestRecorder(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - with patch('homeassistant.core.Config.path', return_value=':memory:'): - recorder.setup(self.hass, {}) + db_uri = 'sqlite://' + with patch('homeassistant.core.Config.path', return_value=db_uri): + recorder.setup(self.hass, config={ + "recorder": { + "db_url": db_uri}}) self.hass.start() + recorder._INSTANCE.block_till_db_ready() + self.session = recorder.Session() recorder._INSTANCE.block_till_done() def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() - recorder._INSTANCE.block_till_done() def _add_test_states(self): """Add multiple states to the db for testing.""" - now = int(time.time()) - five_days_ago = now - (60*60*24*5) + now = datetime.now() + five_days_ago = now - timedelta(days=5) attributes = {'test_attr': 5, 'test_attr_10': 'nice'} self.hass.pool.block_till_done() recorder._INSTANCE.block_till_done() + for event_id in range(5): if event_id < 3: timestamp = five_days_ago @@ -42,19 +47,24 @@ class TestRecorder(unittest.TestCase): else: timestamp = now state = 'dontpurgeme' - recorder.query("INSERT INTO states (" - "entity_id, domain, state, attributes," - "last_changed, last_updated, created," - "utc_offset, event_id)" - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ('test.recorder2', 'sensor', state, - json.dumps(attributes), timestamp, timestamp, - timestamp, -18000, event_id + 1000)) + + self.session.add(recorder.get_model('States')( + entity_id='test.recorder2', + domain='sensor', + state=state, + attributes=json.dumps(attributes), + last_changed=timestamp, + last_updated=timestamp, + created=timestamp, + event_id=event_id + 1000 + )) + + self.session.commit() def _add_test_events(self): """Add a few events for testing.""" - now = int(time.time()) - five_days_ago = now - (60*60*24*5) + now = datetime.now() + five_days_ago = now - timedelta(days=5) event_data = {'test_attr': 5, 'test_attr_10': 'nice'} self.hass.pool.block_till_done() @@ -66,12 +76,14 @@ class TestRecorder(unittest.TestCase): else: timestamp = now event_type = 'EVENT_TEST' - recorder.query("INSERT INTO events" - "(event_type, event_data, origin, created," - "time_fired, utc_offset)" - "VALUES (?, ?, ?, ?, ?, ?)", - (event_type, json.dumps(event_data), 'LOCAL', - timestamp, timestamp, -18000)) + + self.session.add(recorder.get_model('Events')( + event_type=event_type, + event_data=json.dumps(event_data), + origin='LOCAL', + created=timestamp, + time_fired=timestamp, + )) def test_saving_state(self): """Test saving and restoring a state.""" @@ -84,7 +96,10 @@ class TestRecorder(unittest.TestCase): self.hass.pool.block_till_done() recorder._INSTANCE.block_till_done() - states = recorder.query_states('SELECT * FROM states') + db_states = recorder.query('States') + states = recorder.execute(db_states) + + assert db_states[0].event_id is not None self.assertEqual(1, len(states)) self.assertEqual(self.hass.states.get(entity_id), states[0]) @@ -108,8 +123,9 @@ class TestRecorder(unittest.TestCase): self.hass.pool.block_till_done() recorder._INSTANCE.block_till_done() - db_events = recorder.query_events( - 'SELECT * FROM events WHERE event_type = ?', (event_type, )) + db_events = recorder.execute( + recorder.query('Events').filter_by( + event_type=event_type)) assert len(events) == 1 assert len(db_events) == 1 @@ -129,51 +145,45 @@ class TestRecorder(unittest.TestCase): """Test deleting old states.""" self._add_test_states() # make sure we start with 5 states - states = recorder.query_states('SELECT * FROM states') - self.assertEqual(len(states), 5) + states = recorder.query('States') + self.assertEqual(states.count(), 5) # run purge_old_data() recorder._INSTANCE.purge_days = 4 recorder._INSTANCE._purge_old_data() # we should only have 2 states left after purging - states = recorder.query_states('SELECT * FROM states') - self.assertEqual(len(states), 2) + self.assertEqual(states.count(), 2) def test_purge_old_events(self): """Test deleting old events.""" self._add_test_events() - events = recorder.query_events('SELECT * FROM events WHERE ' - 'event_type LIKE "EVENT_TEST%"') - self.assertEqual(len(events), 5) + events = recorder.query('Events').filter( + recorder.get_model('Events').event_type.like("EVENT_TEST%")) + self.assertEqual(events.count(), 5) # run purge_old_data() recorder._INSTANCE.purge_days = 4 recorder._INSTANCE._purge_old_data() # now we should only have 3 events left - events = recorder.query_events('SELECT * FROM events WHERE ' - 'event_type LIKE "EVENT_TEST%"') - self.assertEqual(len(events), 3) + self.assertEqual(events.count(), 3) def test_purge_disabled(self): """Test leaving purge_days disabled.""" self._add_test_states() self._add_test_events() # make sure we start with 5 states and events - states = recorder.query_states('SELECT * FROM states') - events = recorder.query_events('SELECT * FROM events WHERE ' - 'event_type LIKE "EVENT_TEST%"') - self.assertEqual(len(states), 5) - self.assertEqual(len(events), 5) + states = recorder.query('States') + events = recorder.query('Events').filter( + recorder.get_model('Events').event_type.like("EVENT_TEST%")) + self.assertEqual(states.count(), 5) + self.assertEqual(events.count(), 5) # run purge_old_data() recorder._INSTANCE.purge_days = None recorder._INSTANCE._purge_old_data() # we should have all of our states still - states = recorder.query_states('SELECT * FROM states') - events = recorder.query_events('SELECT * FROM events WHERE ' - 'event_type LIKE "EVENT_TEST%"') - self.assertEqual(len(states), 5) - self.assertEqual(len(events), 5) + self.assertEqual(states.count(), 5) + self.assertEqual(events.count(), 5) diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py new file mode 100644 index 00000000000..55c3e019f15 --- /dev/null +++ b/tests/components/recorder/test_models.py @@ -0,0 +1,140 @@ +"""The tests for the Recorder component.""" +# pylint: disable=too-many-public-methods,protected-access +import unittest +from datetime import datetime + +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker + +import homeassistant.core as ha +from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.util import dt +from homeassistant.components.recorder.models import ( + Base, Events, States, RecorderRuns) + +engine = None +Session = None + + +def setUpModule(): + """Set up a database to use.""" + global engine, Session + + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(bind=engine) + Session = scoped_session(session_factory) + + +def tearDownModule(): + """Close database.""" + global engine, Session + + engine.dispose() + engine = None + Session = None + + +class TestEvents(unittest.TestCase): + """Test Events model.""" + + def test_from_event(self): + """Test converting event to db event.""" + event = ha.Event('test_event', { + 'some_data': 15 + }) + assert event == Events.from_event(event).to_native() + + +class TestStates(unittest.TestCase): + """Test States model.""" + + def test_from_event(self): + """Test converting event to db state.""" + state = ha.State('sensor.temperature', '18') + event = ha.Event(EVENT_STATE_CHANGED, { + 'entity_id': 'sensor.temperature', + 'old_state': None, + 'new_state': state, + }) + assert state == States.from_event(event).to_native() + + def test_from_event_to_delete_state(self): + """Test converting deleting state event to db state.""" + event = ha.Event(EVENT_STATE_CHANGED, { + 'entity_id': 'sensor.temperature', + 'old_state': ha.State('sensor.temperature', '18'), + 'new_state': None, + }) + db_state = States.from_event(event) + + assert db_state.entity_id == 'sensor.temperature' + assert db_state.domain == 'sensor' + assert db_state.state == '' + assert db_state.last_changed == event.time_fired + assert db_state.last_updated == event.time_fired + + +class TestRecorderRuns(unittest.TestCase): + """Test recorder run model.""" + + def setUp(self): + """Set up recorder runs.""" + self.session = session = Session() + session.query(Events).delete() + session.query(States).delete() + session.query(RecorderRuns).delete() + + def tearDown(self): + """Clean up.""" + self.session.rollback() + + def test_entity_ids(self): + """Test if entity ids helper method works.""" + run = RecorderRuns( + start=datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC), + end=datetime(2016, 7, 9, 23, 0, 0, tzinfo=dt.UTC), + closed_incorrect=False, + created=datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC), + ) + + self.session.add(run) + self.session.commit() + + before_run = datetime(2016, 7, 9, 8, 0, 0, tzinfo=dt.UTC) + in_run = datetime(2016, 7, 9, 13, 0, 0, tzinfo=dt.UTC) + in_run2 = datetime(2016, 7, 9, 15, 0, 0, tzinfo=dt.UTC) + in_run3 = datetime(2016, 7, 9, 18, 0, 0, tzinfo=dt.UTC) + after_run = datetime(2016, 7, 9, 23, 30, 0, tzinfo=dt.UTC) + + assert run.to_native() == run + assert run.entity_ids() == [] + + self.session.add(States( + entity_id='sensor.temperature', + state='20', + last_changed=before_run, + last_updated=before_run, + )) + self.session.add(States( + entity_id='sensor.sound', + state='10', + last_changed=after_run, + last_updated=after_run, + )) + + self.session.add(States( + entity_id='sensor.humidity', + state='76', + last_changed=in_run, + last_updated=in_run, + )) + self.session.add(States( + entity_id='sensor.lux', + state='5', + last_changed=in_run3, + last_updated=in_run3, + )) + + assert sorted(run.entity_ids()) == ['sensor.humidity', 'sensor.lux'] + assert run.entity_ids(in_run2) == ['sensor.humidity'] diff --git a/tests/components/sensor/test_rfxtrx.py b/tests/components/sensor/test_rfxtrx.py index ab5d9852d9d..6714dc70428 100644 --- a/tests/components/sensor/test_rfxtrx.py +++ b/tests/components/sensor/test_rfxtrx.py @@ -44,12 +44,7 @@ class TestSensorRfxtrx(unittest.TestCase): entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature'] self.assertEqual('Test', entity.name) self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement) - self.assertEqual(14.9, entity.state) - self.assertEqual({'Humidity status': 'normal', 'Temperature': 14.9, - 'Rssi numeric': 6, 'Humidity': 34, - 'Battery numeric': 9, - 'Humidity status numeric': 2}, - entity.device_state_attributes) + self.assertEqual(None, entity.state) def test_one_sensor(self): """Test with 1 sensor.""" @@ -64,12 +59,7 @@ class TestSensorRfxtrx(unittest.TestCase): entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature'] self.assertEqual('Test', entity.name) self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement) - self.assertEqual(14.9, entity.state) - self.assertEqual({'Humidity status': 'normal', 'Temperature': 14.9, - 'Rssi numeric': 6, 'Humidity': 34, - 'Battery numeric': 9, - 'Humidity status numeric': 2}, - entity.device_state_attributes) + self.assertEqual(None, entity.state) def test_one_sensor_no_datatype(self): """Test with 1 sensor.""" @@ -83,12 +73,7 @@ class TestSensorRfxtrx(unittest.TestCase): entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature'] self.assertEqual('Test', entity.name) self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement) - self.assertEqual(14.9, entity.state) - self.assertEqual({'Humidity status': 'normal', 'Temperature': 14.9, - 'Rssi numeric': 6, 'Humidity': 34, - 'Battery numeric': 9, - 'Humidity status numeric': 2}, - entity.device_state_attributes) + self.assertEqual(None, entity.state) def test_several_sensors(self): """Test with 3 sensors.""" @@ -112,35 +97,16 @@ class TestSensorRfxtrx(unittest.TestCase): _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature'] _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity'] self.assertEqual('%', _entity_hum.unit_of_measurement) - self.assertEqual(14, _entity_hum.state) - self.assertEqual({'Battery numeric': 9, 'Temperature': 25.5, - 'Humidity': 14, 'Humidity status': 'normal', - 'Humidity status numeric': 2, - 'Rssi numeric': 6}, - _entity_hum.device_state_attributes) self.assertEqual('Bath', _entity_hum.__str__()) - + self.assertEqual(None, _entity_hum.state) self.assertEqual(TEMP_CELSIUS, _entity_temp.unit_of_measurement) - self.assertEqual(25.5, _entity_temp.state) - self.assertEqual({'Battery numeric': 9, 'Temperature': 25.5, - 'Humidity': 14, 'Humidity status': 'normal', - 'Humidity status numeric': 2, - 'Rssi numeric': 6}, - _entity_temp.device_state_attributes) self.assertEqual('Bath', _entity_temp.__str__()) elif id == 'sensor_0502': device_num = device_num + 1 entity = rfxtrx_core.RFX_DEVICES[id]['Temperature'] - + self.assertEqual(None, entity.state) self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement) - self.assertEqual(14.9, entity.state) - self.assertEqual({'Humidity status': 'normal', - 'Temperature': 14.9, - 'Rssi numeric': 6, 'Humidity': 34, - 'Battery numeric': 9, - 'Humidity status numeric': 2}, - entity.device_state_attributes) self.assertEqual('Test', entity.__str__()) self.assertEqual(2, device_num) @@ -252,35 +218,16 @@ class TestSensorRfxtrx(unittest.TestCase): _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature'] _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity'] self.assertEqual('%', _entity_hum.unit_of_measurement) - self.assertEqual(14, _entity_hum.state) - self.assertEqual({'Battery numeric': 9, 'Temperature': 25.5, - 'Humidity': 14, 'Humidity status': 'normal', - 'Humidity status numeric': 2, - 'Rssi numeric': 6}, - _entity_hum.device_state_attributes) self.assertEqual('Bath', _entity_hum.__str__()) - + self.assertEqual(None, _entity_temp.state) self.assertEqual(TEMP_CELSIUS, _entity_temp.unit_of_measurement) - self.assertEqual(25.5, _entity_temp.state) - self.assertEqual({'Battery numeric': 9, 'Temperature': 25.5, - 'Humidity': 14, 'Humidity status': 'normal', - 'Humidity status numeric': 2, - 'Rssi numeric': 6}, - _entity_temp.device_state_attributes) self.assertEqual('Bath', _entity_temp.__str__()) elif id == 'sensor_0502': device_num = device_num + 1 entity = rfxtrx_core.RFX_DEVICES[id]['Temperature'] - + self.assertEqual(None, entity.state) self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement) - self.assertEqual(14.9, entity.state) - self.assertEqual({'Humidity status': 'normal', - 'Temperature': 14.9, - 'Rssi numeric': 6, 'Humidity': 34, - 'Battery numeric': 9, - 'Humidity status numeric': 2}, - entity.device_state_attributes) self.assertEqual('Test', entity.__str__()) self.assertEqual(2, device_num) diff --git a/tests/components/switch/test_rfxtrx.py b/tests/components/switch/test_rfxtrx.py index a73c843533c..8a36072304b 100644 --- a/tests/components/switch/test_rfxtrx.py +++ b/tests/components/switch/test_rfxtrx.py @@ -34,6 +34,17 @@ class TestSwitchRfxtrx(unittest.TestCase): rfxtrx_core.ATTR_FIREEVENT: True} }}})) + def test_valid_config_int_device_id(self): + """Test configuration.""" + self.assertTrue(_setup_component(self.hass, 'switch', { + 'switch': {'platform': 'rfxtrx', + 'automatic_add': True, + 'devices': + {'710000141010170': { + 'name': 'Test', + rfxtrx_core.ATTR_FIREEVENT: True} + }}})) + def test_invalid_config1(self): self.assertFalse(_setup_component(self.hass, 'switch', { 'switch': {'platform': 'rfxtrx', diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 77dc24655ba..447629ee070 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -25,9 +25,13 @@ class TestComponentHistory(unittest.TestCase): def init_recorder(self): """Initialize the recorder.""" - with patch('homeassistant.core.Config.path', return_value=':memory:'): - recorder.setup(self.hass, {}) + db_uri = 'sqlite://' + with patch('homeassistant.core.Config.path', return_value=db_uri): + recorder.setup(self.hass, config={ + "recorder": { + "db_url": db_uri}}) self.hass.start() + recorder._INSTANCE.block_till_db_ready() self.wait_recording_done() def wait_recording_done(self): diff --git a/tests/components/test_statsd.py b/tests/components/test_statsd.py index 7b0ca2e2f19..6209f1986f5 100644 --- a/tests/components/test_statsd.py +++ b/tests/components/test_statsd.py @@ -10,9 +10,8 @@ from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED class TestStatsd(unittest.TestCase): """Test the StatsD component.""" - @mock.patch('statsd.Connection') - @mock.patch('statsd.Gauge') - def test_statsd_setup_full(self, mock_gauge, mock_connection): + @mock.patch('statsd.StatsClient') + def test_statsd_setup_full(self, mock_connection): """Test setup with all data.""" config = { 'statsd': { @@ -24,18 +23,17 @@ class TestStatsd(unittest.TestCase): } hass = mock.MagicMock() self.assertTrue(statsd.setup(hass, config)) - mock_connection.assert_called_once_with(host='host', port=123, - sample_rate=1, - disabled=False) - mock_gauge.assert_called_once_with('foo', - mock_connection.return_value) + mock_connection.assert_called_once_with( + host='host', + port=123, + prefix='foo') + self.assertTrue(hass.bus.listen.called) self.assertEqual(EVENT_STATE_CHANGED, hass.bus.listen.call_args_list[0][0][0]) - @mock.patch('statsd.Connection') - @mock.patch('statsd.Gauge') - def test_statsd_setup_defaults(self, mock_gauge, mock_connection): + @mock.patch('statsd.StatsClient') + def test_statsd_setup_defaults(self, mock_connection): """Test setup with defaults.""" config = { 'statsd': { @@ -47,15 +45,11 @@ class TestStatsd(unittest.TestCase): mock_connection.assert_called_once_with( host='host', port=statsd.DEFAULT_PORT, - sample_rate=statsd.DEFAULT_RATE, - disabled=False) - mock_gauge.assert_called_once_with(statsd.DEFAULT_PREFIX, - mock_connection.return_value) + prefix=statsd.DEFAULT_PREFIX) self.assertTrue(hass.bus.listen.called) - @mock.patch('statsd.Connection') - @mock.patch('statsd.Gauge') - def test_event_listener(self, mock_gauge, mock_connection): + @mock.patch('statsd.StatsClient') + def test_event_listener_defaults(self, mock_client): """Test event listener.""" config = { 'statsd': { @@ -72,13 +66,62 @@ class TestStatsd(unittest.TestCase): STATE_ON: 1, STATE_OFF: 0} for in_, out in valid.items(): - state = mock.MagicMock(state=in_) + state = mock.MagicMock(state=in_, + attributes={"attribute key": 3.2}) handler_method(mock.MagicMock(data={'new_state': state})) - mock_gauge.return_value.send.assert_called_once_with( - state.entity_id, out) - mock_gauge.return_value.send.reset_mock() + mock_client.return_value.gauge.assert_has_calls([ + mock.call(state.entity_id, out, statsd.DEFAULT_RATE), + ]) + + mock_client.return_value.gauge.reset_mock() + + mock_client.return_value.incr.assert_called_once_with( + state.entity_id, rate=statsd.DEFAULT_RATE) + mock_client.return_value.incr.reset_mock() for invalid in ('foo', '', object): handler_method(mock.MagicMock(data={ 'new_state': ha.State('domain.test', invalid, {})})) - self.assertFalse(mock_gauge.return_value.send.called) + self.assertFalse(mock_client.return_value.gauge.called) + self.assertFalse(mock_client.return_value.incr.called) + + @mock.patch('statsd.StatsClient') + def test_event_listener_attr_details(self, mock_client): + """Test event listener.""" + config = { + 'statsd': { + 'host': 'host', + 'log_attributes': True + } + } + hass = mock.MagicMock() + statsd.setup(hass, config) + self.assertTrue(hass.bus.listen.called) + handler_method = hass.bus.listen.call_args_list[0][0][1] + + valid = {'1': 1, + '1.0': 1.0, + STATE_ON: 1, + STATE_OFF: 0} + for in_, out in valid.items(): + state = mock.MagicMock(state=in_, + attributes={"attribute key": 3.2}) + handler_method(mock.MagicMock(data={'new_state': state})) + mock_client.return_value.gauge.assert_has_calls([ + mock.call("%s.state" % state.entity_id, + out, statsd.DEFAULT_RATE), + mock.call("%s.attribute_key" % state.entity_id, + 3.2, statsd.DEFAULT_RATE), + ]) + + mock_client.return_value.gauge.reset_mock() + + mock_client.return_value.incr.assert_called_once_with( + state.entity_id, rate=statsd.DEFAULT_RATE) + mock_client.return_value.incr.reset_mock() + + for invalid in ('foo', '', object): + handler_method(mock.MagicMock(data={ + 'new_state': ha.State('domain.test', invalid, {})})) + self.assertFalse(mock_client.return_value.gauge.called) + self.assertFalse(mock_client.return_value.incr.called) diff --git a/tests/test_config.py b/tests/test_config.py index 6be3f585967..0a6321c2240 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,7 @@ from tests.common import ( CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) +VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -41,6 +42,9 @@ class TestConfig(unittest.TestCase): if os.path.isfile(YAML_PATH): os.remove(YAML_PATH) + if os.path.isfile(VERSION_PATH): + os.remove(VERSION_PATH) + if hasattr(self, 'hass'): self.hass.stop() diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 884b59ec10e..50bee79283e 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -75,6 +75,58 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((255, 255, 255), color_util.color_name_to_rgb('not a color')) + def test_color_rgb_to_rgbw(self): + """Test color_rgb_to_rgbw.""" + self.assertEqual((0, 0, 0, 0), + color_util.color_rgb_to_rgbw(0, 0, 0)) + + self.assertEqual((0, 0, 0, 255), + color_util.color_rgb_to_rgbw(255, 255, 255)) + + self.assertEqual((255, 0, 0, 0), + color_util.color_rgb_to_rgbw(255, 0, 0)) + + self.assertEqual((0, 255, 0, 0), + color_util.color_rgb_to_rgbw(0, 255, 0)) + + self.assertEqual((0, 0, 255, 0), + color_util.color_rgb_to_rgbw(0, 0, 255)) + + self.assertEqual((255, 127, 0, 0), + color_util.color_rgb_to_rgbw(255, 127, 0)) + + self.assertEqual((255, 0, 0, 253), + color_util.color_rgb_to_rgbw(255, 127, 127)) + + self.assertEqual((0, 0, 0, 127), + color_util.color_rgb_to_rgbw(127, 127, 127)) + + def test_color_rgbw_to_rgb(self): + """Test color_rgbw_to_rgb.""" + self.assertEqual((0, 0, 0), + color_util.color_rgbw_to_rgb(0, 0, 0, 0)) + + self.assertEqual((255, 255, 255), + color_util.color_rgbw_to_rgb(0, 0, 0, 255)) + + self.assertEqual((255, 0, 0), + color_util.color_rgbw_to_rgb(255, 0, 0, 0)) + + self.assertEqual((0, 255, 0), + color_util.color_rgbw_to_rgb(0, 255, 0, 0)) + + self.assertEqual((0, 0, 255), + color_util.color_rgbw_to_rgb(0, 0, 255, 0)) + + self.assertEqual((255, 127, 0), + color_util.color_rgbw_to_rgb(255, 127, 0, 0)) + + self.assertEqual((255, 127, 127), + color_util.color_rgbw_to_rgb(255, 0, 0, 253)) + + self.assertEqual((127, 127, 127), + color_util.color_rgbw_to_rgb(0, 0, 0, 127)) + class ColorTemperatureMiredToKelvinTests(unittest.TestCase): """Test color_temperature_mired_to_kelvin."""